44 커밋 3e6474bbd9 ... fde85986f4

작성자 SHA1 메시지 날짜
  tsk fde85986f4 feat: bump to 0.15.1 (#1658) 5 일 전
  tsk 6710a35a9b feat: limit checks (#1657) 5 일 전
  github-actions[bot] 963a620310 Weekly Meeting Agenda - 2026-02-18 (#1655) 6 일 전
  tsk 9c040ad98e fix: build_with_seed with custom paths (#1654) 6 일 전
  tsk f0310669d9 chore: bump to 0.15.0 (#1652) 1 주 전
  tsk 89f9df168b chore: clean up logging of config (#1650) 1 주 전
  tsk 48845ed3d8 feat: move input limit check to verify_inputs (#1649) 1 주 전
  tsk bd33f6cec9 fix: use more checked math and disallow auth unit (#1647) 1 주 전
  tsk daf118851f feat: add better db logging (#1640) 1 주 전
  tsk ea0a0a8be4 feat: bump to 0.15.0-rc.3 (#1639) 1 주 전
  tsk 9071389324 feat: add tests for sat sub (#1632) 1 주 전
  tsk 879848581d fix: downgrade native-tls to 0.2.14 (#1636) 1 주 전
  tsk 3bfacdecfb fix: release kotlin trigger workflow name (#1635) 1 주 전
  tsk bfae0f9c5a feat: bump to 0.15.0-rc.2 (#1634) 1 주 전
  tsk b53dbe8478 feat: have check mint quote return MintQuote in bindings (#1633) 1 주 전
  tsk 946e525873 Rename ffi to match cdk (#1631) 1 주 전
  tsk f18db60770 fix: ffi get_balances returns WalletKey (#1630) 1 주 전
  tsk c5213bfd3f feat: rename refresh to check mint quote (#1629) 1 주 전
  tsk 8e67b3e7fa chore: bump 0.15.0-rc.1 (#1628) 1 주 전
  tsk 167dfd105e fix: android logging ffi change (#1627) 1 주 전
  tsk 5294288cc5 fix: account for swap fee in select_proofs when force_swap is true (#1626) 1 주 전
  a1denvalu3 93e80f3cbd feat: track cdk version in keysets (#1556) 1 주 전
  cdk-bot 65612d8e40 2026-02-13 automated rustfmt nightly (#1624) 1 주 전
  tsk d0b9bd5c5f Prepare v0.15 (#1623) 1 주 전
  tsk 64627a847e feat: aaga post mmw cleanup (#1622) 1 주 전
  tsk 03a5a914b8 feat: add grpc version header to ensure client and server match (#1617) 1 주 전
  asmo b809131544 feat(wallet): remove multi mint wallet (#1582) 1 주 전
  tsk fb3973750f feat: replace async header with prefer in body (#1618) 1 주 전
  github-actions[bot] a729ba271c Weekly Meeting Agenda - 2026-02-11 (#1620) 1 주 전
  tsk daf141a02e Nut26 clean up (#1621) 1 주 전
  tsk eae8f6490b feat: glob pattern for nut21/22 (#1586) 2 주 전
  tsk 3ff42c7bbb feat: add error code for input and output limits (#1619) 2 주 전
  tsk 1f084ed34d fix: make fee needs to be converted in the payment backend (#1614) 2 주 전
  tsk 87f555629f feat: add extra stored state to melt mint saga (#1613) 2 주 전
  tsk 89c91f6c6c docs: add better example docs of wallet start up (#1612) 2 주 전
  tsk d4eb35e2a9 chore: cashu crate default features off (#1565) 2 주 전
  tsk 4704817b83 chore: add tests to kill mutations (#1611) 2 주 전
  tsk b4b92a974d chore: update rust in rust file (#1610) 2 주 전
  asmo a6dfadfa47 feat: Add more ldk-node config settings (#1010) 2 주 전
  tsk 0e02b7c0ff chore: clippy warnings (#1606) 2 주 전
  gudnuf ed9d9e6d80 fix: add Prefer header to CORS allowed headers (#1607) 2 주 전
  asmo 11a39b6c4b feat(wallet): fetch_mint_quote (#1569) 2 주 전
  tsk 5af581d121 feat: wallet async melt (#1600) 2 주 전
  C 1a1b701bc5 Correct SQLite connection pool size check (#1605) 2 주 전
100개의 변경된 파일4708개의 추가작업 그리고 2920개의 파일을 삭제
  1. 3 0
      .cargo-mutants.toml
  2. 7 0
      .cargo/mutants.toml
  3. 102 50
      CHANGELOG.md
  4. 159 135
      Cargo.lock
  5. 160 136
      Cargo.lock.msrv
  6. 22 22
      Cargo.toml
  7. 0 1
      crates/cashu/Cargo.toml
  8. 108 0
      crates/cashu/src/amount.rs
  9. 471 47
      crates/cashu/src/nuts/auth/nut21.rs
  10. 6 9
      crates/cashu/src/nuts/auth/nut22.rs
  11. 60 0
      crates/cashu/src/nuts/nut00/mod.rs
  12. 6 1
      crates/cashu/src/nuts/nut04.rs
  13. 17 0
      crates/cashu/src/nuts/nut05.rs
  14. 6 1
      crates/cashu/src/nuts/nut06.rs
  15. 2 0
      crates/cashu/src/nuts/nut10.rs
  16. 0 4
      crates/cashu/src/nuts/nut18/transport.rs
  17. 6 1
      crates/cashu/src/nuts/nut23.rs
  18. 84 97
      crates/cashu/src/nuts/nut26/encoding.rs
  19. 6 3
      crates/cashu/src/nuts/nut26/error.rs
  20. 12 0
      crates/cashu/src/secret.rs
  21. 1 0
      crates/cashu/src/util/mod.rs
  22. 66 0
      crates/cashu/src/util/serde_helpers.rs
  23. 4 1
      crates/cdk-axum/src/custom_handlers.rs
  24. 1 1
      crates/cdk-axum/src/lib.rs
  25. 51 53
      crates/cdk-cli/src/main.rs
  26. 26 17
      crates/cdk-cli/src/sub_commands/balance.rs
  27. 6 8
      crates/cdk-cli/src/sub_commands/burn.rs
  28. 6 9
      crates/cdk-cli/src/sub_commands/cat_device_login.rs
  29. 6 9
      crates/cdk-cli/src/sub_commands/cat_login.rs
  30. 3 3
      crates/cdk-cli/src/sub_commands/check_pending.rs
  31. 7 5
      crates/cdk-cli/src/sub_commands/create_request.rs
  32. 5 5
      crates/cdk-cli/src/sub_commands/list_mint_proofs.rs
  33. 303 284
      crates/cdk-cli/src/sub_commands/melt.rs
  34. 5 4
      crates/cdk-cli/src/sub_commands/mint.rs
  35. 45 52
      crates/cdk-cli/src/sub_commands/mint_blind_auth.rs
  36. 38 30
      crates/cdk-cli/src/sub_commands/npubcash.rs
  37. 3 3
      crates/cdk-cli/src/sub_commands/pay_request.rs
  38. 11 4
      crates/cdk-cli/src/sub_commands/pending_mints.rs
  39. 28 38
      crates/cdk-cli/src/sub_commands/receive.rs
  40. 7 13
      crates/cdk-cli/src/sub_commands/restore.rs
  41. 32 88
      crates/cdk-cli/src/sub_commands/send.rs
  42. 95 74
      crates/cdk-cli/src/sub_commands/transfer.rs
  43. 10 5
      crates/cdk-cli/src/sub_commands/update_mint_url.rs
  44. 12 14
      crates/cdk-cli/src/utils.rs
  45. 19 2
      crates/cdk-cln/src/lib.rs
  46. 2 0
      crates/cdk-common/Cargo.toml
  47. 230 8
      crates/cdk-common/src/common.rs
  48. 11 0
      crates/cdk-common/src/database/mint/test/keys.rs
  49. 2 0
      crates/cdk-common/src/database/mint/test/mod.rs
  50. 38 13
      crates/cdk-common/src/error.rs
  51. 45 0
      crates/cdk-common/src/grpc.rs
  52. 12 0
      crates/cdk-common/src/lib.rs
  53. 8 0
      crates/cdk-common/src/mint.rs
  54. 3 1
      crates/cdk-common/src/wallet/mod.rs
  55. 2 2
      crates/cdk-ffi/src/lib.rs
  56. 4 2
      crates/cdk-ffi/src/logging.rs
  57. 0 1178
      crates/cdk-ffi/src/multi_mint_wallet.rs
  58. 2 2
      crates/cdk-ffi/src/npubcash.rs
  59. 1 1
      crates/cdk-ffi/src/types/amount.rs
  60. 1 14
      crates/cdk-ffi/src/types/payment_request.rs
  61. 30 0
      crates/cdk-ffi/src/types/wallet.rs
  62. 29 5
      crates/cdk-ffi/src/wallet.rs
  63. 277 0
      crates/cdk-ffi/src/wallet_repository.rs
  64. 4 3
      crates/cdk-integration-tests/src/bin/start_regtest.rs
  65. 68 7
      crates/cdk-integration-tests/src/bin/start_regtest_mints.rs
  66. 65 0
      crates/cdk-integration-tests/src/init_pure_tests.rs
  67. 751 10
      crates/cdk-integration-tests/tests/async_melt.rs
  68. 6 6
      crates/cdk-integration-tests/tests/bolt12.rs
  69. 12 17
      crates/cdk-integration-tests/tests/fake_wallet.rs
  70. 2 2
      crates/cdk-integration-tests/tests/ffi_minting_integration.rs
  71. 74 30
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  72. 188 0
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  73. 1 1
      crates/cdk-integration-tests/tests/regtest.rs
  74. 323 241
      crates/cdk-integration-tests/tests/wallet_repository.rs
  75. 1 0
      crates/cdk-ldk-node/Cargo.toml
  76. 87 36
      crates/cdk-ldk-node/src/lib.rs
  77. 65 0
      crates/cdk-ldk-node/src/log.rs
  78. 1 1
      crates/cdk-ldk-node/src/web/handlers/utils.rs
  79. 3 0
      crates/cdk-lnd/src/error.rs
  80. 19 9
      crates/cdk-lnd/src/lib.rs
  81. 1 1
      crates/cdk-mint-rpc/Cargo.toml
  82. 15 1
      crates/cdk-mint-rpc/src/bin/mint_rpc_cli.rs
  83. 3 0
      crates/cdk-mint-rpc/src/lib.rs
  84. 15 0
      crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/mod.rs
  85. 10 8
      crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/update_nut04.rs
  86. 1 1
      crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto
  87. 3 1
      crates/cdk-mint-rpc/src/proto/mod.rs
  88. 15 6
      crates/cdk-mint-rpc/src/proto/server.rs
  89. 10 0
      crates/cdk-mintd/example.config.toml
  90. 55 2
      crates/cdk-mintd/src/config.rs
  91. 10 0
      crates/cdk-mintd/src/env_vars/ldk_node.rs
  92. 7 0
      crates/cdk-mintd/src/lib.rs
  93. 63 11
      crates/cdk-mintd/src/setup.rs
  94. 2 0
      crates/cdk-npubcash/Cargo.toml
  95. 1 1
      crates/cdk-payment-processor/Cargo.toml
  96. 29 14
      crates/cdk-payment-processor/src/proto/client.rs
  97. 15 4
      crates/cdk-payment-processor/src/proto/server.rs
  98. 1 1
      crates/cdk-postgres/Cargo.toml
  99. 1 0
      crates/cdk-signatory/Cargo.toml
  100. 57 51
      crates/cdk-signatory/src/bin/cli/mod.rs

+ 3 - 0
.cargo-mutants.toml

@@ -6,4 +6,7 @@
 exclude_re = [
     "cashu/src/amount.rs.*FeeAndAmounts::fee",
     "cashu/src/amount.rs.*FeeAndAmounts::amounts",
+    # PartialEq implementations are simple delegations to as_str(), well-tested
+    "cashu/src/nuts/nut00/mod.rs.*impl PartialEq<&str> for PaymentMethod",
+    "cashu/src/nuts/nut00/mod.rs.*impl PartialEq<str> for PaymentMethod",
 ]

+ 7 - 0
.cargo/mutants.toml

@@ -76,4 +76,11 @@ exclude_re = [
     "crates/cashu/src/nuts/nut00/mod.rs:.*PaymentMethod::is_custom",
     "crates/cashu/src/nuts/nut00/mod.rs:.*PaymentMethod::is_bolt11",
     "crates/cashu/src/nuts/nut00/mod.rs:.*impl fmt::Display for KnownMethod.*fmt",
+
+    # Trivial PartialEq implementations - simple delegation for ergonomic comparisons
+    "crates/cashu/src/nuts/nut00/mod.rs:.*impl PartialEq<&str> for PaymentMethod.*eq",
+    "crates/cashu/src/nuts/nut00/mod.rs:.*impl PartialEq<str> for PaymentMethod.*eq",
+    "crates/cashu/src/nuts/nut00/mod.rs:.*impl PartialEq<PaymentMethod> for &str.*eq",
+    "crates/cashu/src/nuts/nut00/mod.rs:.*impl PartialEq<KnownMethod> for PaymentMethod.*eq",
+    "crates/cashu/src/nuts/nut00/mod.rs:.*impl PartialEq<PaymentMethod> for KnownMethod.*eq",
 ]

+ 102 - 50
CHANGELOG.md

@@ -7,15 +7,113 @@
 
 ## [Unreleased]
 
-### Added
-- cdk: Add `get_all_mint_info` to MultiMintWallet ([thesimplekid]).
+## [0.15.1](https://github.com/cashubtc/cdk/releases/tag/v0.15.1)
 
-## [0.14.0](https://github.com/cashubtc/cdk/releases/tag/v0.14.0)
+## Fixed
+
+- cdk: Add limits to secret and witness ([thesimplekid])
+- cdk: build_with_seed with custom paths ([thesimplekid])
+
+## [0.15.0](https://github.com/cashubtc/cdk/releases/tag/v0.15.0)
 
 ### Summary
 
-This release focuses on reliability and robustness improvements across the codebase. The mint now implements saga patterns for both melt and swap operations, providing better error recovery and state consistency during these critical operations. Async melt processing has been added for improved throughput. The wallet gains a new Tor mint connector with isolated circuits support for enhanced privacy when communicating with mints, along with a MintMetadataCache that delivers significant performance improvements for key and metadata management. A new proof recovery mechanism automatically handles failed wallet operations. MultiMintWallet receives improvements including the ability to check and wait for mint quotes and configure internal wallets. NUT-11 SIG_ALL message aggregation has been updated to match the latest specification. On the infrastructure side, a generic pubsub module has been introduced in cdk-common, and cdk-ffi adds postgres support. Additional highlights include keyset amount tracking and SQL balance calculation optimization for improved performance, wallet functions to pay human readable addresses (BIP353 and Lightning address), invoice decoding for BOLT11 and BOLT12 in the FFI bindings, and a mutation testing infrastructure to ensure security-critical code coverage. The release also brings numerous bug fixes addressing database contention, HTLC witness handling, and quote state management.
+Version 0.15.0 introduces **Wallet Sagas**, a major architectural improvement that brings the saga pattern to all wallet operations for robust error recovery and crash resilience. This release also adds async wallet operations support and NUT-26 (Payment Request Bech32m Encoding) for compatibility with BIP-321 and BIP-353 human-readable addresses.
+
+Key highlights include:
+- **Wallet Sagas**: All wallet operations (mint, melt, send, receive, swap) now use the saga pattern with type-state safety and automatic compensation actions
+- **Melt Flow Redesign**: New two-phase prepare/confirm pattern for melts with `PreparedMelt`, similar to `PreparedSend`
+- **Async Wallet Operations**: Non-blocking melt operations via `prefer` field - returns immediately with a `PendingMelt` future that can be awaited immediately OR dropped to complete via WebSocket notifications or background recovery
+- **NUT-26**: Payment Request Bech32m Encoding support (CREQ-B format) - provides better QR code compatibility and enables integration with BIP-321/BIP-353 human-readable addresses
+- **Breaking Change**: MultiMintWallet has been removed - use `WalletRepository` instead
+- Authentication (NUT-21/NUT-22) is now always enabled - the `auth` feature flag has been removed
+- Keyset V2 is now the default for new keysets
+
+### Added
+- cdk: Wallet saga pattern for all wallet operations (mint, melt, send, receive, swap) with type-state pattern and compensation actions ([thesimplekid]).
+- cdk: `Wallet::recover_incomplete_sagas()` method to recover from interrupted operations and prevent proofs from being stuck in reserved states ([thesimplekid]).
+- cdk: `Wallet::prepare_melt()` and `Wallet::prepare_melt_proofs()` to create `PreparedMelt` for two-phase melt operations ([thesimplekid]).
+- cdk: `PreparedMelt` with `confirm()`, `confirm_with_options()`, `confirm_prefer_async()`, and `cancel()` methods for controlled melt execution ([thesimplekid]).
+- cdk: `MeltConfirmOptions` to configure melt behavior (e.g., `skip_swap`) ([thesimplekid]).
+- cdk: `MeltOutcome` enum with `Paid` (immediate completion) or `Pending` variants - `Pending` returns a `PendingMelt` that can be awaited immediately OR dropped to complete via WebSocket notifications or `Wallet::finalize_pending_melts()` ([thesimplekid]).
+- cdk: `PendingMelt` struct that implements `IntoFuture` for awaiting async melt completion ([thesimplekid]).
+- cdk: `Wallet::finalize_pending_melts()` to recover and complete pending melt operations after crash ([thesimplekid]).
+- cdk: Async wallet operations support using `prefer` field in request body ([thesimplekid]).
+- cdk: NUT-26 Payment Request Bech32m Encoding support (CREQ-B format as an alternative to NUT-18's CREQ-A) ([thesimplekid]).
+- cdk: `WalletRepository` as a simpler replacement for MultiMintWallet - manages Wallet instances by mint URL and currency unit with direct access to Wallet methods ([asmo]).
+- cdk: `WalletRepositoryBuilder` for constructing WalletRepository with configurable proxy, Tor, and database settings ([asmo]).
+- cdk: `WalletConfig` for per-wallet customization of connectors, target proof counts, and metadata cache TTL ([asmo]).
+- cdk: `TokenData` struct for extracting mint URL, proofs, and metadata from parsed tokens ([asmo]).
+- cdk: Keyset V2 configuration with `use_keyset_v2` setting - defaults to V2 for new keysets while preserving existing keyset versions ([thesimplekid]).
+- cdk: Input and output limits for swap, melt, and other transactions to prevent DoS attacks ([thesimplekid]).
+- cdk: Glob pattern support for NUT-21/22 route validation ([thesimplekid]).
+- cdk: `Wallet::fetch_mint_quote()` method to retrieve mint quote by ID ([asmo]).
+- cdk-ldk-node: BIP39 mnemonic seed configuration ([asmo]).
+- cdk-ldk-node: Configurable announcement addresses ([asmo]).
+- cdk-ldk-node: Configurable logging settings ([asmo]).
+
+### Changed
+- cdk: **BREAKING** - Removed `MultiMintWallet` and all its methods - use `WalletRepository` instead for managing multiple wallets ([asmo]).
+- cdk: **BREAKING** - Removed `auth` feature flag - authentication code (NUT-21/NUT-22) is now always compiled ([crodas]).
+- cdk: Melt operations now use the saga pattern with type-state safety and automatic compensation on failure ([thesimplekid]).
+- cdk: `PreparedMelt`, `PreparedSend`, and other prepared structs marked with `#[must_use]` to prevent accidental drops ([thesimplekid]).
+- cashu: Default features are now off ([thesimplekid]).
+- cdk-common: Abstracted HTTP client behind `cdk_common::HttpClient` ([crodas]).
+
+### Fixed
+- cdk: Fee conversion in payment backend ([thesimplekid]).
+- cdk: Mint publishes quote back to unpaid state on failure ([thesimplekid]).
+- cdk: Add error code for input and output limits ([thesimplekid]).
+- cdk-sqlite: Correct SQLite connection pool size check ([crodas]).
+
+### Removed
+- cdk: MultiMintWallet and all associated methods ([asmo]).
+- cdk: `auth` feature flag and all conditional compilation paths ([crodas]).
 
+## [0.14.3](https://github.com/cashubtc/cdk/releases/tag/v0.14.3)
+
+### Added
+- cdk-ffi: Export token creation from raw bytes ([cloudsupper]).
+### Fixed
+- cdk: Batch proof witness queries in check_state to prevent pool exhaustion ([thesimplekid]).
+- cdk-mint-rpc: Set payment_id and payment_amount when moving mint_quote into PAID state ([asmo]).
+- cdk: Fix fee_ppk unit mismatch in select_exact_proofs ([thesimplekid]).
+- cdk: Fix wallet restore gaps ([thesimplekid]).
+## [0.14.2](https://github.com/cashubtc/cdk/releases/tag/v0.14.2)
+### Added
+- cdk-ffi: Add Payment Requests support ([thesimplekid]).
+- cdk-ffi: Add multimint melt with mint functionality ([thesimplekid]).
+- cdk-ffi: Add get wallets functionality ([thesimplekid]).
+- cdk: Add MultiMintWallet function to check proofs state ([thesimplekid]).
+- cdk: Add get_token_data and get_mint_keysets to MultiMintWallet ([thesimplekid]).
+- cdk: Melt external support ([thesimplekid]).
+### Changed
+- cdk: Swap before melt ([thesimplekid]).
+- cdk: Use try proof in swap within melt ([thesimplekid]).
+
+### Fixed
+- cdk-ffi: Check melt quote in FFI ([thesimplekid]).
+- cdk: Use the client id from mint configuration ([lescuer97]).
+- cdk: Fix proof selection with fees to ensure net amount meets target ([thesimplekid]).
+- cdk: Do not remove melt quote ([thesimplekid]).
+- cdk: Return TransactionUnbalanced error for empty swap inputs/outputs ([thesimplekid]).
+- cdk: Fix connection pool resource initialization and path validation ([thesimplekid]).
+- cdk: Fix WASM use web_time ([thesimplekid]).
+
+## [0.14.1](https://github.com/cashubtc/cdk/releases/tag/v0.14.1)
+
+### Added
+- cdk: Add MultiMintWallet human-readable address melt quote support (BIP353 and Lightning address) ([thesimplekid]).
+- cdk-ffi: Add metadata cache TTL configuration methods ([thesimplekid]).
+### Changed
+- cdk: Ensure mint info is loaded before quote operations ([thesimplekid]).
+- cdk: Replace deferred database persistence with synchronous writes in mint metadata cache ([thesimplekid]).
+
+### Added
+- cdk: Add `get_all_mint_info` to MultiMintWallet ([thesimplekid]).
+## [0.14.0](https://github.com/cashubtc/cdk/releases/tag/v0.14.0)
+### Summary
+This release focuses on reliability and robustness improvements across the codebase. The mint now implements saga patterns for both melt and swap operations, providing better error recovery and state consistency during these critical operations. Async melt processing has been added for improved throughput. The wallet gains a new Tor mint connector with isolated circuits support for enhanced privacy when communicating with mints, along with a MintMetadataCache that delivers significant performance improvements for key and metadata management. A new proof recovery mechanism automatically handles failed wallet operations. MultiMintWallet receives improvements including the ability to check and wait for mint quotes and configure internal wallets. NUT-11 SIG_ALL message aggregation has been updated to match the latest specification. On the infrastructure side, a generic pubsub module has been introduced in cdk-common, and cdk-ffi adds postgres support. Additional highlights include keyset amount tracking and SQL balance calculation optimization for improved performance, wallet functions to pay human readable addresses (BIP353 and Lightning address), invoice decoding for BOLT11 and BOLT12 in the FFI bindings, and a mutation testing infrastructure to ensure security-critical code coverage. The release also brings numerous bug fixes addressing database contention, HTLC witness handling, and quote state management.
 ### Added
 - cdk: Add wallet functions to pay human readable addresses (BIP353 and Lightning address) ([thesimplekid]).
 - cdk-ffi: Add invoice decoding for bolt11 and bolt12 ([thesimplekid]).
@@ -32,7 +130,6 @@ This release focuses on reliability and robustness improvements across the codeb
 - cdk: Allow passing metadata to a melt ([benthecarman]).
 - cashu: Include supported amounts instead of assuming the power of 2 ([crodas]).
 - test: Add mutation testing infrastructure and security-critical coverage ([thesimplekid]).
-
 ### Changed
 - cdk: Introduce MintMetadataCache for efficient key and metadata management ([crodas]).
 - cdk: Implement saga pattern for melt operations ([thesimplekid]).
@@ -79,17 +176,12 @@ This release focuses on reliability and robustness improvements across the codeb
 ### Added
 - cdk-lnbits: Update LNbits integration ([thesimplekid]).
 - cdk: Clean witness data ([thesimplekid]).
-
 ## [0.13.3](https://github.com/cashubtc/cdk/releases/tag/v0.13.3)
-
 ### Fixed
 - cdk-lnbits: Fix lnbits fee calc ([thesimplekid]).
-
 ## [0.13.2](https://github.com/cashubtc/cdk/releases/tag/v0.13.2)
-
 ### Added
 - cashu: Add spending-condition inspection helpers and token_secrets() ([lollerfirst]).
-
 ### Changed
 - cdk: Make sorting Transactions a stable sort ([benthecarman]).
 - Updated stable Rust to 1.85.0 ([thesimplekid]).
@@ -140,8 +232,6 @@ Version 0.13.0 marks a major milestone for mobile development with the introduct
 - cdk-mintd: Optional Prometheus metrics server with configurable address/port via config and env vars (feature: `prometheus`) ([thesimplekid]).
 - cdk-ldk-node: Web UI improvements (dynamic status, navigation, mobile support) ([erik]).
 - cdk-postgres: Dedicated auth database support with separate schema and migrations when auth is enabled ([thesimplekid]/[asmo]).
-
-
 ### Changed
 - cdk-common: Refactored `MintPayment` trait method `wait_any_incoming_payment` to `wait_payment_event` with event-driven architecture ([thesimplekid]).
 - cdk-common: Updated `wait_payment_event` return type to stream `Event` enum instead of `WaitPaymentResponse` directly ([thesimplekid]).
@@ -206,7 +296,6 @@ Version 0.12.0 delivers end-to-end BOLT12 offers and payments, adds BIP‑353 ad
 - cdk: Log-to-file support ([thesimplekid]).
 - cdk(wallet): BIP-353 support ([thesimplekid]).
 - security: Zeroize secrets on drop ([vnprc]).
-
 ### Changed
 - cdk-common: Modified `Database::get_keyset_counter` trait method to return `u32` instead of `Option<u32>` for simpler keyset counter handling ([thesimplekid]).
 - cdk: Refactored wallet keyset management methods for better clarity and separation of concerns ([thesimplekid]).
@@ -284,7 +373,6 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - Database transaction support [PR](https://github.com/cashubtc/cdk/pull/826) ([crodas]).
 - Support for multsig refund [PR](https://github.com/cashubtc/cdk/pull/860) ([thesimplekid]).
 - Convert unit helper fn [PR](https://github.com/cashubtc/cdk/pull/856) ([davidcaseria]).
-
 ### Changed
 - cdk-sqlite: remove sqlx in favor of rusqlite ([crodas]).
 - cdk-lnd: use custom tonic gRPC instead of fedimint-tonic-grpc [PR](https://github.com/cashubtc/cdk/pull/831) ([thesimplekid]).
@@ -311,7 +399,6 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - Mint URL flag option [PR](https://github.com/cashubtc/cdk/pull/765) ([thesimplekid]).
 - Export NUT-06 supported settings field [PR](https://github.com/cashubtc/cdk/pull/764) ([davidcaseria]).
 - Docker build workflow for arm64 images [PR](https://github.com/cashubtc/cdk/pull/770) ([asmo]).
-
 ### Changed
 - cdk-redb: Removed mint storage functionality to be wallet-only ([thesimplekid]).
 - Updated Nix flake to 25.05 and removed Nix cache [PR](https://github.com/cashubtc/cdk/pull/769) ([thesimplekid]).
@@ -331,7 +418,6 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - HTLC from hash support [PR](https://github.com/cashubtc/cdk/pull/753) ([thesimplekid]).
 - Optional transport and NUT-10 secret on payment request [PR](https://github.com/cashubtc/cdk/pull/744) ([thesimplekid]).
 - Multi-part payments support in cdk-cli [PR](https://github.com/cashubtc/cdk/pull/743) ([thesimplekid]).
-
 ### Changed
 - Refactored Lightning module to use common types [PR](https://github.com/cashubtc/cdk/pull/751) ([thesimplekid]).
 - Updated LND to support mission control and improved requery behavior [PR](https://github.com/cashubtc/cdk/pull/746) ([lollerfirst]).
@@ -363,7 +449,6 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - Amountless invoices [NUT](https://github.com/cashubtc/nuts/pull/173) [PR](https://github.com/cashubtc/cdk/pull/497) ([thesimplekid]).
 - `create_time`, `paid_time` to mint and melt quotes [PR](https://github.com/cashubtc/cdk/pull/708) ([thesimplekid]).
 - cdk-mint-rpc: Added get mint and melt quotes ttl [PR](https://github.com/cashubtc/cdk/pull/716) ([thesimplekid]).
-
 ### Changed
 - cashu: Move wallet mod to cdk-common ([thesimplekid]).
 - Export Mint DB Traits [PR](https://github.com/cashubtc/cdk/pull/710) ([davidcaseria]).
@@ -418,13 +503,11 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - Utility functions for Proofs ([davidcaseria]).
 - Utility functions for SendKind ([davidcaseria]).
 - Completed checked arithmetic operations for Amount (i.e., checked_mul and checked_div) ([davidcaseria]).
-
 ### Removed
 - Remove support for Memory Database in cdk ([crodas]).
 - Remove `AmountStr` ([crodas]).
 - Remove `get_nostr_last_checked` from `WalletDatabase` ([thesimplekid]).
 - Remove `add_nostr_last_checked` from `WalletDatabase` ([thesimplekid]).
-
 ## [cdk-mintd:v0.7.4](https://github.com/cashubtc/cdk/releases/tag/cdk-mintd-v0.7.4)
 ### Changed
 - cdk-mintd: Update to cdk v0.7.2 ([thesimplekid]).
@@ -447,11 +530,8 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 
 ### Added
 - cdk: Mint builder add ability to set custom derivation paths ([thesimplekid]).
-
 ### Fixed
 - cdk-cln: Return error on stream error ([thesimplekid]).
-
-
 ## [v0.7.0](https://github.com/cashubtc/cdk/releases/tag/v0.7.0)
 ### Changed
 - Moved db traits to `cdk-common` ([crodas]).
@@ -466,21 +546,16 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - cdk-mint-rpc: Mint management gRPC client and server ([thesimplekid]).
 - cdk-common: cdk specific types and traits ([crodas]).
 - cashu: Core types and functions defined in NUTs ([crodas]).
-
 ### Fixed
 - Multimint unit check when wallet receiving token ([thesimplekid]).
 - Mint start up with most recent keyset after a rotation ([thesimplekid]).
-
-
 ## [cdk-v0.6.1, cdk-mintd-v0.6.2](https://github.com/cashubtc/cdk/releases/tag/cdk-mintd-v0.6.1)
 ### Fixed
 - cdk: Missing check on mint that outputs equals the quote amount ([thesimplekid]).
 - cdk: Reset mint quote status if in state that cannot continue ([thesimplekid]).
-
 ## [v0.6.1](https://github.com/cashubtc/cdk/releases/tag/cdk-v0.6.1)
 ### Added
 - cdk-mintd: Get work-dir from env var ([thesimplekid]).
-
 ## [v0.6.0](https://github.com/cashubtc/cdk/releases/tag/v0.6.0)
 ### Changed
 - cdk: Enforce `quote_id` to uuid type in mint ([tdelabro]).
@@ -492,14 +567,12 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - cdk-axum: Redis cache backend ([crodas]).
 - cdk-mints: Get mint settings from env vars ([thesimplekid]).
 - cdk-axum: HTTP compression support ([ok300]).
-
 ### Fixed
 - cdk-sqlite: Keyset counter was overwritten when keyset was fetched from mint ([thesimplekid]).
 - cdk-cli: On `mint` use `unit` from cli args ([thesimplekid]).
 - cdk-cli: On `restore` create `wallet` if it does not exist ([thesimplekid]).
 - cdk: Signaling support for optional nuts ([thesimplekid]).
 - cdk-phd: Check payment has valid uuid ([thesimplekid]).
-
 ## [v0.5.0](https://github.com/cashubtc/cdk/releases/tag/v0.5.0)
 ### Changed
 - cdk: Bump `bitcoin` to `0.32.2` ([prusnak]).
@@ -533,19 +606,13 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - cdk: NUT18 payment request support ([thesimplekid]).
 - cdk: Add `Wallet::get_proofs_with` ([ok300]).
 - cdk: Mint NUT-17 Websocket support ([crodas]).
-
 ### Removed
 - cdk: Remove `MintMeltSettings` since it is no longer used ([lollerfirst]).
 - cdk: `PaymentMethod::Custom` ([thesimplekid]).
 - cdk: Remove deprecated `MeltBolt11Response` ([thesimplekid]).
-
 ### Fixed
 - cdk: Check of inputs to include fee ([thesimplekid]).
 - cdk: Make unit mandatory in tokenv4 ([ok300]).
-
-
-
-
 ## [v0.4.0](https://github.com/cashubtc/cdk/releases/tag/v0.4.0)
 ### Changed
 - cdk: Reduce MSRV to 1.63.0 ([thesimplekid]).
@@ -564,15 +631,11 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 
 ### Added
 - cdk: Multiple error types ([thesimplekid]).
-
 ### Fixed
 - cdk(mint): Use checked addition on amount to ensure there is no overflow ([thesimplekid]).
-
 ### Removed
 - cdk(wallet): Removed CDK wallet error ([thesimplekid]).
 - cdk(mint): Removed CDK mint error ([thesimplekid]).
-
-
 ## [v0.3.0](https://github.com/cashubtc/cdk/releases/tag/v0.3.0)
 ### Changed
 - cdk(wallet): `fn send` returns `Token` so the user can use the struct of convert it to a v3 or v4 string ([thesimplekid]).
@@ -606,29 +669,22 @@ If you are currently running a mint with redb, you must migrate to SQLite before
 - cdk: Add `MintUrl` that sanitizes mint url by removing trailing `/` ([cjbeery24]).
 - cdk(cdk-database/mint): Add `update_proofs` that both adds new `ProofInfo`s to the db and deletes ([davidcaseria]).
 - cdk(cdk-database/mint): Add `set_pending_proofs`, `reserve_proofs`, and `set_unspent_proofs` ([davidcaseria]).
-
-
 ### Fixed
 - cdk(mint): `SIG_ALL` is not allowed in `melt` ([thesimplekid]).
 - cdk(mint): On `swap` verify correct number of sigs on outputs when `SigAll` ([thesimplekid]).
 - cdk(mint): Use amount in payment_quote response from ln backend ([thesimplekid]).
 - cdk(mint): Create new keysets for added supported units ([thesimplekid]).
 - cdk(mint): If there is an error in swap proofs should be reset to unspent ([thesimplekid]).
-
 ### Removed
 - cdk(wallet): Remove unused argument `SplitTarget` on `melt` ([thesimplekid]).
 - cdk(cdk-database/mint): Remove `get_spent_proofs`, `get_spent_proofs_by_ys`,`get_pending_proofs`, `get_pending_proofs_by_ys`, and `remove_pending_proofs` ([thesimplekid]).
 - cdk: Remove `UncheckedUrl` in favor of `MintUrl` ([cjbeery24]).
 - cdk(cdk-database/mint): Remove `set_proof_state`, `remove_proofs` and `add_proofs` ([davidcaseria]).
-
 ## [v0.2.0](https://github.com/cashubtc/cdk/releases/tag/v0.2.0)
 ### Summary
 This release introduces TokenV4, which uses CBOR encoding as the default token format. It also includes fee support for both wallet and mint operations.
-
 When sending, the sender can choose to include the necessary fee to ensure that the receiver can redeem the full sent amount. If this is not done, the receiver will be responsible for the fee.
-
 Additionally, this release introduces a Mint binary cdk-mintd that uses the cdk-axum crate as a web server to create a full Cashu mint. When paired with a Lightning backend, currently implemented as Core Lightning, it is included in this release as cdk-cln.
-
 ### Changed
 - cdk(wallet): `wallet:receive` will not claim `proofs` from a mint other than the wallet's mint ([thesimplekid]).
 - cdk(NUT00): `Token` is changed from a `struct` to `enum` of either `TokenV4` or `Tokenv3` ([thesimplekid]).
@@ -647,18 +703,14 @@ Additionally, this release introduces a Mint binary cdk-mintd that uses the cdk-
 - cdk: NUT06 `MintInfo` and `NUTs` builder ([thesimplekid]).
 - cdk: NUT00 `PreMintSecret` added Keyset id ([thesimplekid]).
 - cdk: NUT02 Support fees ([thesimplekid]).
-
 ### Fixed
 - cdk: NUT06 deserialize `MintInfo` ([thesimplekid]).
-
-
 ## [v0.1.1](https://github.com/cashubtc/cdk/releases/tag/v0.1.1)
 ### Changed
 - cdk(wallet): `wallet::total_pending_balance` does not include reserved proofs ([thesimplekid]).
 
 ### Added
 - cdk(wallet): Added get reserved proofs ([thesimplekid]).
-
 <!-- Contributors -->
 [thesimplekid]: https://github.com/thesimplekid
 [davidcaseria]: https://github.com/davidcaseria

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 159 - 135
Cargo.lock


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 160 - 136
Cargo.lock.msrv


+ 22 - 22
Cargo.toml

@@ -36,7 +36,7 @@ rust-version = "1.85.0"
 license = "MIT"
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-version = "0.14.0"
+version = "0.15.1"
 readme = "README.md"
 
 [workspace.dependencies]
@@ -46,27 +46,27 @@ axum = { version = "0.8.1", features = ["ws"] }
 bitcoin = { version = "0.32.2", features = ["base64", "serde", "rand", "rand-std"] }
 bip39 = { version = "2.0", features = ["rand"] }
 jsonwebtoken = "9.2.0"
-cashu = { path = "./crates/cashu", version = "=0.14.0" }
-cdk = { path = "./crates/cdk", default-features = false, version = "=0.14.0" }
-cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.14.0" }
-cdk-axum = { path = "./crates/cdk-axum", default-features = false, version = "=0.14.0" }
-cdk-cln = { path = "./crates/cdk-cln", version = "=0.14.0" }
-cdk-lnbits = { path = "./crates/cdk-lnbits", version = "=0.14.0" }
-cdk-lnd = { path = "./crates/cdk-lnd", version = "=0.14.0" }
-cdk-ldk-node = { path = "./crates/cdk-ldk-node", version = "=0.14.0" }
-cdk-fake-wallet = { path = "./crates/cdk-fake-wallet", version = "=0.14.0" }
-cdk-ffi = { path = "./crates/cdk-ffi", version = "=0.14.0" }
-cdk-payment-processor = { path = "./crates/cdk-payment-processor", default-features = true, version = "=0.14.0" }
-cdk-mint-rpc = { path = "./crates/cdk-mint-rpc", version = "=0.14.0" }
-cdk-redb = { path = "./crates/cdk-redb", default-features = true, version = "=0.14.0" }
-cdk-sql-common = { path = "./crates/cdk-sql-common", default-features = true, version = "=0.14.0" }
-cdk-sqlite = { path = "./crates/cdk-sqlite", default-features = true, version = "=0.14.0" }
-cdk-postgres = { path = "./crates/cdk-postgres", default-features = true, version = "=0.14.0" }
-cdk-signatory = { path = "./crates/cdk-signatory", version = "=0.14.0", default-features = false }
-cdk-mintd = { path = "./crates/cdk-mintd", version = "=0.14.0", default-features = false }
-cdk-prometheus = { path = "./crates/cdk-prometheus", version = "=0.14.0", default-features = false }
-cdk-npubcash = { path = "./crates/cdk-npubcash", version = "=0.14.0" }
-cdk-http-client = { path = "./crates/cdk-http-client", version = "=0.14.0" }
+cashu = { path = "./crates/cashu", default-features = false, version = "=0.15.1" }
+cdk = { path = "./crates/cdk", default-features = false, version = "=0.15.1" }
+cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.15.1" }
+cdk-axum = { path = "./crates/cdk-axum", default-features = false, version = "=0.15.1" }
+cdk-cln = { path = "./crates/cdk-cln", version = "=0.15.1" }
+cdk-lnbits = { path = "./crates/cdk-lnbits", version = "=0.15.1" }
+cdk-lnd = { path = "./crates/cdk-lnd", version = "=0.15.1" }
+cdk-ldk-node = { path = "./crates/cdk-ldk-node", version = "=0.15.1" }
+cdk-fake-wallet = { path = "./crates/cdk-fake-wallet", version = "=0.15.1" }
+cdk-ffi = { path = "./crates/cdk-ffi", version = "=0.15.1" }
+cdk-payment-processor = { path = "./crates/cdk-payment-processor", default-features = true, version = "=0.15.1" }
+cdk-mint-rpc = { path = "./crates/cdk-mint-rpc", version = "=0.15.1" }
+cdk-redb = { path = "./crates/cdk-redb", default-features = true, version = "=0.15.1" }
+cdk-sql-common = { path = "./crates/cdk-sql-common", default-features = true, version = "=0.15.1" }
+cdk-sqlite = { path = "./crates/cdk-sqlite", default-features = true, version = "=0.15.1" }
+cdk-postgres = { path = "./crates/cdk-postgres", default-features = true, version = "=0.15.1" }
+cdk-signatory = { path = "./crates/cdk-signatory", version = "=0.15.1", default-features = false }
+cdk-mintd = { path = "./crates/cdk-mintd", version = "=0.15.1", default-features = false }
+cdk-prometheus = { path = "./crates/cdk-prometheus", version = "=0.15.1", default-features = false }
+cdk-npubcash = { path = "./crates/cdk-npubcash", version = "=0.15.1" }
+cdk-http-client = { path = "./crates/cdk-http-client", version = "=0.15.1" }
 clap = { version = "4.5.31", features = ["derive"] }
 ciborium = { version = "0.2.2", default-features = false, features = ["std"] }
 cbor-diag = "0.1.12"

+ 0 - 1
crates/cashu/Cargo.toml

@@ -33,7 +33,6 @@ url.workspace = true
 utoipa = { workspace = true, optional = true }
 serde_json.workspace = true
 serde_with.workspace = true
-regex.workspace = true
 strum.workspace = true
 strum_macros.workspace = true
 nostr-sdk = { workspace = true, optional = true }

+ 108 - 0
crates/cashu/src/amount.rs

@@ -2043,4 +2043,112 @@ mod tests {
         assert_eq!(sum, Amount::from(150));
         assert_ne!(sum, Amount::ZERO);
     }
+
+    /// Tests that saturating_sub correctly subtracts amounts without underflow.
+    ///
+    /// This is critical for any saturating subtraction operations. If it returns
+    /// Default::default() (Amount::ZERO) always, or changes the comparison or
+    /// subtraction operation, calculations will be wrong.
+    ///
+    /// Mutant testing: Kills mutations that:
+    /// - Replace saturating_sub with Default::default()
+    /// - Replace > with ==, <, or >= in the comparison
+    /// - Replace - with + or / in the subtraction
+    #[test]
+    fn test_saturating_sub_normal_case() {
+        // Normal subtraction: larger - smaller
+        let amount1 = Amount::from(100);
+        let amount2 = Amount::from(30);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::from(70));
+        assert_ne!(result, Amount::ZERO);
+
+        // Another normal case
+        let amount1 = Amount::from(1000);
+        let amount2 = Amount::from(1);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::from(999));
+
+        // Edge case: subtraction resulting in 1
+        let amount1 = Amount::from(2);
+        let amount2 = Amount::from(1);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::from(1));
+        assert_ne!(result, Amount::ZERO);
+    }
+
+    /// Tests that saturating_sub returns ZERO when subtracting a larger amount.
+    ///
+    /// This catches mutations that change the comparison operator (>, ==, <, >=)
+    /// or that don't return ZERO on underflow.
+    #[test]
+    fn test_saturating_sub_saturates_at_zero() {
+        // Subtracting larger from smaller should return ZERO
+        let amount1 = Amount::from(30);
+        let amount2 = Amount::from(100);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::ZERO);
+        assert_ne!(result, Amount::from(30)); // Should not be the original value
+
+        // Another case
+        let amount1 = Amount::from(5);
+        let amount2 = Amount::from(10);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::ZERO);
+
+        // Edge case: subtracting from zero
+        let amount1 = Amount::ZERO;
+        let amount2 = Amount::from(1);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::ZERO);
+    }
+
+    /// Tests that saturating_sub returns ZERO when amounts are equal.
+    ///
+    /// This is a boundary case that catches comparison operator mutations.
+    #[test]
+    fn test_saturating_sub_equal_amounts() {
+        // Equal amounts should return ZERO (other > self is false when equal)
+        let amount1 = Amount::from(100);
+        let amount2 = Amount::from(100);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::ZERO);
+
+        // Another equal case
+        let amount1 = Amount::from(1);
+        let amount2 = Amount::from(1);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::ZERO);
+
+        // Edge case: both zero
+        let result = Amount::ZERO.saturating_sub(Amount::ZERO);
+        assert_eq!(result, Amount::ZERO);
+    }
+
+    /// Tests that saturating_sub handles the edge case where other == self + 1.
+    ///
+    /// This catches the mutation where `>` is replaced with `>=`.
+    /// If `other == self + 1`, then `other > self` is true, so we should return ZERO.
+    /// But if changed to `other >= self`, it would incorrectly return 1.
+    #[test]
+    fn test_saturating_sub_edge_case_other_greater_by_one() {
+        // When other is exactly one greater than self
+        let amount1 = Amount::from(10);
+        let amount2 = Amount::from(11); // amount2 = amount1 + 1
+        let result = amount1.saturating_sub(amount2);
+        // Should saturate to ZERO since other > self
+        assert_eq!(result, Amount::ZERO);
+
+        // Another case: subtracting 2 from 1
+        let amount1 = Amount::from(1);
+        let amount2 = Amount::from(2);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::ZERO);
+
+        // Edge case with zero: subtracting 1 from 0
+        let amount1 = Amount::ZERO;
+        let amount2 = Amount::from(1);
+        let result = amount1.saturating_sub(amount2);
+        assert_eq!(result, Amount::ZERO);
+    }
 }

+ 471 - 47
crates/cashu/src/nuts/auth/nut21.rs

@@ -3,16 +3,18 @@
 use std::collections::HashSet;
 use std::str::FromStr;
 
-use regex::Regex;
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
 /// NUT21 Error
 #[derive(Debug, Error)]
 pub enum Error {
-    /// Invalid regex pattern
-    #[error("Invalid regex pattern: {0}")]
-    InvalidRegex(#[from] regex::Error),
+    /// Invalid pattern
+    #[error("Invalid pattern: {0}")]
+    InvalidPattern(String),
+    /// Unknown route path
+    #[error("Unknown route path: {0}. Valid paths are: /v1/mint/quote/{{method}}, /v1/mint/{{method}}, /v1/melt/quote/{{method}}, /v1/melt/{{method}}, /v1/swap, /v1/checkstate, /v1/restore, /v1/auth/blind/mint, /v1/ws")]
+    UnknownRoute(String),
 }
 
 /// Clear Auth Settings
@@ -42,7 +44,7 @@ impl Settings {
     }
 }
 
-// Custom deserializer for Settings to expand regex patterns in protected endpoints
+// Custom deserializer for Settings to expand patterns in protected endpoints
 impl<'de> Deserialize<'de> for Settings {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
@@ -65,15 +67,12 @@ impl<'de> Deserialize<'de> for Settings {
         // Deserialize into the temporary struct
         let raw = RawSettings::deserialize(deserializer)?;
 
-        // Process protected endpoints, expanding regex patterns if present
+        // Process protected endpoints, expanding patterns if present
         let mut protected_endpoints = HashSet::new();
 
         for raw_endpoint in raw.protected_endpoints {
             let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
-                serde::de::Error::custom(format!(
-                    "Invalid regex pattern '{}': {}",
-                    raw_endpoint.path, e
-                ))
+                serde::de::Error::custom(format!("Invalid pattern '{}': {}", raw_endpoint.path, e))
             })?;
 
             for path in expanded_paths {
@@ -151,15 +150,12 @@ impl Serialize for RoutePath {
     }
 }
 
-impl<'de> Deserialize<'de> for RoutePath {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: serde::Deserializer<'de>,
-    {
-        let s = String::deserialize(deserializer)?;
+impl std::str::FromStr for RoutePath {
+    type Err = Error;
 
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
         // Try to parse as a known static path first
-        match s.as_str() {
+        match s {
             "/v1/swap" => Ok(RoutePath::Swap),
             "/v1/checkstate" => Ok(RoutePath::Checkstate),
             "/v1/restore" => Ok(RoutePath::Restore),
@@ -178,16 +174,23 @@ impl<'de> Deserialize<'de> for RoutePath {
                 } else {
                     // Unknown path - this might be an old database value or config
                     // Provide a helpful error message
-                    Err(serde::de::Error::custom(format!(
-                        "Unknown route path: {}. Valid paths are: /v1/mint/quote/{{method}}, /v1/mint/{{method}}, /v1/melt/quote/{{method}}, /v1/melt/{{method}}, /v1/swap, /v1/checkstate, /v1/restore, /v1/auth/blind/mint, /v1/ws",
-                        s
-                    )))
+                    Err(Error::UnknownRoute(s.to_string()))
                 }
             }
         }
     }
 }
 
+impl<'de> Deserialize<'de> for RoutePath {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        RoutePath::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
+
 impl RoutePath {
     /// Get all non-payment-method route paths
     /// These are routes that don't depend on payment methods
@@ -202,7 +205,7 @@ impl RoutePath {
     }
 
     /// Get all route paths for common payment methods (bolt11, bolt12)
-    /// This is used for regex matching in configuration
+    /// This is used for pattern matching in configuration
     pub fn common_payment_method_paths() -> Vec<RoutePath> {
         let methods = vec!["bolt11", "bolt12"];
         let mut paths = Vec::new();
@@ -217,7 +220,7 @@ impl RoutePath {
         paths
     }
 
-    /// Get all paths for regex matching (static + common payment methods)
+    /// Get all paths for pattern matching (static + common payment methods)
     pub fn all_known_paths() -> Vec<RoutePath> {
         let mut paths = Self::static_paths();
         paths.extend(Self::common_payment_method_paths());
@@ -225,15 +228,36 @@ impl RoutePath {
     }
 }
 
-/// Returns [`RoutePath`]s that match regex
-/// Matches against all known static paths and common payment methods (bolt11, bolt12)
+/// Returns [`RoutePath`]s that match the pattern (Exact or Prefix)
 pub fn matching_route_paths(pattern: &str) -> Result<Vec<RoutePath>, Error> {
-    let regex = Regex::from_str(pattern)?;
+    // Check for wildcard
+    if let Some(prefix) = pattern.strip_suffix('*') {
+        // Prefix matching
+        // Ensure '*' is only at the end
+        if prefix.contains('*') {
+            return Err(Error::InvalidPattern(
+                "Wildcard '*' must be the last character".to_string(),
+            ));
+        }
 
-    Ok(RoutePath::all_known_paths()
-        .into_iter()
-        .filter(|path| regex.is_match(&path.to_string()))
-        .collect())
+        // Filter all known paths
+        Ok(RoutePath::all_known_paths()
+            .into_iter()
+            .filter(|path| path.to_string().starts_with(prefix))
+            .collect())
+    } else {
+        // Exact matching
+        if pattern.contains('*') {
+            return Err(Error::InvalidPattern(
+                "Wildcard '*' must be the last character".to_string(),
+            ));
+        }
+
+        match RoutePath::from_str(pattern) {
+            Ok(path) => Ok(vec![path]),
+            Err(_) => Ok(vec![]), // Ignore unknown paths for matching
+        }
+    }
 }
 impl std::fmt::Display for RoutePath {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -259,9 +283,55 @@ mod tests {
     use crate::PaymentMethod;
 
     #[test]
+    fn test_matching_route_paths_root_wildcard() {
+        // Pattern that matches everything
+        let paths = matching_route_paths("*").unwrap();
+
+        // Should match all known variants
+        assert_eq!(paths.len(), RoutePath::all_known_paths().len());
+    }
+
+    #[test]
+    fn test_matching_route_paths_middle_wildcard() {
+        // Invalid wildcard position
+        let result = matching_route_paths("/v1/*/mint");
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
+    }
+
+    #[test]
+    fn test_matching_route_paths_prefix_without_slash() {
+        // "/v1/mint*" matches "/v1/mint" and "/v1/mint/..."
+        let paths = matching_route_paths("/v1/mint*").unwrap();
+
+        // Should match all mint paths + mint quote paths
+        assert_eq!(paths.len(), 4);
+
+        // Should NOT match /v1/melt...
+        assert!(!paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_matching_route_paths_exact_match_unknown() {
+        // Exact match for unknown path structure should return empty list
+        let paths = matching_route_paths("/v1/invalid/path").unwrap();
+        assert!(paths.is_empty());
+    }
+
+    #[test]
+    fn test_matching_route_paths_dynamic_method() {
+        // Verify that custom payment methods are parsed correctly
+        let paths = matching_route_paths("/v1/mint/custom_method").unwrap();
+        assert_eq!(paths.len(), 1);
+        assert_eq!(paths[0], RoutePath::Mint("custom_method".to_string()));
+    }
+
+    #[test]
     fn test_matching_route_paths_all() {
-        // Regex that matches all paths
-        let paths = matching_route_paths(".*").unwrap();
+        // Prefix that matches all paths
+        let paths = matching_route_paths("/v1/*").unwrap();
 
         // Should match all known variants
         assert_eq!(paths.len(), RoutePath::all_known_paths().len());
@@ -294,7 +364,7 @@ mod tests {
     #[test]
     fn test_matching_route_paths_mint_only() {
         // Regex that matches only mint paths
-        let paths = matching_route_paths("^/v1/mint/.*").unwrap();
+        let paths = matching_route_paths("/v1/mint/*").unwrap();
 
         // Should match only mint paths (4 paths: mint quote and mint for bolt11 and bolt12)
         assert_eq!(paths.len(), 4);
@@ -330,22 +400,16 @@ mod tests {
     #[test]
     fn test_matching_route_paths_quote_only() {
         // Regex that matches only quote paths
-        let paths = matching_route_paths(".*/quote/.*").unwrap();
+        let paths = matching_route_paths("/v1/mint/quote/*").unwrap();
 
-        // Should match only quote paths (4 paths: mint quote and melt quote for bolt11 and bolt12)
-        assert_eq!(paths.len(), 4);
+        // Should match only quote paths (2 paths: mint quote for bolt11 and bolt12)
+        assert_eq!(paths.len(), 2);
         assert!(paths.contains(&RoutePath::MintQuote(
             PaymentMethod::Known(KnownMethod::Bolt11).to_string()
         )));
-        assert!(paths.contains(&RoutePath::MeltQuote(
-            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
-        )));
         assert!(paths.contains(&RoutePath::MintQuote(
             PaymentMethod::Known(KnownMethod::Bolt12).to_string()
         )));
-        assert!(paths.contains(&RoutePath::MeltQuote(
-            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
-        )));
 
         // Should not match non-quote paths
         assert!(!paths.contains(&RoutePath::Mint(
@@ -380,11 +444,11 @@ mod tests {
     #[test]
     fn test_matching_route_paths_invalid_regex() {
         // Invalid regex pattern
-        let result = matching_route_paths("(unclosed parenthesis");
+        let result = matching_route_paths("/*unclosed parenthesis");
 
         // Should return an error for invalid regex
         assert!(result.is_err());
-        assert!(matches!(result.unwrap_err(), Error::InvalidRegex(_)));
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
     }
 
     #[test]
@@ -492,7 +556,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": "^/v1/mint/.*"
+                    "path": "/v1/mint/*"
                 },
                 {
                     "method": "POST",
@@ -543,7 +607,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": "(unclosed parenthesis"
+                    "path": "/*wildcard_start"
                 }
             ]
         }"#;
@@ -582,7 +646,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": ".*"
+                    "path": "/v1/*"
                 }
             ]
         }"#;
@@ -593,4 +657,364 @@ mod tests {
             RoutePath::all_known_paths().len()
         );
     }
+
+    #[test]
+    fn test_matching_route_paths_empty_pattern() {
+        // Empty pattern should return empty list (nothing matches)
+        let paths = matching_route_paths("").unwrap();
+        assert!(paths.is_empty());
+    }
+
+    #[test]
+    fn test_matching_route_paths_just_slash() {
+        // Pattern "/" should not match any known paths (all start with /v1/)
+        let paths = matching_route_paths("/").unwrap();
+        assert!(paths.is_empty());
+    }
+
+    #[test]
+    fn test_matching_route_paths_trailing_slash() {
+        // Pattern with trailing slash after wildcard: "/v1/mint/*/"
+        // The wildcard "*" is not the last character ("/" comes after it)
+        // This should be an invalid pattern according to the spec
+        let result = matching_route_paths("/v1/mint/*/");
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
+    }
+
+    #[test]
+    fn test_matching_route_paths_consecutive_wildcards() {
+        // Pattern "**" - the first * is the suffix, second * is in prefix
+        // After strip_suffix('*'), we get "*" which contains '*'
+        // This should be an error because wildcard must be at the end only
+        let result = matching_route_paths("**");
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
+    }
+
+    #[test]
+    fn test_matching_route_paths_method_specific() {
+        // Test that GET and POST methods are properly distinguished
+        // The matching function only returns paths, methods are handled by Settings
+        // This test verifies paths are correctly matched regardless of method
+        let paths = matching_route_paths("/v1/swap").unwrap();
+        assert_eq!(paths.len(), 1);
+        assert!(paths.contains(&RoutePath::Swap));
+    }
+
+    #[test]
+    fn test_settings_mixed_methods() {
+        // Test Settings with mixed methods for same path pattern
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "/v1/swap"
+                },
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert_eq!(settings.protected_endpoints.len(), 2);
+
+        // Check both methods are present
+        let methods: Vec<_> = settings
+            .protected_endpoints
+            .iter()
+            .map(|ep| ep.method)
+            .collect();
+        assert!(methods.contains(&Method::Get));
+        assert!(methods.contains(&Method::Post));
+
+        // Both should have the same path
+        for ep in &settings.protected_endpoints {
+            assert_eq!(ep.path, RoutePath::Swap);
+        }
+    }
+
+    #[test]
+    fn test_matching_route_paths_melt_prefix() {
+        // Test prefix matching for melt endpoints: "/v1/melt/*"
+        let paths = matching_route_paths("/v1/melt/*").unwrap();
+
+        // Should match 4 melt paths (bolt11/12 for melt and melt quote)
+        assert_eq!(paths.len(), 4);
+        assert!(paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+
+        // Should NOT match mint paths
+        assert!(!paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_matching_route_paths_static_exact() {
+        // Test exact matches for static paths
+        let swap_paths = matching_route_paths("/v1/swap").unwrap();
+        assert_eq!(swap_paths.len(), 1);
+        assert_eq!(swap_paths[0], RoutePath::Swap);
+
+        let checkstate_paths = matching_route_paths("/v1/checkstate").unwrap();
+        assert_eq!(checkstate_paths.len(), 1);
+        assert_eq!(checkstate_paths[0], RoutePath::Checkstate);
+
+        let restore_paths = matching_route_paths("/v1/restore").unwrap();
+        assert_eq!(restore_paths.len(), 1);
+        assert_eq!(restore_paths[0], RoutePath::Restore);
+
+        let ws_paths = matching_route_paths("/v1/ws").unwrap();
+        assert_eq!(ws_paths.len(), 1);
+        assert_eq!(ws_paths[0], RoutePath::Ws);
+    }
+
+    #[test]
+    fn test_matching_route_paths_auth_blind_mint() {
+        // Test exact match for auth blind mint endpoint
+        let paths = matching_route_paths("/v1/auth/blind/mint").unwrap();
+        assert_eq!(paths.len(), 1);
+        assert_eq!(paths[0], RoutePath::MintBlindAuth);
+    }
+
+    #[test]
+    fn test_settings_empty_endpoints() {
+        // Test Settings with empty protected_endpoints array
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": []
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert!(settings.protected_endpoints.is_empty());
+    }
+
+    #[test]
+    fn test_settings_duplicate_paths() {
+        // Test that duplicate paths are deduplicated by HashSet
+        // Using same pattern twice with same method should result in single entry
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                },
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert_eq!(settings.protected_endpoints.len(), 1);
+        assert_eq!(settings.protected_endpoints[0].method, Method::Post);
+        assert_eq!(settings.protected_endpoints[0].path, RoutePath::Swap);
+    }
+
+    #[test]
+    fn test_matching_route_paths_only_wildcard() {
+        // Pattern with just "*" matches everything
+        let paths = matching_route_paths("*").unwrap();
+        assert_eq!(paths.len(), RoutePath::all_known_paths().len());
+    }
+
+    #[test]
+    fn test_matching_route_paths_wildcard_in_middle() {
+        // Pattern "/v1/*/bolt11" - wildcard in the middle
+        // After strip_suffix('*'), we get "/v1/*/bolt11" which contains '*'
+        // This should be an error
+        let result = matching_route_paths("/v1/*/bolt11");
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidPattern(_)));
+    }
+
+    #[test]
+    fn test_exact_match_no_child_paths() {
+        // Exact match "/v1/mint" should NOT match child paths like "/v1/mint/bolt11"
+        let paths = matching_route_paths("/v1/mint").unwrap();
+
+        // "/v1/mint" is not a valid RoutePath by itself (needs payment method)
+        // So it should return empty
+        assert!(paths.is_empty());
+
+        // Also verify it doesn't match any mint paths with payment methods
+        assert!(!paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(!paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_exact_match_no_extra_path() {
+        // Exact match "/v1/swap" should NOT match "/v1/swap/extra"
+        // Since "/v1/swap/extra" is not a known path, it won't be in all_known_paths
+        // But let's verify "/v1/swap" only matches the exact Swap path
+        let paths = matching_route_paths("/v1/swap").unwrap();
+        assert_eq!(paths.len(), 1);
+        assert_eq!(paths[0], RoutePath::Swap);
+
+        // Verify it doesn't match any other paths
+        assert!(!paths.contains(&RoutePath::Checkstate));
+        assert!(!paths.contains(&RoutePath::Restore));
+    }
+
+    #[test]
+    fn test_partial_prefix_matching() {
+        // Pattern "/v1/mi*" - partial prefix that matches "/v1/mint/..." but not "/v1/melt/..."
+        let paths = matching_route_paths("/v1/mi*").unwrap();
+
+        // This DOES match "/v1/mint/bolt11" because "/v1/mint/bolt11" starts with "/v1/mi"
+        assert!(paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // But it does NOT match melt paths because "/v1/melt" doesn't start with "/v1/mi"
+        assert!(!paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(!paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_exact_match_wrong_payment_method() {
+        // Pattern "/v1/mint/quote/bolt11" should NOT match "/v1/mint/quote/bolt12"
+        let paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
+
+        assert_eq!(paths.len(), 1);
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // Should NOT contain bolt12
+        assert!(!paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+
+        // Should NOT contain regular mint (non-quote)
+        assert!(!paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+    }
+
+    #[test]
+    fn test_prefix_match_wrong_category() {
+        // Pattern "/v1/mint/*" should NOT match melt paths "/v1/melt/*"
+        let paths = matching_route_paths("/v1/mint/*").unwrap();
+
+        // Should contain mint paths
+        assert!(paths.contains(&RoutePath::Mint(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // Should NOT contain melt paths (different category)
+        assert!(!paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(!paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // Should NOT contain static paths
+        assert!(!paths.contains(&RoutePath::Swap));
+        assert!(!paths.contains(&RoutePath::Checkstate));
+    }
+
+    #[test]
+    fn test_case_sensitivity() {
+        // Pattern "/v1/MINT/*" should NOT match "/v1/mint/bolt11" (case sensitive)
+        let paths_upper = matching_route_paths("/v1/MINT/*").unwrap();
+        let paths_lower = matching_route_paths("/v1/mint/*").unwrap();
+
+        // Uppercase should NOT match any known paths
+        assert!(paths_upper.is_empty());
+
+        // Lowercase should match 4 mint paths
+        assert_eq!(paths_lower.len(), 4);
+    }
+
+    #[test]
+    fn test_negative_assertions_comprehensive() {
+        // Comprehensive test that verifies multiple negative cases in one place
+
+        // 1. Exact match for wrong payment method
+        let bolt11_paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
+        assert!(!bolt11_paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+
+        // 2. Prefix for one category doesn't match another
+        let mint_paths = matching_route_paths("/v1/mint/*").unwrap();
+        assert!(!mint_paths.contains(&RoutePath::Melt(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(!mint_paths.contains(&RoutePath::MeltQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+
+        // 3. Exact match for static path doesn't match others
+        let swap_paths = matching_route_paths("/v1/swap").unwrap();
+        assert!(!swap_paths.contains(&RoutePath::Checkstate));
+        assert!(!swap_paths.contains(&RoutePath::Restore));
+        assert!(!swap_paths.contains(&RoutePath::MintBlindAuth));
+
+        // 4. Case sensitivity - wrong case matches nothing
+        assert!(matching_route_paths("/V1/SWAP").unwrap().is_empty());
+        assert!(matching_route_paths("/V1/MINT/*").unwrap().is_empty());
+
+        // 5. Invalid/unknown paths match nothing
+        assert!(matching_route_paths("/unknown/path").unwrap().is_empty());
+        assert!(matching_route_paths("/invalid").unwrap().is_empty());
+    }
+
+    #[test]
+    fn test_prefix_vs_exact_boundary() {
+        // Pattern "/v1/mint/quote/*" should NOT match "/v1/mint/quote" itself
+        let paths = matching_route_paths("/v1/mint/quote/*").unwrap();
+
+        // The pattern requires something after "/v1/mint/quote/"
+        // So "/v1/mint/quote" (without payment method) is NOT a valid RoutePath
+        // and won't be in the results
+        assert!(!paths.is_empty()); // Should have bolt11 and bolt12
+
+        // Verify we have the quote paths with payment methods
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt11).to_string()
+        )));
+        assert!(paths.contains(&RoutePath::MintQuote(
+            PaymentMethod::Known(KnownMethod::Bolt12).to_string()
+        )));
+
+        // But there is no RoutePath::MintQuote without a payment method
+        // So the list should only contain 2 items (bolt11 and bolt12), not a bare "/v1/mint/quote"
+        assert_eq!(paths.len(), 2);
+    }
 }

+ 6 - 9
crates/cashu/src/nuts/auth/nut22.rs

@@ -59,7 +59,7 @@ impl Settings {
     }
 }
 
-// Custom deserializer for Settings to expand regex patterns in protected endpoints
+// Custom deserializer for Settings to expand patterns in protected endpoints
 impl<'de> Deserialize<'de> for Settings {
     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
     where
@@ -85,15 +85,12 @@ impl<'de> Deserialize<'de> for Settings {
         // Deserialize into the temporary struct
         let raw = RawSettings::deserialize(deserializer)?;
 
-        // Process protected endpoints, expanding regex patterns if present
+        // Process protected endpoints, expanding patterns if present
         let mut protected_endpoints = HashSet::new();
 
         for raw_endpoint in raw.protected_endpoints {
             let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
-                serde::de::Error::custom(format!(
-                    "Invalid regex pattern '{}': {}",
-                    raw_endpoint.path, e
-                ))
+                serde::de::Error::custom(format!("Invalid pattern '{}': {}", raw_endpoint.path, e))
             })?;
 
             for path in expanded_paths {
@@ -321,7 +318,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": "^/v1/mint/.*"
+                    "path": "/v1/mint/*"
                 },
                 {
                     "method": "POST",
@@ -367,7 +364,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": "(unclosed parenthesis"
+                    "path": "/*wildcard_start"
                 }
             ]
         }"#;
@@ -383,7 +380,7 @@ mod tests {
             "protected_endpoints": [
                 {
                     "method": "GET",
-                    "path": ".*"
+                    "path": "/v1/*"
                 }
             ]
         }"#;

+ 60 - 0
crates/cashu/src/nuts/nut00/mod.rs

@@ -330,6 +330,16 @@ impl Witness {
     }
 }
 
+impl std::fmt::Display for Witness {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}",
+            serde_json::to_string(self).map_err(|_| std::fmt::Error)?
+        )
+    }
+}
+
 /// Proofs
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
@@ -1195,6 +1205,56 @@ mod tests {
         }
     }
 
+    /// Tests that is_bolt12 correctly identifies BOLT12 payment methods.
+    ///
+    /// This is critical for code that needs to distinguish between BOLT11 and BOLT12.
+    /// If is_bolt12 always returns true or false, the wrong payment flow may be used.
+    ///
+    /// Mutant testing: Kills mutations that:
+    /// - Replace is_bolt12 with true
+    /// - Replace is_bolt12 with false
+    #[test]
+    fn test_is_bolt12_with_bolt12() {
+        // BOLT12 should return true
+        let method = PaymentMethod::BOLT12;
+        assert!(method.is_bolt12());
+
+        // Known BOLT12 should also return true
+        let method = PaymentMethod::Known(KnownMethod::Bolt12);
+        assert!(method.is_bolt12());
+    }
+
+    #[test]
+    fn test_is_bolt12_with_non_bolt12() {
+        // BOLT11 should return false
+        let method = PaymentMethod::BOLT11;
+        assert!(!method.is_bolt12());
+
+        // Known BOLT11 should return false
+        let method = PaymentMethod::Known(KnownMethod::Bolt11);
+        assert!(!method.is_bolt12());
+
+        // Custom methods should return false
+        let method = PaymentMethod::Custom("paypal".to_string());
+        assert!(!method.is_bolt12());
+
+        let method = PaymentMethod::Custom("bolt12".to_string());
+        assert!(!method.is_bolt12()); // String match is not the same as actual BOLT12
+    }
+
+    /// Tests that is_bolt12 correctly distinguishes between all payment method variants.
+    #[test]
+    fn test_is_bolt12_comprehensive() {
+        // Test all variants
+        assert!(PaymentMethod::BOLT12.is_bolt12());
+        assert!(PaymentMethod::Known(KnownMethod::Bolt12).is_bolt12());
+
+        assert!(!PaymentMethod::BOLT11.is_bolt12());
+        assert!(!PaymentMethod::Known(KnownMethod::Bolt11).is_bolt12());
+        assert!(!PaymentMethod::Custom("anything".to_string()).is_bolt12());
+        assert!(!PaymentMethod::Custom("bolt12".to_string()).is_bolt12());
+    }
+
     #[test]
     fn test_witness_serialization() {
         let htlc_witness = HTLCWitness {

+ 6 - 1
crates/cashu/src/nuts/nut04.rs

@@ -18,6 +18,7 @@ use crate::nut23::QuoteState;
 use crate::quote_id::QuoteId;
 #[cfg(feature = "mint")]
 use crate::quote_id::QuoteIdError;
+use crate::util::serde_helpers::deserialize_empty_string_as_none;
 use crate::{Amount, PublicKey};
 
 /// NUT04 Error
@@ -380,7 +381,11 @@ pub struct MintQuoteCustomResponse<Q> {
     /// Unix timestamp until the quote is valid
     pub expiry: Option<u64>,
     /// NUT-19 Pubkey
-    #[serde(skip_serializing_if = "Option::is_none")]
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "deserialize_empty_string_as_none"
+    )]
     pub pubkey: Option<PublicKey>,
     /// Extra payment-method-specific fields
     ///

+ 17 - 0
crates/cashu/src/nuts/nut05.rs

@@ -92,6 +92,10 @@ pub struct MeltRequest<Q> {
     /// Blinded Message that can be used to return change [NUT-08]
     /// Amount field of BlindedMessages `SHOULD` be set to zero
     outputs: Option<Vec<BlindedMessage>>,
+    /// Whether the client prefers asynchronous processing
+    #[serde(default)]
+    #[cfg_attr(feature = "swagger", schema(value_type = bool))]
+    prefer_async: bool,
 }
 
 #[cfg(feature = "mint")]
@@ -103,6 +107,7 @@ impl TryFrom<MeltRequest<String>> for MeltRequest<QuoteId> {
             quote: QuoteId::from_str(&value.quote).map_err(|_e| Error::InvalidQuote)?,
             inputs: value.inputs,
             outputs: value.outputs,
+            prefer_async: value.prefer_async,
         })
     }
 }
@@ -140,9 +145,21 @@ where
             quote,
             inputs: inputs.without_dleqs(),
             outputs,
+            prefer_async: false,
         }
     }
 
+    /// Set the prefer_async flag for asynchronous processing
+    pub fn prefer_async(mut self, prefer_async: bool) -> Self {
+        self.prefer_async = prefer_async;
+        self
+    }
+
+    /// Get the prefer_async flag
+    pub fn is_prefer_async(&self) -> bool {
+        self.prefer_async
+    }
+
     /// Get quote
     pub fn quote(&self) -> &Q {
         &self.quote

+ 6 - 1
crates/cashu/src/nuts/nut06.rs

@@ -13,6 +13,7 @@ use super::{
     nut04, nut05, nut15, nut19, AuthRequired, BlindAuthSettings, ClearAuthSettings,
     MppMethodSettings, ProtectedEndpoint,
 };
+use crate::util::serde_helpers::deserialize_empty_string_as_none;
 use crate::CurrencyUnit;
 
 /// Mint Version
@@ -73,7 +74,11 @@ pub struct MintInfo {
     #[serde(skip_serializing_if = "Option::is_none")]
     pub name: Option<String>,
     /// hex pubkey of the mint
-    #[serde(skip_serializing_if = "Option::is_none")]
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "deserialize_empty_string_as_none"
+    )]
     pub pubkey: Option<PublicKey>,
     /// implementation name and the version running
     #[serde(skip_serializing_if = "Option::is_none")]

+ 2 - 0
crates/cashu/src/nuts/nut10.rs

@@ -344,6 +344,8 @@ pub trait SpendingConditionVerification {
                 if has_sig_all {
                     return Ok(true);
                 }
+            } else if proof.witness.is_some() {
+                return Err(super::nut11::Error::IncorrectWitnessKind);
             }
         }
 

+ 0 - 4
crates/cashu/src/nuts/nut18/transport.rs

@@ -12,9 +12,6 @@ use crate::nuts::nut18::error::Error;
 /// Transport Type
 #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
 pub enum TransportType {
-    /// In-band transport (tokens sent directly in the payment request response)
-    #[serde(rename = "in_band")]
-    InBand,
     /// Nostr
     #[serde(rename = "nostr")]
     Nostr,
@@ -36,7 +33,6 @@ impl FromStr for TransportType {
 
     fn from_str(s: &str) -> Result<Self, Self::Err> {
         match s.to_lowercase().as_str() {
-            "in_band" => Ok(Self::InBand),
             "nostr" => Ok(Self::Nostr),
             "post" => Ok(Self::HttpPost),
             _ => Err(Error::InvalidPrefix),

+ 6 - 1
crates/cashu/src/nuts/nut23.rs

@@ -11,6 +11,7 @@ use thiserror::Error;
 use super::{BlindSignature, CurrencyUnit, MeltQuoteState, Mpp, PublicKey};
 #[cfg(feature = "mint")]
 use crate::quote_id::QuoteId;
+use crate::util::serde_helpers::deserialize_empty_string_as_none;
 use crate::Amount;
 
 /// NUT023 Error
@@ -100,7 +101,11 @@ pub struct MintQuoteBolt11Response<Q> {
     /// Unix timestamp until the quote is valid
     pub expiry: Option<u64>,
     /// NUT-19 Pubkey
-    #[serde(skip_serializing_if = "Option::is_none")]
+    #[serde(
+        default,
+        skip_serializing_if = "Option::is_none",
+        deserialize_with = "deserialize_empty_string_as_none"
+    )]
     pub pubkey: Option<PublicKey>,
 }
 impl<Q: ToString> MintQuoteBolt11Response<Q> {

+ 84 - 97
crates/cashu/src/nuts/nut26/encoding.rs

@@ -1,4 +1,4 @@
-//! NUT-26: Bech32m encoding for payment requests  
+//! NUT-26: Bech32m encoding for payment requests
 //!
 //! This module provides bech32m encoding and decoding functionality for Cashu payment requests,
 //! implementing the CREQ-B format using TLV (Tag-Length-Value) encoding as specified in NUT-26.
@@ -63,7 +63,7 @@ impl<'a> TlvReader<'a> {
         Self { data, position: 0 }
     }
 
-    fn read_tlv(&mut self) -> Result<Option<(u8, Vec<u8>)>, &'static str> {
+    fn read_tlv(&mut self) -> Result<Option<(u8, Vec<u8>)>, Error> {
         if self.position + 3 > self.data.len() {
             return Ok(None);
         }
@@ -74,7 +74,7 @@ impl<'a> TlvReader<'a> {
         self.position += 3;
 
         if self.position + len > self.data.len() {
-            return Err("TLV value extends beyond buffer");
+            return Err(Error::InvalidLength);
         }
 
         let value = self.data[self.position..self.position + len].to_vec();
@@ -156,11 +156,11 @@ impl PaymentRequest {
     /// ```
     pub fn to_bech32_string(&self) -> Result<String, Error> {
         let tlv_bytes = self.encode_tlv()?;
-        let hrp = Hrp::parse(CREQ_B_HRP).map_err(|_| Error::InvalidPrefix)?;
+        let hrp = Hrp::parse(CREQ_B_HRP).map_err(|_| Error::InvalidStructure)?;
 
         // Always emit uppercase for QR compatibility
-        let encoded =
-            bech32::encode_upper::<Bech32m>(hrp, &tlv_bytes).map_err(|_| Error::InvalidPrefix)?;
+        let encoded = bech32::encode_upper::<Bech32m>(hrp, &tlv_bytes)
+            .map_err(|_| Error::InvalidStructure)?;
         Ok(encoded)
     }
 
@@ -204,7 +204,7 @@ impl PaymentRequest {
     /// # Ok::<(), cashu::nuts::nut26::Error>(())
     /// ```
     pub fn from_bech32_string(s: &str) -> Result<Self, Error> {
-        let (hrp, data) = bech32::decode(s).map_err(|_| Error::InvalidPrefix)?;
+        let (hrp, data) = bech32::decode(s).map_err(Error::Bech32Error)?;
         if !hrp.as_str().eq_ignore_ascii_case(CREQ_B_HRP) {
             return Err(Error::InvalidPrefix);
         }
@@ -225,16 +225,22 @@ impl PaymentRequest {
         let mut transports: Vec<Transport> = Vec::new();
         let mut nut10: Option<Nut10SecretRequest> = None;
 
-        while let Some((tag, value)) = reader.read_tlv().map_err(|_| Error::InvalidPrefix)? {
+        while let Some((tag, value)) = reader.read_tlv()? {
             match tag {
                 0x01 => {
                     // id: string
-                    id = Some(String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?);
+                    if id.is_some() {
+                        return Err(Error::InvalidStructure);
+                    }
+                    id = Some(String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?);
                 }
                 0x02 => {
                     // amount: u64
+                    if amount.is_some() {
+                        return Err(Error::InvalidStructure);
+                    }
                     if value.len() != 8 {
-                        return Err(Error::InvalidPrefix);
+                        return Err(Error::InvalidLength);
                     }
                     let amount_val = u64::from_be_bytes([
                         value[0], value[1], value[2], value[3], value[4], value[5], value[6],
@@ -244,30 +250,38 @@ impl PaymentRequest {
                 }
                 0x03 => {
                     // unit: u8 or string
+                    if unit.is_some() {
+                        return Err(Error::InvalidStructure);
+                    }
                     if value.len() == 1 && value[0] == 0 {
                         unit = Some(CurrencyUnit::Sat);
                     } else {
-                        let unit_str =
-                            String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?;
+                        let unit_str = String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?;
                         unit = Some(TlvUnit::Custom(unit_str).into());
                     }
                 }
                 0x04 => {
                     // single_use: u8 (0 or 1)
+                    if single_use.is_some() {
+                        return Err(Error::InvalidStructure);
+                    }
                     if !value.is_empty() {
                         single_use = Some(value[0] != 0);
                     }
                 }
                 0x05 => {
                     // mint: string (repeatable)
-                    let mint_str = String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?;
+                    let mint_str = String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?;
                     let mint_url =
-                        MintUrl::from_str(&mint_str).map_err(|_| Error::InvalidPrefix)?;
+                        MintUrl::from_str(&mint_str).map_err(|_| Error::InvalidStructure)?;
                     mints.push(mint_url);
                 }
                 0x06 => {
                     // description: string
-                    description = Some(String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?);
+                    if description.is_some() {
+                        return Err(Error::InvalidStructure);
+                    }
+                    description = Some(String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?);
                 }
                 0x07 => {
                     // transport: sub-TLV (repeatable)
@@ -338,12 +352,8 @@ impl PaymentRequest {
         }
 
         // 0x07 transport: sub-TLV (repeatable, order = priority)
-        // In-band transports are represented by the absence of a transport tag (NUT-18 semantics)
+        // Note: In-band transport is represented by absence of transport tag per NUT-26
         for transport in &self.transports {
-            if transport._type == TransportType::InBand {
-                // Skip in-band transports - absence of transport tag means in-band
-                continue;
-            }
             let transport_bytes = Self::encode_transport(transport)?;
             writer.write_tlv(0x07, &transport_bytes);
         }
@@ -366,12 +376,15 @@ impl PaymentRequest {
         let mut tags: Vec<(String, Vec<String>)> = Vec::new();
         let mut http_target: Option<String> = None;
 
-        while let Some((tag, value)) = reader.read_tlv().map_err(|_| Error::InvalidPrefix)? {
+        while let Some((tag, value)) = reader.read_tlv()? {
             match tag {
                 0x01 => {
                     // kind: u8
+                    if kind.is_some() {
+                        return Err(Error::InvalidStructure);
+                    }
                     if value.len() != 1 {
-                        return Err(Error::InvalidPrefix);
+                        return Err(Error::InvalidLength);
                     }
                     kind = Some(value[0]);
                 }
@@ -381,19 +394,19 @@ impl PaymentRequest {
                         Some(0x00) => {
                             // nostr: 32-byte x-only pubkey
                             if value.len() != 32 {
-                                return Err(Error::InvalidPrefix);
+                                return Err(Error::InvalidLength);
                             }
                             pubkey = Some(value);
                         }
                         Some(0x01) => {
                             // http_post: UTF-8 URL string
                             http_target =
-                                Some(String::from_utf8(value).map_err(|_| Error::InvalidPrefix)?);
+                                Some(String::from_utf8(value).map_err(|_| Error::InvalidUtf8)?);
                         }
                         None => {
                             // kind should always be present if there's a target
                         }
-                        _ => return Err(Error::InvalidPrefix),
+                        _ => return Err(Error::InvalidStructure),
                     }
                 }
                 0x03 => {
@@ -409,10 +422,10 @@ impl PaymentRequest {
 
         // In-band transport is represented by absence of transport tag (0x07)
         // If we're here, we have a transport tag, so it must be nostr or http_post
-        let transport_type = match kind.ok_or(Error::InvalidPrefix)? {
+        let transport_type = match kind.ok_or(Error::InvalidStructure)? {
             0x00 => TransportType::Nostr,
             0x01 => TransportType::HttpPost,
-            _ => return Err(Error::InvalidPrefix),
+            _ => return Err(Error::InvalidStructure),
         };
 
         // Extract relays from "r" tag tuples for Nostr transport
@@ -429,29 +442,15 @@ impl PaymentRequest {
                 if let Some(pk) = pubkey {
                     Self::encode_nprofile(&pk, &relays)?
                 } else {
-                    return Err(Error::InvalidPrefix);
+                    return Err(Error::InvalidStructure);
                 }
             }
-            TransportType::HttpPost => http_target.ok_or(Error::InvalidPrefix)?,
-            TransportType::InBand => {
-                // This case should not be reachable since InBand is not decoded from transport tag
-                unreachable!("InBand transport should not be decoded from transport tag")
-            }
+            TransportType::HttpPost => http_target.ok_or(Error::InvalidStructure)?,
         };
 
-        // Convert tags to the Transport format
-        // For Nostr: keep "n" tags as-is, convert "r" tags to "relay" for compatibility
-        let mut final_tags: Vec<(String, Vec<String>)> = Vec::new();
-        for (key, values) in tags {
-            if key == "r" {
-                // Convert "r" tag tuples to "relay" tags for compatibility
-                for relay in values {
-                    final_tags.push(("relay".to_string(), vec![relay]));
-                }
-            } else {
-                final_tags.push((key, values));
-            }
-        }
+        // Keep tags as-is per NUT-26 spec (no "r" to "relay" conversion)
+        // "r" tags are part of the transport encoding and should be preserved
+        let final_tags: Vec<(String, Vec<String>)> = tags;
 
         Ok(Transport {
             _type: transport_type,
@@ -478,20 +477,13 @@ impl PaymentRequest {
         let mut writer = TlvWriter::new();
 
         // 0x01 kind: u8
-        // Note: InBand transports should not reach here (filtered out in encode_tlv)
-        // but we handle it defensively
         let kind = match transport._type {
-            TransportType::InBand => {
-                // In-band is represented by absence of transport tag, not by encoding
-                return Err(Error::InvalidPrefix);
-            }
             TransportType::Nostr => 0x00u8,
             TransportType::HttpPost => 0x01u8,
         };
         writer.write_tlv(0x01, &[kind]);
 
         // 0x02 target: bytes
-        // Note: InBand already returned error above, so only Nostr and HttpPost reach here
         match transport._type {
             TransportType::Nostr => {
                 // For nostr, decode nprofile to extract pubkey and relays
@@ -544,10 +536,6 @@ impl PaymentRequest {
                     }
                 }
             }
-            TransportType::InBand => {
-                // This case is unreachable since we return early with error for InBand
-                unreachable!("InBand transport should not reach target encoding")
-            }
         }
 
         Ok(writer.into_bytes())
@@ -561,20 +549,26 @@ impl PaymentRequest {
         let mut data: Option<Vec<u8>> = None;
         let mut tags: Vec<(String, Vec<String>)> = Vec::new();
 
-        while let Some((tag, value)) = reader.read_tlv().map_err(|_| Error::InvalidPrefix)? {
+        while let Some((tag, value)) = reader.read_tlv()? {
             match tag {
                 0x01 => {
                     // kind: u8
+                    if kind.is_some() {
+                        return Err(Error::InvalidStructure);
+                    }
                     if value.len() != 1 {
-                        return Err(Error::InvalidPrefix);
+                        return Err(Error::InvalidLength);
                     }
                     kind = Some(value[0]);
                 }
                 0x02 => {
                     // data: bytes
+                    if data.is_some() {
+                        return Err(Error::InvalidStructure);
+                    }
                     data = Some(value);
                 }
-                0x03 | 0x05 => {
+                0x03 => {
                     // tag_tuple: generic tuple (repeatable)
                     let tag_tuple = Self::decode_tag_tuple(&value)?;
                     tags.push(tag_tuple);
@@ -585,7 +579,7 @@ impl PaymentRequest {
             }
         }
 
-        let kind_val = kind.ok_or(Error::InvalidPrefix)?;
+        let kind_val = kind.ok_or(Error::InvalidStructure)?;
         let data_val = data.unwrap_or_default();
 
         // Convert kind u8 to Kind enum
@@ -645,16 +639,16 @@ impl PaymentRequest {
     /// Decode tag tuple
     fn decode_tag_tuple(bytes: &[u8]) -> Result<(String, Vec<String>), Error> {
         if bytes.is_empty() {
-            return Err(Error::InvalidPrefix);
+            return Err(Error::InvalidLength);
         }
 
         let key_len = bytes[0] as usize;
         if bytes.len() < 1 + key_len {
-            return Err(Error::InvalidPrefix);
+            return Err(Error::InvalidLength);
         }
 
         let key =
-            String::from_utf8(bytes[1..1 + key_len].to_vec()).map_err(|_| Error::InvalidPrefix)?;
+            String::from_utf8(bytes[1..1 + key_len].to_vec()).map_err(|_| Error::InvalidUtf8)?;
 
         let mut values = Vec::new();
         let mut pos = 1 + key_len;
@@ -664,11 +658,11 @@ impl PaymentRequest {
             pos += 1;
 
             if pos + val_len > bytes.len() {
-                return Err(Error::InvalidPrefix);
+                return Err(Error::InvalidLength);
             }
 
             let value = String::from_utf8(bytes[pos..pos + val_len].to_vec())
-                .map_err(|_| Error::InvalidPrefix)?;
+                .map_err(|_| Error::InvalidUtf8)?;
             values.push(value);
             pos += val_len;
         }
@@ -679,7 +673,7 @@ impl PaymentRequest {
     /// Encode tag tuple
     fn encode_tag_tuple(tag: &[String]) -> Result<Vec<u8>, Error> {
         if tag.is_empty() {
-            return Err(Error::InvalidPrefix);
+            return Err(Error::InvalidStructure);
         }
 
         let mut bytes = Vec::new();
@@ -703,9 +697,9 @@ impl PaymentRequest {
     /// - Type 0: 32-byte pubkey (required, only one)
     /// - Type 1: relay URL string (optional, repeatable)
     fn decode_nprofile(nprofile: &str) -> Result<(Vec<u8>, Vec<String>), Error> {
-        let (hrp, data) = bech32::decode(nprofile).map_err(|_| Error::InvalidPrefix)?;
+        let (hrp, data) = bech32::decode(nprofile).map_err(Error::Bech32Error)?;
         if hrp.as_str() != "nprofile" {
-            return Err(Error::InvalidPrefix);
+            return Err(Error::InvalidStructure);
         }
 
         // Parse NIP-19 TLV format (Type: 1 byte, Length: 1 byte, Value: variable)
@@ -723,7 +717,7 @@ impl PaymentRequest {
             pos += 2;
 
             if pos + len > data.len() {
-                return Err(Error::InvalidPrefix);
+                return Err(Error::InvalidLength);
             }
 
             let value = &data[pos..pos + len];
@@ -733,14 +727,14 @@ impl PaymentRequest {
                 0 => {
                     // pubkey: 32 bytes
                     if value.len() != 32 {
-                        return Err(Error::InvalidPrefix);
+                        return Err(Error::InvalidLength);
                     }
                     pubkey = Some(value.to_vec());
                 }
                 1 => {
                     // relay: UTF-8 string
                     let relay =
-                        String::from_utf8(value.to_vec()).map_err(|_| Error::InvalidPrefix)?;
+                        String::from_utf8(value.to_vec()).map_err(|_| Error::InvalidUtf8)?;
                     relays.push(relay);
                 }
                 _ => {
@@ -749,7 +743,7 @@ impl PaymentRequest {
             }
         }
 
-        let pubkey = pubkey.ok_or(Error::InvalidPrefix)?;
+        let pubkey = pubkey.ok_or(Error::InvalidStructure)?;
         Ok((pubkey, relays))
     }
 
@@ -757,7 +751,7 @@ impl PaymentRequest {
     /// NIP-19 nprofile TLV format (Type: 1 byte, Length: 1 byte, Value: variable)
     fn encode_nprofile(pubkey: &[u8], relays: &[String]) -> Result<String, Error> {
         if pubkey.len() != 32 {
-            return Err(Error::InvalidPrefix);
+            return Err(Error::InvalidLength);
         }
 
         let mut tlv_bytes = Vec::new();
@@ -770,15 +764,15 @@ impl PaymentRequest {
         // Type 1: relays (repeatable) - Length must fit in 1 byte
         for relay in relays {
             if relay.len() > 255 {
-                return Err(Error::InvalidPrefix); // Relay URL too long for NIP-19
+                return Err(Error::TagTooLong); // Relay URL too long for NIP-19
             }
             tlv_bytes.push(1); // type
             tlv_bytes.push(relay.len() as u8); // length
             tlv_bytes.extend_from_slice(relay.as_bytes());
         }
 
-        let hrp = Hrp::parse("nprofile").map_err(|_| Error::InvalidPrefix)?;
-        bech32::encode::<Bech32>(hrp, &tlv_bytes).map_err(|_| Error::InvalidPrefix)
+        let hrp = Hrp::parse("nprofile").map_err(|_| Error::InvalidStructure)?;
+        bech32::encode::<Bech32>(hrp, &tlv_bytes).map_err(|_| Error::InvalidStructure)
     }
 }
 
@@ -1186,11 +1180,11 @@ mod tests {
         // Should be encoded back as nprofile since it has relays
         assert!(decoded.transports[0].target.starts_with("nprofile"));
 
-        // Check that relay was preserved in tags
+        // Check that relay was preserved in tags as "r" per NUT-26 spec
         let tags = decoded.transports[0].tags.as_ref().unwrap();
         assert!(tags
             .iter()
-            .any(|t| t.len() >= 2 && t[0] == "relay" && t[1] == "wss://relay.example.com"));
+            .any(|t| t.len() >= 2 && t[0] == "r" && t[1] == "wss://relay.example.com"));
     }
 
     #[test]
@@ -1245,7 +1239,7 @@ mod tests {
             .any(|t| t.len() >= 2 && t[0] == "n" && t[1] == "17"));
         assert!(tags
             .iter()
-            .any(|t| t.len() >= 2 && t[0] == "relay" && t[1] == "wss://relay.damus.io"));
+            .any(|t| t.len() >= 2 && t[0] == "r" && t[1] == "wss://relay.damus.io"));
     }
 
     #[test]
@@ -1379,10 +1373,10 @@ mod tests {
             .any(|t| t.len() >= 2 && t[0] == "n" && t[1] == "44"));
         assert!(tags2
             .iter()
-            .any(|t| t.len() >= 2 && t[0] == "relay" && t[1] == "wss://relay.damus.io"));
+            .any(|t| t.len() >= 2 && t[0] == "r" && t[1] == "wss://relay.damus.io"));
         assert!(tags2
             .iter()
-            .any(|t| t.len() >= 2 && t[0] == "relay" && t[1] == "wss://nos.lol"));
+            .any(|t| t.len() >= 2 && t[0] == "r" && t[1] == "wss://nos.lol"));
     }
 
     // Test vectors from NUT-26 specification
@@ -1710,7 +1704,7 @@ mod tests {
 
     #[test]
     fn test_relay_tag_extraction_from_nprofile() {
-        // Test that relays are properly extracted from nprofile and converted to "relay" tags
+        // Test that relays are properly extracted from nprofile as "r" tags per NUT-26 spec
         let json = r#"{
             "i": "relay_test",
             "a": 100,
@@ -1739,16 +1733,16 @@ mod tests {
         // Decode and verify round-trip
         let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
 
-        // Verify relays were extracted and converted to "relay" tags
+        // Verify relays were extracted as "r" tags per NUT-26 spec
         let tags = decoded.transports[0]
             .tags
             .as_ref()
             .expect("should have tags");
 
-        // Check all three relays are present as "relay" tags
+        // Check all three relays are present as "r" tags per NUT-26 spec
         let relay_tags: Vec<&Vec<String>> = tags
             .iter()
-            .filter(|t| !t.is_empty() && t[0] == "relay")
+            .filter(|t| !t.is_empty() && t[0] == "r")
             .collect();
         assert_eq!(relay_tags.len(), 3);
 
@@ -2098,14 +2092,8 @@ mod tests {
 
     #[test]
     fn test_in_band_transport_implicit() {
-        // Test in-band transport: absence of transport tag means in-band (NUT-18 semantics)
-        // In-band transports are NOT encoded - they're represented by the absence of a transport tag
-
-        let transport = Transport {
-            _type: TransportType::InBand,
-            target: String::new(), // In-band has no target
-            tags: None,
-        };
+        // Test that in-band transport is represented by absence of transport tag
+        // Per NUT-26: in-band transport means no transport entries in the list
 
         let payment_request = PaymentRequest {
             payment_id: Some("in_band_test".to_string()),
@@ -2114,7 +2102,7 @@ mod tests {
             single_use: None,
             mints: Some(vec![MintUrl::from_str("https://mint.example.com").unwrap()]),
             description: None,
-            transports: vec![transport],
+            transports: vec![], // Empty transports = in-band per NUT-26
             nut10: None,
         };
 
@@ -2125,8 +2113,7 @@ mod tests {
         // Decode the encoded string
         let decoded = PaymentRequest::from_bech32_string(&encoded).expect("decoding should work");
 
-        // In-band transports are not encoded, so when decoded, transports should be empty
-        // (absence of transport tag = in-band is implicit)
+        // Empty transports list means in-band transport per NUT-26
         assert_eq!(decoded.transports.len(), 0);
         assert_eq!(decoded.payment_id, Some("in_band_test".to_string()));
         assert_eq!(decoded.amount, Some(Amount::from(100)));

+ 6 - 3
crates/cashu/src/nuts/nut26/error.rs

@@ -7,8 +7,10 @@ use std::fmt;
 pub enum Error {
     /// Invalid bech32m prefix (expected "creqb")
     InvalidPrefix,
-    /// Invalid TLV structure
-    InvalidTlvStructure,
+    /// Invalid TLV structure (missing required fields, unexpected values, malformed TLV)
+    InvalidStructure,
+    /// Invalid length of a TLV field or the overall structure
+    InvalidLength,
     /// Invalid UTF-8 in string field
     InvalidUtf8,
     /// Invalid public key
@@ -29,7 +31,8 @@ impl fmt::Display for Error {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             Error::InvalidPrefix => write!(f, "Invalid bech32m prefix, expected 'creqb'"),
-            Error::InvalidTlvStructure => write!(f, "Invalid TLV structure"),
+            Error::InvalidStructure => write!(f, "Invalid TLV structure"),
+            Error::InvalidLength => write!(f, "Invalid TLV field length"),
             Error::InvalidUtf8 => write!(f, "Invalid UTF-8 encoding in string field"),
             Error::InvalidPubkey => write!(f, "Invalid public key"),
             Error::UnknownKind(kind) => write!(f, "Unknown NUT-10 kind: {}", kind),

+ 12 - 0
crates/cashu/src/secret.rs

@@ -59,6 +59,18 @@ impl Secret {
         Self(secret)
     }
 
+    /// Length of the secret string in bytes
+    #[inline]
+    pub fn len(&self) -> usize {
+        self.0.len()
+    }
+
+    /// Check if the secret is empty
+    #[inline]
+    pub fn is_empty(&self) -> bool {
+        self.0.is_empty()
+    }
+
     /// [`Secret`] as bytes
     #[inline]
     pub fn as_bytes(&self) -> &[u8] {

+ 1 - 0
crates/cashu/src/util/mod.rs

@@ -1,6 +1,7 @@
 //! Cashu utils
 
 pub mod hex;
+pub mod serde_helpers;
 
 use bitcoin::secp256k1::{rand, All, Secp256k1};
 use once_cell::sync::Lazy;

+ 66 - 0
crates/cashu/src/util/serde_helpers.rs

@@ -0,0 +1,66 @@
+//! Serde helper functions
+
+use serde::{Deserialize, Deserializer};
+
+/// Deserializes an optional value, treating empty strings as `None`.
+///
+/// This is useful when external APIs return `"pubkey": ""` instead of `null`
+/// or omitting the field entirely.
+pub fn deserialize_empty_string_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
+where
+    D: Deserializer<'de>,
+    T: std::str::FromStr,
+    T::Err: std::fmt::Display,
+{
+    // First deserialize as an Option<String> to handle both null and string values
+    let opt: Option<String> = Option::deserialize(deserializer)?;
+
+    match opt {
+        None => Ok(None),
+        Some(s) if s.is_empty() => Ok(None),
+        Some(s) => s.parse::<T>().map(Some).map_err(serde::de::Error::custom),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use serde::Deserialize;
+
+    use super::*;
+    use crate::PublicKey;
+
+    #[derive(Debug, Deserialize, PartialEq)]
+    struct TestStruct {
+        #[serde(default, deserialize_with = "deserialize_empty_string_as_none")]
+        pubkey: Option<PublicKey>,
+    }
+
+    #[test]
+    fn test_empty_string_as_none() {
+        let json = r#"{"pubkey": ""}"#;
+        let result: TestStruct = serde_json::from_str(json).unwrap();
+        assert_eq!(result.pubkey, None);
+    }
+
+    #[test]
+    fn test_null_as_none() {
+        let json = r#"{"pubkey": null}"#;
+        let result: TestStruct = serde_json::from_str(json).unwrap();
+        assert_eq!(result.pubkey, None);
+    }
+
+    #[test]
+    fn test_missing_field_as_none() {
+        let json = r#"{}"#;
+        let result: TestStruct = serde_json::from_str(json).unwrap();
+        assert_eq!(result.pubkey, None);
+    }
+
+    #[test]
+    fn test_valid_pubkey() {
+        let json =
+            r#"{"pubkey": "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"}"#;
+        let result: TestStruct = serde_json::from_str(json).unwrap();
+        assert!(result.pubkey.is_some());
+    }
+}

+ 4 - 1
crates/cdk-axum/src/custom_handlers.rs

@@ -338,7 +338,10 @@ pub async fn post_melt_custom(
         .await
         .map_err(into_response)?;
 
-    let res = if prefer.respond_async {
+    // Check for async preference in either the Prefer header or the request body
+    let respond_async = prefer.respond_async || payload.is_prefer_async();
+
+    let res = if respond_async {
         // Asynchronous processing - return immediately after setup
         state
             .mint

+ 1 - 1
crates/cdk-axum/src/lib.rs

@@ -167,7 +167,7 @@ async fn cors_middleware(
     req: axum::http::Request<axum::body::Body>,
     next: axum::middleware::Next,
 ) -> Response {
-    let allowed_headers = "Content-Type, Clear-auth, Blind-auth";
+    let allowed_headers = "Content-Type, Clear-auth, Blind-auth, Prefer";
 
     // Handle preflight requests
     if req.method() == axum::http::Method::OPTIONS {

+ 51 - 53
crates/cdk-cli/src/main.rs

@@ -11,7 +11,6 @@ use bip39::Mnemonic;
 use cdk::cdk_database;
 use cdk::cdk_database::WalletDatabase;
 use cdk::nuts::CurrencyUnit;
-use cdk::wallet::MultiMintWallet;
 #[cfg(feature = "redb")]
 use cdk_redb::WalletRedbDatabase;
 use cdk_sqlite::WalletSqliteDatabase;
@@ -210,109 +209,108 @@ async fn main() -> Result<()> {
     let currency_unit = CurrencyUnit::from_str(&args.unit)
         .unwrap_or_else(|_| CurrencyUnit::Custom(args.unit.clone()));
 
-    // Create MultiMintWallet with specified currency unit
-    // The constructor will automatically load wallets for this currency unit
-    let multi_mint_wallet = match &args.proxy {
-        Some(proxy_url) => {
-            MultiMintWallet::new_with_proxy(
-                localstore.clone(),
-                seed,
-                currency_unit.clone(),
-                proxy_url.clone(),
-            )
-            .await?
+    // Create WalletRepository using builder pattern
+    let wallet_repository = {
+        let mut builder = cdk::wallet::WalletRepositoryBuilder::new()
+            .localstore(localstore.clone())
+            .seed(seed);
+
+        if let Some(proxy_url) = &args.proxy {
+            builder = builder.proxy_url(proxy_url.clone());
         }
-        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?
-            }
+
+        #[cfg(all(feature = "tor", not(target_arch = "wasm32")))]
+        if matches!(args.transport, TorToggle::On) {
+            builder = builder.tor();
         }
+
+        builder.build().await?
     };
 
     match &args.command {
         Commands::DecodeToken(sub_command_args) => {
             sub_commands::decode_token::decode_token(sub_command_args)
         }
-        Commands::Balance => sub_commands::balance::balance(&multi_mint_wallet).await,
+        Commands::Balance => sub_commands::balance::balance(&wallet_repository).await,
         Commands::Melt(sub_command_args) => {
-            sub_commands::melt::pay(&multi_mint_wallet, sub_command_args).await
+            sub_commands::melt::pay(&wallet_repository, sub_command_args, &currency_unit).await
         }
         Commands::Receive(sub_command_args) => {
-            sub_commands::receive::receive(&multi_mint_wallet, sub_command_args, &work_dir).await
+            sub_commands::receive::receive(
+                &wallet_repository,
+                sub_command_args,
+                &work_dir,
+                &currency_unit,
+            )
+            .await
         }
         Commands::Send(sub_command_args) => {
-            sub_commands::send::send(&multi_mint_wallet, sub_command_args).await
+            sub_commands::send::send(&wallet_repository, sub_command_args, &currency_unit).await
         }
         Commands::Transfer(sub_command_args) => {
-            sub_commands::transfer::transfer(&multi_mint_wallet, sub_command_args).await
+            sub_commands::transfer::transfer(&wallet_repository, sub_command_args, &currency_unit)
+                .await
         }
         Commands::CheckPending => {
-            sub_commands::check_pending::check_pending(&multi_mint_wallet).await
+            sub_commands::check_pending::check_pending(&wallet_repository).await
         }
         Commands::MintInfo(sub_command_args) => {
             sub_commands::mint_info::mint_info(args.proxy, sub_command_args).await
         }
         Commands::Mint(sub_command_args) => {
-            sub_commands::mint::mint(&multi_mint_wallet, sub_command_args).await
+            sub_commands::mint::mint(&wallet_repository, sub_command_args, &currency_unit).await
         }
         Commands::MintPending => {
-            sub_commands::pending_mints::mint_pending(&multi_mint_wallet).await
+            sub_commands::pending_mints::mint_pending(&wallet_repository).await
         }
         Commands::Burn(sub_command_args) => {
-            sub_commands::burn::burn(&multi_mint_wallet, sub_command_args).await
+            sub_commands::burn::burn(&wallet_repository, sub_command_args).await
         }
         Commands::Restore(sub_command_args) => {
-            sub_commands::restore::restore(&multi_mint_wallet, sub_command_args).await
+            sub_commands::restore::restore(&wallet_repository, sub_command_args, &currency_unit)
+                .await
         }
         Commands::UpdateMintUrl(sub_command_args) => {
-            sub_commands::update_mint_url::update_mint_url(&multi_mint_wallet, sub_command_args)
-                .await
+            sub_commands::update_mint_url::update_mint_url(
+                &wallet_repository,
+                sub_command_args,
+                &currency_unit,
+            )
+            .await
         }
         Commands::ListMintProofs => {
-            sub_commands::list_mint_proofs::proofs(&multi_mint_wallet).await
+            sub_commands::list_mint_proofs::proofs(&wallet_repository).await
         }
         Commands::DecodeRequest(sub_command_args) => {
             sub_commands::decode_request::decode_payment_request(sub_command_args)
         }
         Commands::PayRequest(sub_command_args) => {
-            sub_commands::pay_request::pay_request(&multi_mint_wallet, sub_command_args).await
+            sub_commands::pay_request::pay_request(&wallet_repository, sub_command_args).await
         }
         Commands::CreateRequest(sub_command_args) => {
-            sub_commands::create_request::create_request(&multi_mint_wallet, sub_command_args).await
+            sub_commands::create_request::create_request(
+                &wallet_repository,
+                sub_command_args,
+                &currency_unit,
+            )
+            .await
         }
         Commands::MintBlindAuth(sub_command_args) => {
             sub_commands::mint_blind_auth::mint_blind_auth(
-                &multi_mint_wallet,
+                &wallet_repository,
                 sub_command_args,
                 &work_dir,
+                &currency_unit,
             )
             .await
         }
         Commands::CatLogin(sub_command_args) => {
-            sub_commands::cat_login::cat_login(&multi_mint_wallet, sub_command_args, &work_dir)
+            sub_commands::cat_login::cat_login(&wallet_repository, sub_command_args, &work_dir)
                 .await
         }
         Commands::CatDeviceLogin(sub_command_args) => {
             sub_commands::cat_device_login::cat_device_login(
-                &multi_mint_wallet,
+                &wallet_repository,
                 sub_command_args,
                 &work_dir,
             )
@@ -321,7 +319,7 @@ async fn main() -> Result<()> {
         #[cfg(feature = "npubcash")]
         Commands::NpubCash { mint_url, command } => {
             sub_commands::npubcash::npubcash(
-                &multi_mint_wallet,
+                &wallet_repository,
                 mint_url,
                 command,
                 Some(args.npubcash_url.clone()),

+ 26 - 17
crates/cdk-cli/src/sub_commands/balance.rs

@@ -3,43 +3,52 @@ use std::collections::BTreeMap;
 use anyhow::Result;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::CurrencyUnit;
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 use cdk::Amount;
+use cdk_common::wallet::WalletKey;
 
-pub async fn balance(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
+pub async fn balance(wallet_repository: &WalletRepository) -> Result<()> {
     // Show individual mint balances
-    let mint_balances = mint_balances(multi_mint_wallet, multi_mint_wallet.unit()).await?;
+    let mint_balances = mint_balances(wallet_repository).await?;
 
-    // Show total balance using the new unified interface
-    let total = multi_mint_wallet.total_balance().await?;
     if !mint_balances.is_empty() {
+        // Aggregate totals per currency unit
+        let mut unit_totals: BTreeMap<CurrencyUnit, Amount> = BTreeMap::new();
+        for (_, unit, amount) in &mint_balances {
+            *unit_totals.entry(unit.clone()).or_insert(Amount::ZERO) += *amount;
+        }
+
         println!();
-        println!(
-            "Total balance across all wallets: {} {}",
-            total,
-            multi_mint_wallet.unit()
-        );
+        if unit_totals.len() == 1 {
+            if let Some((unit, total)) = unit_totals.into_iter().next() {
+                println!("Total balance across all wallets: {} {}", total, unit);
+            }
+        } else {
+            println!("Total balance across all wallets:");
+            for (unit, total) in &unit_totals {
+                println!("  {} {}", total, unit);
+            }
+        }
     }
 
     Ok(())
 }
 
 pub async fn mint_balances(
-    multi_mint_wallet: &MultiMintWallet,
-    unit: &CurrencyUnit,
-) -> Result<Vec<(MintUrl, Amount)>> {
-    let wallets: BTreeMap<MintUrl, Amount> = multi_mint_wallet.get_balances().await?;
+    wallet_repository: &WalletRepository,
+) -> Result<Vec<(MintUrl, CurrencyUnit, Amount)>> {
+    let wallets = wallet_repository.get_balances().await?;
 
     let mut wallets_vec = Vec::with_capacity(wallets.len());
 
-    for (i, (mint_url, amount)) in wallets
+    for (i, (wallet_key, amount)) in wallets
         .iter()
         .filter(|(_, a)| a > &&Amount::ZERO)
         .enumerate()
     {
-        let mint_url = mint_url.clone();
+        let WalletKey { mint_url, unit } = wallet_key.clone();
         println!("{i}: {mint_url} {amount} {unit}");
-        wallets_vec.push((mint_url, *amount))
+        wallets_vec.push((mint_url, unit, *amount))
     }
     Ok(wallets_vec)
 }

+ 6 - 8
crates/cdk-cli/src/sub_commands/burn.rs

@@ -1,6 +1,6 @@
 use anyhow::Result;
 use cdk::mint_url::MintUrl;
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 use cdk::Amount;
 use clap::Args;
 
@@ -11,21 +11,19 @@ pub struct BurnSubCommand {
 }
 
 pub async fn burn(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &BurnSubCommand,
 ) -> Result<()> {
     let mut total_burnt = Amount::ZERO;
 
     match &sub_command_args.mint_url {
         Some(mint_url) => {
-            let wallet = multi_mint_wallet
-                .get_wallet(mint_url)
-                .await
-                .ok_or_else(|| anyhow::anyhow!("Wallet not found for mint: {}", mint_url))?;
-            total_burnt = wallet.check_all_pending_proofs().await?;
+            for wallet in wallet_repository.get_wallets_for_mint(mint_url).await {
+                total_burnt += wallet.check_all_pending_proofs().await?;
+            }
         }
         None => {
-            for wallet in multi_mint_wallet.get_wallets().await {
+            for wallet in wallet_repository.get_wallets().await {
                 let amount_burnt = wallet.check_all_pending_proofs().await?;
                 total_burnt += amount_burnt;
             }

+ 6 - 9
crates/cdk-cli/src/sub_commands/cat_device_login.rs

@@ -1,10 +1,10 @@
 use std::path::Path;
 use std::time::Duration;
 
-use anyhow::{anyhow, Result};
+use anyhow::Result;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::MintInfo;
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 use cdk::OidcClient;
 use clap::Args;
 use serde::{Deserialize, Serialize};
@@ -19,21 +19,18 @@ pub struct CatDeviceLoginSubCommand {
 }
 
 pub async fn cat_device_login(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &CatDeviceLoginSubCommand,
     work_dir: &Path,
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
 
     // Ensure the mint exists
-    if !multi_mint_wallet.has_mint(&mint_url).await {
-        multi_mint_wallet.add_mint(mint_url.clone()).await?;
+    if !wallet_repository.has_mint(&mint_url).await {
+        wallet_repository.add_wallet(mint_url.clone()).await?;
     }
 
-    let mint_info = multi_mint_wallet
-        .fetch_mint_info(&mint_url)
-        .await?
-        .ok_or(anyhow!("Mint info not found"))?;
+    let mint_info = wallet_repository.fetch_mint_info(&mint_url).await?;
 
     let (access_token, refresh_token) = get_device_code_token(&mint_info).await;
 

+ 6 - 9
crates/cdk-cli/src/sub_commands/cat_login.rs

@@ -1,9 +1,9 @@
 use std::path::Path;
 
-use anyhow::{anyhow, Result};
+use anyhow::Result;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::MintInfo;
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 use cdk::OidcClient;
 use clap::Args;
 use serde::{Deserialize, Serialize};
@@ -21,21 +21,18 @@ pub struct CatLoginSubCommand {
 }
 
 pub async fn cat_login(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &CatLoginSubCommand,
     work_dir: &Path,
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
 
     // Ensure the mint exists
-    if !multi_mint_wallet.has_mint(&mint_url).await {
-        multi_mint_wallet.add_mint(mint_url.clone()).await?;
+    if !wallet_repository.has_mint(&mint_url).await {
+        wallet_repository.add_wallet(mint_url.clone()).await?;
     }
 
-    let mint_info = multi_mint_wallet
-        .fetch_mint_info(&mint_url)
-        .await?
-        .ok_or(anyhow!("Mint info not found"))?;
+    let mint_info = wallet_repository.fetch_mint_info(&mint_url).await?;
 
     let (access_token, refresh_token) = get_access_token(
         &mint_info,

+ 3 - 3
crates/cdk-cli/src/sub_commands/check_pending.rs

@@ -1,9 +1,9 @@
 use anyhow::Result;
-use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 use cdk::Amount;
 
-pub async fn check_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
-    let wallets = multi_mint_wallet.get_wallets().await;
+pub async fn check_pending(wallet_repository: &WalletRepository) -> Result<()> {
+    let wallets = wallet_repository.get_wallets().await;
 
     for (i, wallet) in wallets.iter().enumerate() {
         let mint_url = wallet.mint_url.clone();

+ 7 - 5
crates/cdk-cli/src/sub_commands/create_request.rs

@@ -1,5 +1,6 @@
 use anyhow::Result;
-use cdk::wallet::{payment_request as pr, MultiMintWallet};
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::{payment_request as pr, WalletRepository};
 use clap::Args;
 
 #[derive(Args)]
@@ -39,13 +40,14 @@ pub struct CreateRequestSubCommand {
 }
 
 pub async fn create_request(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &CreateRequestSubCommand,
+    unit: &CurrencyUnit,
 ) -> Result<()> {
     // Gather parameters for library call
     let params = pr::CreateRequestParams {
         amount: sub_command_args.amount,
-        unit: multi_mint_wallet.unit().to_string(),
+        unit: unit.to_string(),
         description: sub_command_args.description.clone(),
         pubkeys: sub_command_args.pubkey.clone(),
         num_sigs: sub_command_args.num_sigs,
@@ -56,7 +58,7 @@ pub async fn create_request(
         nostr_relays: sub_command_args.nostr_relay.clone(),
     };
 
-    let (req, nostr_wait) = multi_mint_wallet.create_request(params).await?;
+    let (req, nostr_wait) = wallet_repository.create_request(params).await?;
 
     // Print the request to stdout
     println!("{}", req);
@@ -64,7 +66,7 @@ pub async fn create_request(
     // If we set up Nostr transport, optionally wait for payment and receive it
     if let Some(info) = nostr_wait {
         println!("Listening for payment via Nostr...");
-        let amount = multi_mint_wallet.wait_for_nostr_payment(info).await?;
+        let amount = wallet_repository.wait_for_nostr_payment(info).await?;
         println!("Received {}", amount);
     }
 

+ 5 - 5
crates/cdk-cli/src/sub_commands/list_mint_proofs.rs

@@ -1,19 +1,19 @@
 use anyhow::Result;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::{CurrencyUnit, Proof};
-use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 
-pub async fn proofs(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
-    list_proofs(multi_mint_wallet).await?;
+pub async fn proofs(wallet_repository: &WalletRepository) -> Result<()> {
+    list_proofs(wallet_repository).await?;
     Ok(())
 }
 
 async fn list_proofs(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
 ) -> Result<Vec<(MintUrl, (Vec<Proof>, CurrencyUnit))>> {
     let mut proofs_vec = Vec::new();
 
-    let wallets = multi_mint_wallet.get_wallets().await;
+    let wallets = wallet_repository.get_wallets().await;
 
     for (i, wallet) in wallets.iter().enumerate() {
         let mint_url = wallet.mint_url.clone();

+ 303 - 284
crates/cdk-cli/src/sub_commands/melt.rs

@@ -1,15 +1,18 @@
+use std::collections::HashMap;
 use std::str::FromStr;
 
 use anyhow::{bail, Result};
 use cdk::amount::{amount_for_offer, Amount, MSAT_IN_SAT};
 use cdk::mint_url::MintUrl;
+use cdk::nuts::nut00::KnownMethod;
 use cdk::nuts::{CurrencyUnit, MeltOptions, PaymentMethod};
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 use cdk::Bolt11Invoice;
+use cdk_common::wallet::WalletKey;
 use clap::{Args, ValueEnum};
 use lightning::offers::offer::Offer;
 
-use crate::utils::{get_number_input, get_user_input};
+use crate::utils::{get_number_input, get_or_create_wallet, get_user_input};
 
 #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
 pub enum PaymentType {
@@ -23,7 +26,7 @@ pub enum PaymentType {
 
 #[derive(Args)]
 pub struct MeltSubCommand {
-    /// Mpp
+    /// Use Multi-Path Payment (split payment across multiple mints, BOLT11 only)
     #[arg(short, long, conflicts_with = "mint_url")]
     mpp: bool,
     /// Mint URL to use for melting
@@ -78,42 +81,44 @@ fn input_or_prompt(arg: Option<&String>, prompt: &str) -> Result<String> {
 }
 
 pub async fn pay(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &MeltSubCommand,
+    unit: &CurrencyUnit,
 ) -> Result<()> {
-    // Check total balance across all wallets
-    let total_balance = multi_mint_wallet.total_balance().await?;
+    // Check total balance for the requested unit
+    let balances_by_unit = wallet_repository.total_balance().await?;
+    let total_balance = balances_by_unit.get(unit).copied().unwrap_or(Amount::ZERO);
     if total_balance == Amount::ZERO {
-        bail!("No funds available");
+        bail!("No funds available for unit {}", unit);
     }
 
-    // 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 {
+    // Handle MPP mode separately
+    if sub_command_args.mpp {
+        return pay_mpp(wallet_repository, sub_command_args, unit).await;
+    }
+
+    // Determine which mint to use for melting
+    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?;
+        let balances_map = wallet_repository.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();
+        let balances_vec: Vec<(WalletKey, Amount)> = balances_map.into_iter().collect();
 
         // If only one mint exists, automatically select it
         if balances_vec.len() == 1 {
-            Some(balances_vec[0].0.clone())
+            Some(balances_vec[0].0.mint_url.clone())
         } else {
             // Display all mints with their balances and let user select
             println!("\nAvailable mints and balances:");
-            for (index, (mint_url, balance)) in balances_vec.iter().enumerate() {
+            for (index, (key, balance)) in balances_vec.iter().enumerate() {
                 println!(
-                    "  {}: {} - {} {}",
-                    index,
-                    mint_url,
-                    balance,
-                    multi_mint_wallet.unit()
+                    "  {}: {} ({}) - {} {}",
+                    index, key.mint_url, key.unit, balance, unit
                 );
             }
             println!("  {}: Any mint (auto-select best)", balances_vec.len());
@@ -126,8 +131,8 @@ pub async fn pay(
                     break None; // "Any" option selected
                 }
 
-                if let Some((mint_url, _)) = balances_vec.get(selection) {
-                    break Some(mint_url.clone());
+                if let Some((key, _)) = balances_vec.get(selection) {
+                    break Some(key.mint_url.clone());
                 }
 
                 println!("Invalid selection, please try again.");
@@ -137,293 +142,307 @@ pub async fn pay(
         }
     };
 
-    if sub_command_args.mpp {
-        // Manual MPP - user specifies which mints and amounts to use
-        if !matches!(sub_command_args.method, PaymentType::Bolt11) {
-            bail!("MPP is only supported for BOLT11 invoices");
-        }
+    let available_funds = <cdk::Amount as Into<u64>>::into(total_balance) * MSAT_IN_SAT;
 
-        let bolt11_str =
-            input_or_prompt(sub_command_args.invoice.as_ref(), "Enter bolt11 invoice")?;
-        let _bolt11 = Bolt11Invoice::from_str(&bolt11_str)?; // Validate invoice format
+    // Process payment based on payment method using individual wallets
+    match sub_command_args.method {
+        PaymentType::Bolt11 => {
+            // Process BOLT11 payment
+            let bolt11_str =
+                input_or_prompt(sub_command_args.invoice.as_ref(), "Enter bolt11 invoice")?;
+            let bolt11 = Bolt11Invoice::from_str(&bolt11_str)?;
 
-        // Show available mints and balances
-        let balances = multi_mint_wallet.get_balances().await?;
-        println!("\nAvailable mints and balances:");
-        for (i, (mint_url, balance)) in balances.iter().enumerate() {
-            println!(
-                "  {}: {} - {} {}",
-                i,
-                mint_url,
-                balance,
-                multi_mint_wallet.unit()
+            // Determine payment amount and options
+            let prompt = format!(
+                "Enter the amount you would like to pay in {} for this amountless invoice.",
+                unit
             );
-        }
+            let options =
+                create_melt_options(available_funds, bolt11.amount_milli_satoshis(), &prompt)?;
+
+            // Get or select a mint with sufficient balance
+            let mint_url = if let Some(specific_mint) = selected_mint {
+                specific_mint
+            } else {
+                // Auto-select the first mint with sufficient balance
+                let balances = wallet_repository.get_balances().await?;
+                let required_amount = bolt11
+                    .amount_milli_satoshis()
+                    .map(|a| Amount::from(a / MSAT_IN_SAT))
+                    .unwrap_or(Amount::ZERO);
+
+                balances
+                    .into_iter()
+                    .find(|(_, balance)| *balance >= required_amount)
+                    .map(|(key, _)| key.mint_url)
+                    .ok_or_else(|| anyhow::anyhow!("No mint with sufficient balance"))?
+            };
 
-        // Collect mint selections and amounts
-        let mut mint_amounts = Vec::new();
-        loop {
-            let mint_input = get_user_input("Enter mint number to use (or 'done' to finish)")?;
+            let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?;
+
+            // Get melt quote
+            let quote = wallet
+                .melt_quote(
+                    PaymentMethod::Known(KnownMethod::Bolt11),
+                    bolt11_str.clone(),
+                    options,
+                    None,
+                )
+                .await?;
+
+            println!("Melt quote created:");
+            println!("  Quote ID: {}", quote.id);
+            println!("  Amount: {}", quote.amount);
+            println!("  Fee Reserve: {}", quote.fee_reserve);
+
+            // Execute the melt
+            let melted = wallet
+                .prepare_melt(&quote.id, HashMap::new())
+                .await?
+                .confirm()
+                .await?;
 
-            if mint_input.to_lowercase() == "done" || mint_input.is_empty() {
-                break;
+            println!(
+                "Payment successful: state={}, amount={}, fee_paid={}",
+                melted.state(),
+                melted.amount(),
+                melted.fee_paid()
+            );
+            if let Some(preimage) = melted.payment_proof() {
+                println!("Payment preimage: {}", preimage);
             }
-
-            let mint_index: usize = mint_input.parse()?;
-            let mint_url = balances
-                .iter()
-                .nth(mint_index)
-                .map(|(url, _)| url.clone())
-                .ok_or_else(|| anyhow::anyhow!("Invalid mint index"))?;
-
-            let amount: u64 = get_number_input(&format!(
-                "Enter amount to use from this mint ({})",
-                multi_mint_wallet.unit()
-            ))?;
-            mint_amounts.push((mint_url, Amount::from(amount)));
-        }
-
-        if mint_amounts.is_empty() {
-            bail!("No mints selected for MPP payment");
         }
+        PaymentType::Bolt12 => {
+            // Process BOLT12 payment (offer)
+            let offer_str = input_or_prompt(sub_command_args.offer.as_ref(), "Enter BOLT12 offer")?;
+            let offer = Offer::from_str(&offer_str)
+                .map_err(|e| anyhow::anyhow!("Invalid BOLT12 offer: {:?}", e))?;
+
+            // Determine if offer has an amount
+            let prompt = format!(
+                "Enter the amount you would like to pay in {} for this amountless offer:",
+                unit
+            );
+            let amount_msat = match amount_for_offer(&offer, &CurrencyUnit::Msat) {
+                Ok(amount) => Some(u64::from(amount)),
+                Err(_) => None,
+            };
 
-        // Get quotes for each mint
-        println!("\nGetting melt quotes...");
-        let quotes = multi_mint_wallet
-            .mpp_melt_quote(bolt11_str, mint_amounts)
-            .await?;
+            let options = create_melt_options(available_funds, amount_msat, &prompt)?;
 
-        // Display quotes
-        println!("\nMelt quotes obtained:");
-        for (mint_url, quote) in &quotes {
-            println!("  {} - Quote ID: {}", mint_url, quote.id);
-            println!("    Amount: {}, Fee: {}", quote.amount, quote.fee_reserve);
-        }
+            // Get wallet for BOLT12 using the selected mint
+            let mint_url = if let Some(specific_mint) = selected_mint {
+                specific_mint
+            } else {
+                // User selected "Any" - just pick the first mint with any balance
+                let balances = wallet_repository.get_balances().await?;
 
-        // Execute the melts
-        let quotes_to_execute: Vec<(MintUrl, String)> = quotes
-            .iter()
-            .map(|(url, quote)| (url.clone(), quote.id.clone()))
-            .collect();
+                balances
+                    .into_iter()
+                    .find(|(_, balance)| *balance > Amount::ZERO)
+                    .map(|(key, _)| key.mint_url)
+                    .ok_or_else(|| anyhow::anyhow!("No mint available for BOLT12 payment"))?
+            };
 
-        println!("\nExecuting MPP payment...");
-        let results = multi_mint_wallet.mpp_melt(quotes_to_execute).await?;
+            let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?;
+
+            // Get melt quote for BOLT12
+            let quote = wallet
+                .melt_quote(
+                    PaymentMethod::Known(KnownMethod::Bolt12),
+                    offer_str,
+                    options,
+                    None,
+                )
+                .await?;
+
+            // Display quote info
+            println!("Melt quote created:");
+            println!("  Quote ID: {}", quote.id);
+            println!("  Amount: {}", quote.amount);
+            println!("  Fee Reserve: {}", quote.fee_reserve);
+            println!("  State: {}", quote.state);
+            println!("  Expiry: {}", quote.expiry);
+
+            // Execute the melt
+            let melted = wallet
+                .prepare_melt(&quote.id, HashMap::new())
+                .await?
+                .confirm()
+                .await?;
+            println!(
+                "Payment successful: Paid {} with fee {}",
+                melted.amount(),
+                melted.fee_paid()
+            );
+            if let Some(preimage) = melted.payment_proof() {
+                println!("Payment preimage: {}", preimage);
+            }
+        }
+        PaymentType::Bip353 => {
+            let bip353_addr =
+                input_or_prompt(sub_command_args.address.as_ref(), "Enter Bip353 address")?;
 
-        // Display results
-        println!("\nPayment results:");
-        let mut total_paid = Amount::ZERO;
-        let mut total_fees = Amount::ZERO;
+            let prompt = format!(
+                "Enter the amount you would like to pay in {} for this amountless offer:",
+                unit
+            );
+            // BIP353 payments are always amountless for now
+            let options = create_melt_options(available_funds, None, &prompt)?;
+
+            // Get wallet for BIP353 using the selected mint
+            let mint_url = if let Some(specific_mint) = selected_mint {
+                specific_mint
+            } else {
+                // User selected "Any" - just pick the first mint with any balance
+                let balances = wallet_repository.get_balances().await?;
+
+                balances
+                    .into_iter()
+                    .find(|(_, balance)| *balance > Amount::ZERO)
+                    .map(|(key, _)| key.mint_url)
+                    .ok_or_else(|| anyhow::anyhow!("No mint available for BIP353 payment"))?
+            };
 
-        for (mint_url, melted) in results {
+            let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?;
+
+            // Get melt quote for BIP353 address (internally resolves and gets BOLT12 quote)
+            let quote = wallet
+                .melt_bip353_quote(
+                    &bip353_addr,
+                    options.expect("Amount is required").amount_msat(),
+                )
+                .await?;
+
+            // Display quote info
+            println!("Melt quote created:");
+            println!("  Quote ID: {}", quote.id);
+            println!("  Amount: {}", quote.amount);
+            println!("  Fee Reserve: {}", quote.fee_reserve);
+            println!("  State: {}", quote.state);
+            println!("  Expiry: {}", quote.expiry);
+
+            // Execute the melt
+            let melted = wallet
+                .prepare_melt(&quote.id, HashMap::new())
+                .await?
+                .confirm()
+                .await?;
             println!(
-                "  {} - Paid: {}, Fee: {}",
-                mint_url,
+                "Payment successful: Paid {} with fee {}",
                 melted.amount(),
                 melted.fee_paid()
             );
-            total_paid += melted.amount();
-            total_fees += melted.fee_paid();
-
             if let Some(preimage) = melted.payment_proof() {
-                println!("    Preimage: {}", preimage);
+                println!("Payment preimage: {}", preimage);
             }
         }
+    }
 
-        println!("\nTotal paid: {} {}", total_paid, multi_mint_wallet.unit());
-        println!("Total fees: {} {}", total_fees, multi_mint_wallet.unit());
-    } else {
-        let available_funds = <cdk::Amount as Into<u64>>::into(total_balance) * MSAT_IN_SAT;
-
-        // Process payment based on payment method using new unified interface
-        match sub_command_args.method {
-            PaymentType::Bolt11 => {
-                // Process BOLT11 payment
-                let bolt11_str =
-                    input_or_prompt(sub_command_args.invoice.as_ref(), "Enter bolt11 invoice")?;
-                let bolt11 = Bolt11Invoice::from_str(&bolt11_str)?;
-
-                // Determine payment amount and options
-                let prompt = format!(
-                    "Enter the amount you would like to pay in {} for this amountless invoice.",
-                    multi_mint_wallet.unit()
-                );
-                let options =
-                    create_melt_options(available_funds, bolt11.amount_milli_satoshis(), &prompt)?;
-
-                // 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,
-                            PaymentMethod::BOLT11,
-                            bolt11_str.clone(),
-                            options,
-                            None,
-                        )
-                        .await?;
-
-                    println!("Melt quote created:");
-                    println!("  Quote ID: {}", quote.id);
-                    println!("  Amount: {}", quote.amount);
-                    println!("  Fee Reserve: {}", quote.fee_reserve);
-
-                    // Execute the melt
-                    multi_mint_wallet
-                        .melt_with_mint(&mint_url, &quote.id)
-                        .await?
-                } else {
-                    // User selected "Any" - let the wallet auto-select the best mint
-                    multi_mint_wallet.melt(&bolt11_str, options, None).await?
-                };
+    Ok(())
+}
 
-                println!(
-                    "Payment successful: state={}, amount={}, fee_paid={}",
-                    melted.state(),
-                    melted.amount(),
-                    melted.fee_paid()
-                );
-                if let Some(preimage) = melted.payment_proof() {
-                    println!("Payment preimage: {}", preimage);
-                }
-            }
-            PaymentType::Bolt12 => {
-                // Process BOLT12 payment (offer)
-                let offer_str =
-                    input_or_prompt(sub_command_args.offer.as_ref(), "Enter BOLT12 offer")?;
-                let offer = Offer::from_str(&offer_str)
-                    .map_err(|e| anyhow::anyhow!("Invalid BOLT12 offer: {:?}", e))?;
-
-                // Determine if offer has an amount
-                let prompt = format!(
-                    "Enter the amount you would like to pay in {} for this amountless offer:",
-                    multi_mint_wallet.unit()
-                );
-                let amount_msat = match amount_for_offer(&offer, &CurrencyUnit::Msat) {
-                    Ok(amount) => Some(u64::from(amount)),
-                    Err(_) => None,
-                };
-
-                let options = create_melt_options(available_funds, amount_msat, &prompt)?;
-
-                // Get wallet for BOLT12 using the selected mint
-                let mint_url = if let Some(specific_mint) = selected_mint {
-                    specific_mint
-                } else {
-                    // User selected "Any" - just pick the first mint with any balance
-                    let balances = multi_mint_wallet.get_balances().await?;
-
-                    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_quote(PaymentMethod::BOLT12, offer_str, options, None)
-                    .await?;
-
-                // Display quote info
-                println!("Melt quote created:");
-                println!("  Quote ID: {}", quote.id);
-                println!("  Amount: {}", quote.amount);
-                println!("  Fee Reserve: {}", quote.fee_reserve);
-                println!("  State: {}", quote.state);
-                println!("  Expiry: {}", quote.expiry);
-
-                // Execute the melt
-                let prepared = wallet
-                    .prepare_melt(&quote.id, std::collections::HashMap::new())
-                    .await?;
-                println!(
-                    "Prepared melt - Amount: {}, Fee: {}",
-                    prepared.amount(),
-                    prepared.total_fee()
-                );
-                let confirmed = prepared.confirm().await?;
-                println!(
-                    "Payment successful: Paid {} with fee {}",
-                    confirmed.amount(),
-                    confirmed.fee_paid()
-                );
-                if let Some(preimage) = confirmed.payment_proof() {
-                    println!("Payment preimage: {}", preimage);
-                }
-            }
-            PaymentType::Bip353 => {
-                let bip353_addr =
-                    input_or_prompt(sub_command_args.address.as_ref(), "Enter Bip353 address")?;
+/// Handle Multi-Path Payment (MPP) - split a BOLT11 payment across multiple mints
+async fn pay_mpp(
+    wallet_repository: &WalletRepository,
+    sub_command_args: &MeltSubCommand,
+    unit: &CurrencyUnit,
+) -> Result<()> {
+    if !matches!(sub_command_args.method, PaymentType::Bolt11) {
+        bail!("MPP is only supported for BOLT11 invoices");
+    }
 
-                let prompt = format!(
-                    "Enter the amount you would like to pay in {} for this amountless offer:",
-                    multi_mint_wallet.unit()
-                );
-                // BIP353 payments are always amountless for now
-                let options = create_melt_options(available_funds, None, &prompt)?;
-
-                // Get wallet for BIP353 using the selected mint
-                let mint_url = if let Some(specific_mint) = selected_mint {
-                    specific_mint
-                } else {
-                    // User selected "Any" - just pick the first mint with any balance
-                    let balances = multi_mint_wallet.get_balances().await?;
-
-                    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(
-                        &bip353_addr,
-                        options.expect("Amount is required").amount_msat(),
-                    )
-                    .await?;
-
-                // Display quote info
-                println!("Melt quote created:");
-                println!("  Quote ID: {}", quote.id);
-                println!("  Amount: {}", quote.amount);
-                println!("  Fee Reserve: {}", quote.fee_reserve);
-                println!("  State: {}", quote.state);
-                println!("  Expiry: {}", quote.expiry);
-
-                // Execute the melt
-                let prepared = wallet
-                    .prepare_melt(&quote.id, std::collections::HashMap::new())
-                    .await?;
-                println!(
-                    "Prepared melt - Amount: {}, Fee: {}",
-                    prepared.amount(),
-                    prepared.total_fee()
-                );
-                let confirmed = prepared.confirm().await?;
-                println!(
-                    "Payment successful: Paid {} with fee {}",
-                    confirmed.amount(),
-                    confirmed.fee_paid()
-                );
-                if let Some(preimage) = confirmed.payment_proof() {
-                    println!("Payment preimage: {}", preimage);
-                }
-            }
+    let bolt11_str = input_or_prompt(sub_command_args.invoice.as_ref(), "Enter bolt11 invoice")?;
+    // Validate invoice format
+    let _bolt11 = Bolt11Invoice::from_str(&bolt11_str)?;
+
+    // Show available mints and balances
+    let balances = wallet_repository.get_balances().await?;
+    let balances_vec: Vec<(WalletKey, Amount)> = balances.into_iter().collect();
+
+    println!("\nAvailable mints and balances:");
+    for (i, (key, balance)) in balances_vec.iter().enumerate() {
+        println!(
+            "  {}: {} ({}) - {} {}",
+            i, key.mint_url, key.unit, balance, unit
+        );
+    }
+
+    // Collect mint selections and amounts from user
+    let mut mint_amounts: Vec<(MintUrl, Amount)> = Vec::new();
+    loop {
+        let mint_input = get_user_input("Enter mint number to use (or 'done' to finish)")?;
+
+        if mint_input.to_lowercase() == "done" || mint_input.is_empty() {
+            break;
         }
+
+        let mint_index: usize = mint_input.parse()?;
+        let (key, _) = balances_vec
+            .get(mint_index)
+            .ok_or_else(|| anyhow::anyhow!("Invalid mint index"))?;
+
+        let amount: u64 =
+            get_number_input(&format!("Enter amount to use from this mint ({})", unit))?;
+        mint_amounts.push((key.mint_url.clone(), Amount::from(amount)));
+    }
+
+    if mint_amounts.is_empty() {
+        bail!("No mints selected for MPP payment");
     }
 
+    // Get quotes from each mint with MPP options
+    println!("\nGetting melt quotes...");
+    let mut quotes = Vec::new();
+    for (mint_url, amount) in &mint_amounts {
+        let wallet = get_or_create_wallet(wallet_repository, mint_url, unit).await?;
+
+        // Convert amount to millisats for MPP
+        let amount_msat = u64::from(*amount) * MSAT_IN_SAT;
+        let options = Some(MeltOptions::new_mpp(amount_msat));
+
+        let quote = wallet
+            .melt_quote(
+                PaymentMethod::Known(KnownMethod::Bolt11),
+                bolt11_str.clone(),
+                options,
+                None,
+            )
+            .await?;
+
+        println!("  {} - Quote ID: {}", mint_url, quote.id);
+        println!("    Amount: {}, Fee: {}", quote.amount, quote.fee_reserve);
+        quotes.push((mint_url.clone(), wallet, quote));
+    }
+
+    // Execute all melts
+    println!("\nExecuting MPP payment...");
+    let mut total_paid = Amount::ZERO;
+    let mut total_fees = Amount::ZERO;
+
+    for (mint_url, wallet, quote) in quotes {
+        let melted = wallet
+            .prepare_melt(&quote.id, HashMap::new())
+            .await?
+            .confirm()
+            .await?;
+
+        println!(
+            "  {} - Paid: {}, Fee: {}",
+            mint_url,
+            melted.amount(),
+            melted.fee_paid()
+        );
+        total_paid += melted.amount();
+        total_fees += melted.fee_paid();
+
+        if let Some(preimage) = melted.payment_proof() {
+            println!("    Preimage: {}", preimage);
+        }
+    }
+
+    println!("\nTotal paid: {} {}", total_paid, unit);
+    println!("Total fees: {} {}", total_fees, unit);
+
     Ok(())
 }

+ 5 - 4
crates/cdk-cli/src/sub_commands/mint.rs

@@ -4,8 +4,8 @@ use anyhow::{anyhow, Result};
 use cdk::amount::SplitTarget;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::PaymentMethod;
-use cdk::wallet::MultiMintWallet;
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
+use cdk::wallet::WalletRepository;
 use cdk::{Amount, StreamExt};
 use cdk_common::nut00::KnownMethod;
 use clap::Args;
@@ -40,13 +40,14 @@ pub struct MintSubCommand {
 }
 
 pub async fn mint(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &MintSubCommand,
+    unit: &CurrencyUnit,
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
     let description: Option<String> = sub_command_args.description.clone();
 
-    let wallet = get_or_create_wallet(multi_mint_wallet, &mint_url).await?;
+    let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?;
 
     let payment_method = PaymentMethod::from_str(&sub_command_args.method)?;
 

+ 45 - 52
crates/cdk-cli/src/sub_commands/mint_blind_auth.rs

@@ -2,13 +2,14 @@ use std::path::Path;
 
 use anyhow::{anyhow, Result};
 use cdk::mint_url::MintUrl;
-use cdk::nuts::MintInfo;
-use cdk::wallet::MultiMintWallet;
+use cdk::nuts::{CurrencyUnit, MintInfo};
+use cdk::wallet::WalletRepository;
 use cdk::{Amount, OidcClient};
 use clap::Args;
 use serde::{Deserialize, Serialize};
 
 use crate::token_storage;
+use crate::utils::get_or_create_wallet;
 
 #[derive(Args, Serialize, Deserialize)]
 pub struct MintBlindAuthSubCommand {
@@ -22,18 +23,22 @@ pub struct MintBlindAuthSubCommand {
 }
 
 pub async fn mint_blind_auth(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &MintBlindAuthSubCommand,
     work_dir: &Path,
+    unit: &CurrencyUnit,
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
 
     // Ensure the mint exists
-    if !multi_mint_wallet.has_mint(&mint_url).await {
-        multi_mint_wallet.add_mint(mint_url.clone()).await?;
+    if !wallet_repository.has_mint(&mint_url).await {
+        wallet_repository.add_wallet(mint_url.clone()).await?;
     }
 
-    multi_mint_wallet.fetch_mint_info(&mint_url).await?;
+    wallet_repository.fetch_mint_info(&mint_url).await?;
+
+    // Get a wallet for this mint
+    let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?;
 
     // Try to get the token from the provided argument or from the stored file
     let cat = match &sub_command_args.cat {
@@ -61,7 +66,7 @@ pub async fn mint_blind_auth(
     };
 
     // Try to set the access token
-    if let Err(err) = multi_mint_wallet.set_cat(&mint_url, cat.clone()).await {
+    if let Err(err) = wallet.set_cat(cat.clone()).await {
         tracing::error!("Could not set cat: {}", err);
 
         // Try to refresh the token if we have a refresh token
@@ -69,42 +74,37 @@ pub async fn mint_blind_auth(
             println!("Attempting to refresh the access token...");
 
             // Get the mint info to access OIDC configuration
-            if let Some(mint_info) = multi_mint_wallet.fetch_mint_info(&mint_url).await? {
-                match refresh_access_token(&mint_info, &token_data.refresh_token).await {
-                    Ok((new_access_token, new_refresh_token)) => {
-                        println!("Successfully refreshed access token");
-
-                        // Save the new tokens
-                        if let Err(e) = token_storage::save_tokens(
-                            work_dir,
-                            &mint_url,
-                            &new_access_token,
-                            &new_refresh_token,
-                        )
-                        .await
-                        {
-                            println!("Warning: Failed to save refreshed tokens: {e}");
-                        }
-
-                        // Try setting the new access token
-                        if let Err(err) =
-                            multi_mint_wallet.set_cat(&mint_url, new_access_token).await
-                        {
-                            tracing::error!("Could not set refreshed cat: {}", err);
-                            return Err(anyhow::anyhow!(
-                                "Authentication failed even after token refresh"
-                            ));
-                        }
-
-                        // Set the refresh token
-                        multi_mint_wallet
-                            .set_refresh_token(&mint_url, new_refresh_token)
-                            .await?;
+            let mint_info = wallet_repository.fetch_mint_info(&mint_url).await?;
+            match refresh_access_token(&mint_info, &token_data.refresh_token).await {
+                Ok((new_access_token, new_refresh_token)) => {
+                    println!("Successfully refreshed access token");
+
+                    // Save the new tokens
+                    if let Err(e) = token_storage::save_tokens(
+                        work_dir,
+                        &mint_url,
+                        &new_access_token,
+                        &new_refresh_token,
+                    )
+                    .await
+                    {
+                        println!("Warning: Failed to save refreshed tokens: {e}");
                     }
-                    Err(e) => {
-                        tracing::error!("Failed to refresh token: {}", e);
-                        return Err(anyhow::anyhow!("Failed to refresh access token: {}", e));
+
+                    // Try setting the new access token
+                    if let Err(err) = wallet.set_cat(new_access_token).await {
+                        tracing::error!("Could not set refreshed cat: {}", err);
+                        return Err(anyhow::anyhow!(
+                            "Authentication failed even after token refresh"
+                        ));
                     }
+
+                    // Set the refresh token
+                    wallet.set_refresh_token(new_refresh_token).await?;
+                }
+                Err(e) => {
+                    tracing::error!("Failed to refresh token: {}", e);
+                    return Err(anyhow::anyhow!("Failed to refresh access token: {}", e));
                 }
             }
         } else {
@@ -116,10 +116,8 @@ pub async fn mint_blind_auth(
         // If we have a refresh token, set it
         if let Ok(Some(token_data)) = token_storage::get_token_for_mint(work_dir, &mint_url).await {
             tracing::info!("Attempting to use refresh access token to refresh auth token");
-            multi_mint_wallet
-                .set_refresh_token(&mint_url, token_data.refresh_token)
-                .await?;
-            multi_mint_wallet.refresh_access_token(&mint_url).await?;
+            wallet.set_refresh_token(token_data.refresh_token).await?;
+            wallet.refresh_access_token().await?;
         }
     }
 
@@ -128,19 +126,14 @@ pub async fn mint_blind_auth(
     let amount = match sub_command_args.amount {
         Some(amount) => amount,
         None => {
-            let mint_info = multi_mint_wallet
-                .fetch_mint_info(&mint_url)
-                .await?
-                .ok_or(anyhow!("Unknown mint info"))?;
+            let mint_info = wallet_repository.fetch_mint_info(&mint_url).await?;
             mint_info
                 .bat_max_mint()
                 .ok_or(anyhow!("Unknown max bat mint"))?
         }
     };
 
-    let proofs = multi_mint_wallet
-        .mint_blind_auth(&mint_url, Amount::from(amount))
-        .await?;
+    let proofs = wallet.mint_blind_auth(Amount::from(amount)).await?;
 
     println!("Received {} auth proofs for mint {mint_url}", proofs.len());
 

+ 38 - 30
crates/cdk-cli/src/sub_commands/npubcash.rs

@@ -6,28 +6,36 @@ use anyhow::{bail, Result};
 use cdk::amount::SplitTarget;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::wallet::{MultiMintWallet, Wallet};
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::{Wallet, WalletRepository};
 use cdk::StreamExt;
 use clap::Subcommand;
 use nostr_sdk::ToBech32;
 
 /// Helper function to get wallet for a specific mint URL
 async fn get_wallet_for_mint(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     mint_url_str: &str,
 ) -> Result<Arc<Wallet>> {
     let mint_url = MintUrl::from_str(mint_url_str)?;
 
     // Check if wallet exists for this mint
-    if !multi_mint_wallet.has_mint(&mint_url).await {
+    if !wallet_repository.has_mint(&mint_url).await {
         // Add the mint to the wallet
-        multi_mint_wallet.add_mint(mint_url.clone()).await?;
+        wallet_repository.add_wallet(mint_url.clone()).await?;
     }
 
-    multi_mint_wallet
-        .get_wallet(&mint_url)
+    match wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
         .await
-        .ok_or_else(|| anyhow::anyhow!("Failed to get wallet for mint: {}", mint_url_str))
+    {
+        Ok(wallet) => Ok(Arc::new(wallet)),
+        Err(_) => Ok(Arc::new(
+            wallet_repository
+                .create_wallet(mint_url, CurrencyUnit::Sat, None)
+                .await?,
+        )),
+    }
 }
 
 #[derive(Subcommand)]
@@ -55,7 +63,7 @@ pub enum NpubCashSubCommand {
 }
 
 pub async fn npubcash(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     mint_url: &str,
     sub_command: &NpubCashSubCommand,
     npubcash_url: Option<String>,
@@ -64,23 +72,23 @@ pub async fn npubcash(
     let base_url = npubcash_url.unwrap_or_else(|| "https://npubx.cash".to_string());
 
     match sub_command {
-        NpubCashSubCommand::Sync => sync(multi_mint_wallet, mint_url, &base_url).await,
+        NpubCashSubCommand::Sync => sync(wallet_repository, mint_url, &base_url).await,
         NpubCashSubCommand::List { since, format } => {
-            list(multi_mint_wallet, mint_url, &base_url, *since, format).await
+            list(wallet_repository, mint_url, &base_url, *since, format).await
         }
-        NpubCashSubCommand::Subscribe => subscribe(multi_mint_wallet, mint_url, &base_url).await,
+        NpubCashSubCommand::Subscribe => subscribe(wallet_repository, mint_url, &base_url).await,
         NpubCashSubCommand::SetMint { url } => {
-            set_mint(multi_mint_wallet, mint_url, &base_url, url).await
+            set_mint(wallet_repository, mint_url, &base_url, url).await
         }
-        NpubCashSubCommand::ShowKeys => show_keys(multi_mint_wallet, mint_url).await,
+        NpubCashSubCommand::ShowKeys => show_keys(wallet_repository, mint_url).await,
     }
 }
 
 /// Helper function to ensure active mint consistency
-async fn ensure_active_mint(multi_mint_wallet: &MultiMintWallet, mint_url: &str) -> Result<()> {
+async fn ensure_active_mint(wallet_repository: &WalletRepository, mint_url: &str) -> Result<()> {
     let mint_url_struct = MintUrl::from_str(mint_url)?;
 
-    match multi_mint_wallet.get_active_npubcash_mint().await? {
+    match wallet_repository.get_active_npubcash_mint().await? {
         Some(active_mint) => {
             if active_mint != mint_url_struct {
                 bail!(
@@ -96,7 +104,7 @@ async fn ensure_active_mint(multi_mint_wallet: &MultiMintWallet, mint_url: &str)
         }
         None => {
             // No active mint set, set this one as active
-            multi_mint_wallet
+            wallet_repository
                 .set_active_npubcash_mint(mint_url_struct)
                 .await?;
             println!("✓ Set {} as active NpubCash mint", mint_url);
@@ -105,12 +113,12 @@ async fn ensure_active_mint(multi_mint_wallet: &MultiMintWallet, mint_url: &str)
     Ok(())
 }
 
-async fn sync(multi_mint_wallet: &MultiMintWallet, mint_url: &str, base_url: &str) -> Result<()> {
-    ensure_active_mint(multi_mint_wallet, mint_url).await?;
+async fn sync(wallet_repository: &WalletRepository, mint_url: &str, base_url: &str) -> Result<()> {
+    ensure_active_mint(wallet_repository, mint_url).await?;
 
     println!("Syncing quotes from NpubCash...");
 
-    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+    let wallet = get_wallet_for_mint(wallet_repository, mint_url).await?;
 
     // Enable NpubCash if not already enabled
     wallet.enable_npubcash(base_url.to_string()).await?;
@@ -122,15 +130,15 @@ async fn sync(multi_mint_wallet: &MultiMintWallet, mint_url: &str, base_url: &st
 }
 
 async fn list(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     mint_url: &str,
     base_url: &str,
     since: Option<u64>,
     format: &str,
 ) -> Result<()> {
-    ensure_active_mint(multi_mint_wallet, mint_url).await?;
+    ensure_active_mint(wallet_repository, mint_url).await?;
 
-    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+    let wallet = get_wallet_for_mint(wallet_repository, mint_url).await?;
 
     // Enable NpubCash if not already enabled
     wallet.enable_npubcash(base_url.to_string()).await?;
@@ -170,15 +178,15 @@ async fn list(
 }
 
 async fn subscribe(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     mint_url: &str,
     base_url: &str,
 ) -> Result<()> {
-    ensure_active_mint(multi_mint_wallet, mint_url).await?;
+    ensure_active_mint(wallet_repository, mint_url).await?;
 
     println!("=== NpubCash Quote Subscription ===\n");
 
-    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+    let wallet = get_wallet_for_mint(wallet_repository, mint_url).await?;
 
     // Enable NpubCash if not already enabled
     wallet.enable_npubcash(base_url.to_string()).await?;
@@ -241,7 +249,7 @@ async fn subscribe(
 }
 
 async fn set_mint(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     mint_url: &str,
     base_url: &str,
     url: &str,
@@ -250,11 +258,11 @@ async fn set_mint(
 
     // Update active mint in KV store
     let mint_url_struct = MintUrl::from_str(mint_url)?;
-    multi_mint_wallet
+    wallet_repository
         .set_active_npubcash_mint(mint_url_struct)
         .await?;
 
-    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+    let wallet = get_wallet_for_mint(wallet_repository, mint_url).await?;
 
     // Enable NpubCash if not already enabled
     wallet.enable_npubcash(base_url.to_string()).await?;
@@ -290,8 +298,8 @@ async fn set_mint(
     Ok(())
 }
 
-async fn show_keys(multi_mint_wallet: &MultiMintWallet, mint_url: &str) -> Result<()> {
-    let wallet = get_wallet_for_mint(multi_mint_wallet, mint_url).await?;
+async fn show_keys(wallet_repository: &WalletRepository, mint_url: &str) -> Result<()> {
+    let wallet = get_wallet_for_mint(wallet_repository, mint_url).await?;
 
     let keys = wallet.get_npubcash_keys()?;
     let npub = keys.public_key().to_bech32()?;

+ 3 - 3
crates/cdk-cli/src/sub_commands/pay_request.rs

@@ -2,7 +2,7 @@ use std::io::{self, Write};
 
 use anyhow::{anyhow, Result};
 use cdk::nuts::PaymentRequest;
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 use cdk::Amount;
 use clap::Args;
 
@@ -15,7 +15,7 @@ pub struct PayRequestSubCommand {
 }
 
 pub async fn pay_request(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &PayRequestSubCommand,
 ) -> Result<()> {
     let payment_request = &sub_command_args.payment_request;
@@ -43,7 +43,7 @@ pub async fn pay_request(
 
     let request_mints = &payment_request.mints;
 
-    let wallet_mints = multi_mint_wallet.get_wallets().await;
+    let wallet_mints = wallet_repository.get_wallets().await;
 
     // Wallets where unit, balance and mint match request
     let mut matching_wallets = vec![];

+ 11 - 4
crates/cdk-cli/src/sub_commands/pending_mints.rs

@@ -1,10 +1,17 @@
 use anyhow::Result;
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
+use cdk::Amount;
 
-pub async fn mint_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
-    let amount = multi_mint_wallet.mint_unissued_quotes(None).await?;
+pub async fn mint_pending(wallet_repository: &WalletRepository) -> Result<()> {
+    let wallets = wallet_repository.get_wallets().await;
+    let mut total_amount = Amount::ZERO;
 
-    println!("Amount: {amount}");
+    for wallet in wallets {
+        let amount = wallet.check_all_pending_proofs().await?;
+        total_amount += amount;
+    }
+
+    println!("Amount: {total_amount}");
 
     Ok(())
 }

+ 28 - 38
crates/cdk-cli/src/sub_commands/receive.rs

@@ -4,11 +4,9 @@ use std::str::FromStr;
 use std::time::Duration;
 
 use anyhow::{anyhow, Result};
-use cdk::mint_url::MintUrl;
-use cdk::nuts::{SecretKey, Token};
+use cdk::nuts::{CurrencyUnit, SecretKey, Token};
 use cdk::util::unix_time;
-use cdk::wallet::multi_mint_wallet::MultiMintWallet;
-use cdk::wallet::{MultiMintReceiveOptions, ReceiveOptions};
+use cdk::wallet::{ReceiveOptions, WalletRepository};
 use cdk::Amount;
 use clap::Args;
 use nostr_sdk::nips::nip04;
@@ -39,15 +37,13 @@ pub struct ReceiveSubCommand {
     /// Allow receiving from untrusted mints (mints not already in the wallet)
     #[arg(long, default_value = "false")]
     allow_untrusted: bool,
-    /// Transfer tokens from untrusted mints to this mint
-    #[arg(long, value_name = "MINT_URL")]
-    transfer_to: Option<String>,
 }
 
 pub async fn receive(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &ReceiveSubCommand,
     work_dir: &Path,
+    unit: &CurrencyUnit,
 ) -> Result<()> {
     let mut signing_keys = Vec::new();
 
@@ -71,12 +67,12 @@ pub async fn receive(
     let amount = match &sub_command_args.token {
         Some(token_str) => {
             receive_token(
-                multi_mint_wallet,
+                wallet_repository,
                 token_str,
                 &signing_keys,
                 &sub_command_args.preimage,
                 sub_command_args.allow_untrusted,
-                sub_command_args.transfer_to.as_deref(),
+                unit,
             )
             .await?
         }
@@ -113,12 +109,12 @@ pub async fn receive(
             let mut total_amount = Amount::ZERO;
             for token_str in &tokens {
                 match receive_token(
-                    multi_mint_wallet,
+                    wallet_repository,
                     token_str,
                     &signing_keys,
                     &sub_command_args.preimage,
                     sub_command_args.allow_untrusted,
-                    sub_command_args.transfer_to.as_deref(),
+                    unit,
                 )
                 .await
                 {
@@ -141,46 +137,40 @@ pub async fn receive(
 }
 
 async fn receive_token(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     token_str: &str,
     signing_keys: &[SecretKey],
     preimage: &[String],
     allow_untrusted: bool,
-    transfer_to: Option<&str>,
+    unit: &CurrencyUnit,
 ) -> Result<Amount> {
     let token: Token = Token::from_str(token_str)?;
 
     let mint_url = token.mint_url()?;
 
-    // Parse transfer_to mint URL if provided
-    let transfer_to_mint = if let Some(mint_str) = transfer_to {
-        Some(MintUrl::from_str(mint_str)?)
-    } else {
-        None
-    };
-
     // Check if the mint is already trusted
-    let is_trusted = multi_mint_wallet.get_wallet(&mint_url).await.is_some();
+    let is_trusted = wallet_repository.has_mint(&mint_url).await;
 
-    // If mint is not trusted and we don't allow untrusted, add it first (old behavior)
+    // If mint is not trusted and we don't allow untrusted, error out
     if !is_trusted && !allow_untrusted {
-        get_or_create_wallet(multi_mint_wallet, &mint_url).await?;
+        return Err(anyhow!(
+            "Mint {} is not trusted. Use --allow-untrusted to receive from untrusted mints.",
+            mint_url
+        ));
     }
 
-    // Create multi-mint receive options
-    let multi_mint_options = MultiMintReceiveOptions::default()
-        .allow_untrusted(allow_untrusted)
-        .transfer_to_mint(transfer_to_mint)
-        .receive_options(ReceiveOptions {
-            p2pk_signing_keys: signing_keys.to_vec(),
-            preimages: preimage.to_vec(),
-            ..Default::default()
-        });
-
-    let amount = multi_mint_wallet
-        .receive(token_str, multi_mint_options)
-        .await?;
-    Ok(amount)
+    // Get or create wallet for the token's mint
+    let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?;
+
+    // Create receive options
+    let receive_options = ReceiveOptions {
+        p2pk_signing_keys: signing_keys.to_vec(),
+        preimages: preimage.to_vec(),
+        ..Default::default()
+    };
+
+    let received = wallet.receive(token_str, receive_options).await?;
+    Ok(received)
 }
 
 /// Receive tokens sent to nostr pubkey via dm

+ 7 - 13
crates/cdk-cli/src/sub_commands/restore.rs

@@ -1,8 +1,11 @@
 use anyhow::Result;
 use cdk::mint_url::MintUrl;
-use cdk::wallet::MultiMintWallet;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::WalletRepository;
 use clap::Args;
 
+use crate::utils::get_or_create_wallet;
+
 #[derive(Args)]
 pub struct RestoreSubCommand {
     /// Mint Url
@@ -10,22 +13,13 @@ pub struct RestoreSubCommand {
 }
 
 pub async fn restore(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &RestoreSubCommand,
+    unit: &CurrencyUnit,
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
 
-    let wallet = match multi_mint_wallet.get_wallet(&mint_url).await {
-        Some(wallet) => wallet.clone(),
-        None => {
-            multi_mint_wallet.add_mint(mint_url.clone()).await?;
-            multi_mint_wallet
-                .get_wallet(&mint_url)
-                .await
-                .expect("Wallet should exist after adding mint")
-                .clone()
-        }
-    };
+    let wallet = get_or_create_wallet(wallet_repository, &mint_url, unit).await?;
 
     let restored = wallet.restore().await?;
 

+ 32 - 88
crates/cdk-cli/src/sub_commands/send.rs

@@ -2,13 +2,13 @@ use std::str::FromStr;
 
 use anyhow::{anyhow, Result};
 use cdk::mint_url::MintUrl;
-use cdk::nuts::{Conditions, PublicKey, SpendingConditions};
+use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions};
 use cdk::wallet::types::SendKind;
-use cdk::wallet::{MultiMintWallet, SendMemo, SendOptions};
+use cdk::wallet::{SendMemo, SendOptions, WalletRepository};
 use cdk::Amount;
 use clap::Args;
 
-use crate::utils::get_number_input;
+use crate::utils::{get_number_input, get_or_create_wallet};
 
 #[derive(Args)]
 pub struct SendSubCommand {
@@ -48,70 +48,50 @@ pub struct SendSubCommand {
     /// Mint URL to use for sending
     #[arg(long)]
     mint_url: Option<String>,
-    /// Allow transferring funds from other mints if the target mint has insufficient balance
-    #[arg(long)]
-    allow_transfer: bool,
-    /// Maximum amount to transfer from other mints
-    #[arg(long)]
-    max_transfer_amount: Option<u64>,
-
-    /// Specific mints to exclude from transfers (can be specified multiple times)
-    #[arg(long, action = clap::ArgAction::Append)]
-    excluded_mints: Vec<String>,
     /// Amount to send
     #[arg(short, long)]
     amount: Option<u64>,
 }
 
 pub async fn send(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &SendSubCommand,
+    unit: &CurrencyUnit,
 ) -> 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)?)
+        MintUrl::from_str(mint_url)?
     } else {
         // Get all mints with their balances
-        let balances_map = multi_mint_wallet.get_balances().await?;
+        let balances_map = wallet_repository.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();
+        let balances_vec: Vec<_> = balances_map.into_iter().collect();
 
         // If only one mint exists, automatically select it
         if balances_vec.len() == 1 {
-            Some(balances_vec[0].0.clone())
+            balances_vec[0].0.mint_url.clone()
         } else {
             // Display all mints with their balances and let user select
             println!("\nAvailable mints and balances:");
-            for (index, (mint_url, balance)) in balances_vec.iter().enumerate() {
+            for (index, (key, balance)) in balances_vec.iter().enumerate() {
                 println!(
-                    "  {}: {} - {} {}",
-                    index,
-                    mint_url,
-                    balance,
-                    multi_mint_wallet.unit()
+                    "  {}: {} ({}) - {} {}",
+                    index, key.mint_url, key.unit, balance, 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)")?;
+            loop {
+                let selection: usize = get_number_input("Enter mint number to send from")?;
 
-                if selection == balances_vec.len() {
-                    break None; // "Any" option selected
-                }
-
-                if let Some((mint_url, _)) = balances_vec.get(selection) {
-                    break Some(mint_url.clone());
+                if let Some((key, _)) = balances_vec.get(selection) {
+                    break key.mint_url.clone();
                 }
 
                 println!("Invalid selection, please try again.");
-            };
-
-            selection
+            }
         }
     };
 
@@ -119,16 +99,19 @@ pub async fn send(
         Some(amount) => Amount::from(amount),
         None => Amount::from(get_number_input::<u64>(&format!(
             "Enter value of token in {}",
-            multi_mint_wallet.unit()
+            unit
         ))?),
     };
 
-    // Check total balance across all wallets
-    let total_balance = multi_mint_wallet.total_balance().await?;
-    if total_balance < token_amount {
+    // Get or create wallet for the selected mint
+    let wallet = get_or_create_wallet(wallet_repository, &selected_mint, unit).await?;
+
+    // Check wallet balance
+    let balance = wallet.total_balance().await?;
+    if balance < token_amount {
         return Err(anyhow!(
-            "Insufficient funds. Total balance: {}, Required: {}",
-            total_balance,
+            "Insufficient funds. Wallet balance: {}, Required: {}",
+            balance,
             token_amount
         ));
     }
@@ -265,54 +248,15 @@ pub async fn send(
         ..Default::default()
     };
 
-    // Parse excluded mints from CLI arguments
-    let excluded_mints: Result<Vec<MintUrl>, _> = sub_command_args
-        .excluded_mints
-        .iter()
-        .map(|url| MintUrl::from_str(url))
-        .collect();
-    let excluded_mints = excluded_mints?;
-
-    // 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(),
-        };
-
-        multi_mint_wallet
-            .send(specific_mint, token_amount, multi_mint_options)
-            .await?
-    } else {
-        // User selected "Any" - find the first mint with sufficient balance
-        let balances = multi_mint_wallet.get_balances().await?;
-        let best_mint = balances
-            .into_iter()
-            .find(|(_, balance)| *balance >= token_amount)
-            .map(|(mint_url, _)| mint_url)
-            .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(),
-        };
-
-        multi_mint_wallet
-            .send(best_mint, token_amount, multi_mint_options)
-            .await?
-    };
+    // Prepare and confirm the send using the individual wallet
+    let prepared = wallet
+        .prepare_send(token_amount, send_options.clone())
+        .await?;
+    let memo = send_options.memo;
+    let token = prepared.confirm(memo).await?;
 
     match sub_command_args.v3 {
         true => {
-            let token = token;
-
             println!("{}", token.to_v3_string());
         }
         false => {

+ 95 - 74
crates/cdk-cli/src/sub_commands/transfer.rs

@@ -2,9 +2,9 @@ use std::str::FromStr;
 
 use anyhow::{bail, Result};
 use cdk::mint_url::MintUrl;
-use cdk::wallet::multi_mint_wallet::TransferMode;
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::WalletRepository;
 use cdk::Amount;
+use cdk_common::wallet::WalletKey;
 use clap::Args;
 
 use crate::utils::get_number_input;
@@ -27,16 +27,17 @@ pub struct TransferSubCommand {
 
 /// Helper function to select a mint from available mints
 async fn select_mint(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     prompt: &str,
     exclude_mint: Option<&MintUrl>,
+    unit: &cdk::nuts::CurrencyUnit,
 ) -> Result<MintUrl> {
-    let balances = multi_mint_wallet.get_balances().await?;
+    let balances = wallet_repository.get_balances().await?;
 
     // Filter out excluded mint if provided
     let available_mints: Vec<_> = balances
         .iter()
-        .filter(|(url, _)| exclude_mint.is_none_or(|excluded| url != &excluded))
+        .filter(|(key, _)| exclude_mint.is_none_or(|excluded| &key.mint_url != excluded))
         .collect();
 
     if available_mints.is_empty() {
@@ -44,38 +45,37 @@ async fn select_mint(
     }
 
     println!("\nAvailable mints:");
-    for (i, (mint_url, balance)) in available_mints.iter().enumerate() {
+    for (i, (key, balance)) in available_mints.iter().enumerate() {
         println!(
-            "  {}: {} - {} {}",
-            i,
-            mint_url,
-            balance,
-            multi_mint_wallet.unit()
+            "  {}: {} ({}) - {} {}",
+            i, key.mint_url, key.unit, balance, unit
         );
     }
 
     let mint_number: usize = get_number_input(prompt)?;
     available_mints
         .get(mint_number)
-        .map(|(url, _)| (*url).clone())
+        .map(|(key, _)| key.mint_url.clone())
         .ok_or_else(|| anyhow::anyhow!("Invalid mint number"))
 }
 
 pub async fn transfer(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &TransferSubCommand,
+    unit: &cdk::nuts::CurrencyUnit,
 ) -> Result<()> {
-    // Check total balance across all wallets
-    let total_balance = multi_mint_wallet.total_balance().await?;
+    // Check total balance for the requested unit
+    let balances_by_unit = wallet_repository.total_balance().await?;
+    let total_balance = balances_by_unit.get(unit).copied().unwrap_or(Amount::ZERO);
     if total_balance == Amount::ZERO {
-        bail!("No funds available");
+        bail!("No funds available for unit {}", unit);
     }
 
     // Get source mint URL either from args or by prompting user
     let source_mint_url = if let Some(source_mint) = &sub_command_args.source_mint {
         let url = MintUrl::from_str(source_mint)?;
         // Verify the mint is in the wallet
-        if !multi_mint_wallet.has_mint(&url).await {
+        if !wallet_repository.has_mint(&url).await {
             bail!(
                 "Source mint {} is not in the wallet. Please add it first.",
                 url
@@ -85,9 +85,10 @@ pub async fn transfer(
     } else {
         // Show available mints and let user select source
         select_mint(
-            multi_mint_wallet,
+            wallet_repository,
             "Enter source mint number to transfer from",
             None,
+            unit,
         )
         .await?
     };
@@ -96,7 +97,7 @@ pub async fn transfer(
     let target_mint_url = if let Some(target_mint) = &sub_command_args.target_mint {
         let url = MintUrl::from_str(target_mint)?;
         // Verify the mint is in the wallet
-        if !multi_mint_wallet.has_mint(&url).await {
+        if !wallet_repository.has_mint(&url).await {
             bail!(
                 "Target mint {} is not in the wallet. Please add it first.",
                 url
@@ -106,9 +107,10 @@ pub async fn transfer(
     } else {
         // Show available mints (excluding source) and let user select target
         select_mint(
-            multi_mint_wallet,
+            wallet_repository,
             "Enter target mint number to transfer to",
             Some(&source_mint_url),
+            unit,
         )
         .await?
     };
@@ -119,32 +121,61 @@ pub async fn transfer(
     }
 
     // Check source mint balance
-    let balances = multi_mint_wallet.get_balances().await?;
-    let source_balance = balances
-        .get(&source_mint_url)
-        .copied()
-        .unwrap_or(Amount::ZERO);
+    let balances = wallet_repository.get_balances().await?;
+    let source_key = WalletKey::new(source_mint_url.clone(), unit.clone());
+    let source_balance = balances.get(&source_key).copied().unwrap_or(Amount::ZERO);
 
     if source_balance == Amount::ZERO {
         bail!("Source mint has no balance to transfer");
     }
 
-    // Determine transfer mode based on user input
-    let transfer_mode = if sub_command_args.full_balance {
+    // Get source and target wallets
+    let source_wallet = wallet_repository.get_wallet(&source_mint_url, unit).await?;
+    let target_wallet = wallet_repository.get_wallet(&target_mint_url, unit).await?;
+
+    // Determine transfer mode and execute
+    if sub_command_args.full_balance {
         println!(
             "\nTransferring full balance ({} {}) from {} to {}...",
-            source_balance,
-            multi_mint_wallet.unit(),
-            source_mint_url,
-            target_mint_url
+            source_balance, unit, source_mint_url, target_mint_url
+        );
+
+        // Send all from source
+        let prepared = source_wallet
+            .prepare_send(source_balance, Default::default())
+            .await?;
+        let token = prepared.confirm(None).await?;
+
+        // Receive at target
+        let received = target_wallet
+            .receive(&token.to_string(), Default::default())
+            .await?;
+
+        let source_balance_after = source_wallet.total_balance().await?;
+        let target_balance_after = target_wallet.total_balance().await?;
+
+        println!("\nTransfer completed successfully!");
+        println!("Amount sent: {} {}", source_balance, unit);
+        println!("Amount received: {} {}", received, unit);
+        let fees_paid = source_balance - received;
+        if fees_paid > Amount::ZERO {
+            println!("Fees paid: {} {}", fees_paid, unit);
+        }
+        println!("\nUpdated balances:");
+        println!(
+            "  Source mint ({}): {} {}",
+            source_mint_url, source_balance_after, unit
+        );
+        println!(
+            "  Target mint ({}): {} {}",
+            target_mint_url, target_balance_after, unit
         );
-        TransferMode::FullBalance
     } else {
         let amount = match sub_command_args.amount {
             Some(amt) => Amount::from(amt),
             None => Amount::from(get_number_input::<u64>(&format!(
                 "Enter amount to transfer in {}",
-                multi_mint_wallet.unit()
+                unit
             ))?),
         };
 
@@ -152,58 +183,48 @@ pub async fn transfer(
             bail!(
                 "Insufficient funds in source mint. Available: {} {}, Required: {} {}",
                 source_balance,
-                multi_mint_wallet.unit(),
+                unit,
                 amount,
-                multi_mint_wallet.unit()
+                unit
             );
         }
 
         println!(
             "\nTransferring {} {} from {} to {}...",
-            amount,
-            multi_mint_wallet.unit(),
-            source_mint_url,
-            target_mint_url
+            amount, unit, source_mint_url, target_mint_url
         );
-        TransferMode::ExactReceive(amount)
-    };
 
-    // Perform the transfer
-    let transfer_result = multi_mint_wallet
-        .transfer(&source_mint_url, &target_mint_url, transfer_mode)
-        .await?;
-
-    println!("\nTransfer completed successfully!");
-    println!(
-        "Amount sent: {} {}",
-        transfer_result.amount_sent,
-        multi_mint_wallet.unit()
-    );
-    println!(
-        "Amount received: {} {}",
-        transfer_result.amount_received,
-        multi_mint_wallet.unit()
-    );
-    if transfer_result.fees_paid > Amount::ZERO {
+        // Send from source
+        let prepared = source_wallet
+            .prepare_send(amount, Default::default())
+            .await?;
+        let token = prepared.confirm(None).await?;
+
+        // Receive at target
+        let received = target_wallet
+            .receive(&token.to_string(), Default::default())
+            .await?;
+
+        let source_balance_after = source_wallet.total_balance().await?;
+        let target_balance_after = target_wallet.total_balance().await?;
+
+        println!("\nTransfer completed successfully!");
+        println!("Amount sent: {} {}", amount, unit);
+        println!("Amount received: {} {}", received, unit);
+        let fees_paid = amount - received;
+        if fees_paid > Amount::ZERO {
+            println!("Fees paid: {} {}", fees_paid, unit);
+        }
+        println!("\nUpdated balances:");
+        println!(
+            "  Source mint ({}): {} {}",
+            source_mint_url, source_balance_after, unit
+        );
         println!(
-            "Fees paid: {} {}",
-            transfer_result.fees_paid,
-            multi_mint_wallet.unit()
+            "  Target mint ({}): {} {}",
+            target_mint_url, target_balance_after, unit
         );
     }
-    println!("\nUpdated balances:");
-    println!(
-        "  Source mint ({}): {} {}",
-        source_mint_url,
-        transfer_result.source_balance_after,
-        multi_mint_wallet.unit()
-    );
-    println!(
-        "  Target mint ({}): {} {}",
-        target_mint_url,
-        transfer_result.target_balance_after,
-        multi_mint_wallet.unit()
-    );
 
     Ok(())
 }

+ 10 - 5
crates/cdk-cli/src/sub_commands/update_mint_url.rs

@@ -1,6 +1,7 @@
 use anyhow::Result;
 use cdk::mint_url::MintUrl;
-use cdk::wallet::MultiMintWallet;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::WalletRepository;
 use clap::Args;
 
 #[derive(Args)]
@@ -12,17 +13,21 @@ pub struct UpdateMintUrlSubCommand {
 }
 
 pub async fn update_mint_url(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     sub_command_args: &UpdateMintUrlSubCommand,
+    unit: &CurrencyUnit,
 ) -> Result<()> {
     let UpdateMintUrlSubCommand {
         old_mint_url,
         new_mint_url,
     } = sub_command_args;
 
-    multi_mint_wallet
-        .update_mint_url(old_mint_url, new_mint_url.clone())
-        .await?;
+    let mut wallet = wallet_repository
+        .get_wallet(&sub_command_args.old_mint_url, unit)
+        .await?
+        .clone();
+
+    wallet.update_mint_url(new_mint_url.clone()).await?;
 
     println!("Mint Url changed from {old_mint_url} to {new_mint_url}");
 

+ 12 - 14
crates/cdk-cli/src/utils.rs

@@ -3,7 +3,8 @@ use std::str::FromStr;
 
 use anyhow::Result;
 use cdk::mint_url::MintUrl;
-use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::WalletRepository;
 
 /// Helper function to get user input with a prompt
 pub fn get_user_input(prompt: &str) -> Result<String> {
@@ -25,20 +26,17 @@ where
     Ok(number)
 }
 
-/// Helper function to create or get a wallet
+/// Helper function to get an existing wallet or create one if it doesn't exist
 pub async fn get_or_create_wallet(
-    multi_mint_wallet: &MultiMintWallet,
+    wallet_repository: &WalletRepository,
     mint_url: &MintUrl,
-) -> Result<std::sync::Arc<cdk::wallet::Wallet>> {
-    match multi_mint_wallet.get_wallet(mint_url).await {
-        Some(wallet) => Ok(wallet),
-        None => {
-            tracing::debug!("Wallet does not exist creating..");
-            multi_mint_wallet.add_mint(mint_url.clone()).await?;
-            Ok(multi_mint_wallet
-                .get_wallet(mint_url)
-                .await
-                .expect("Wallet should exist after adding mint"))
-        }
+    unit: &CurrencyUnit,
+) -> Result<cdk::wallet::Wallet> {
+    match wallet_repository.get_wallet(mint_url, unit).await {
+        Ok(wallet) => Ok(wallet),
+        Err(_) => wallet_repository
+            .create_wallet(mint_url.clone(), unit.clone(), None)
+            .await
+            .map_err(Into::into),
     }
 }

+ 19 - 2
crates/cdk-cln/src/lib.rs

@@ -422,7 +422,14 @@ impl MintPayment for Cln {
                     }
                 }
 
-                max_fee_msat = bolt11_options.max_fee_amount.map(|a| a.into());
+                max_fee_msat = bolt11_options
+                    .max_fee_amount
+                    .map(|a| {
+                        Amount::new(a.into(), unit.clone())
+                            .convert_to(&CurrencyUnit::Msat)
+                            .map(|a| a.value())
+                    })
+                    .transpose()?;
 
                 bolt11_options.bolt11.to_string()
             }
@@ -477,7 +484,14 @@ impl MintPayment for Cln {
 
                 self.check_outgoing_unpaided(&payment_identifier).await?;
 
-                max_fee_msat = bolt12_options.max_fee_amount.map(|a| a.into());
+                max_fee_msat = bolt12_options
+                    .max_fee_amount
+                    .map(|a| {
+                        Amount::new(a.into(), unit.clone())
+                            .convert_to(&CurrencyUnit::Msat)
+                            .map(|a| a.value())
+                    })
+                    .transpose()?;
 
                 cln_response.invoice
             }
@@ -489,6 +503,9 @@ impl MintPayment for Cln {
         if invoice.is_empty() {
             return Err(Error::UnknownInvoice.into());
         }
+
+        tracing::debug!("Attempting payment with max fee: {:?}", max_fee_msat);
+
         let cln_response = cln_client
             .call_typed(&PayRequest {
                 bolt11: invoice,

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

@@ -20,6 +20,7 @@ mint = ["cashu/mint", "dep:uuid"]
 nostr = ["wallet", "cashu/nostr"]
 prometheus = ["cdk-prometheus/default"]
 http = ["dep:cdk-http-client"]
+grpc = ["dep:tonic"]
 
 [dependencies]
 cdk-http-client = { workspace = true, optional = true }
@@ -44,6 +45,7 @@ serde_with.workspace = true
 web-time.workspace = true
 parking_lot = "0.12.5"
 paste = "1.0.15"
+tonic = { workspace = true, optional = true }
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 tokio = { version = "1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "test-util", "sync"] }

+ 230 - 8
crates/cdk-common/src/common.rs

@@ -196,6 +196,124 @@ impl Default for QuoteTTL {
     }
 }
 
+/// Mint Fee Reserve
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct FeeReserve {
+    /// Absolute expected min fee
+    pub min_fee_reserve: Amount,
+    /// Percentage expected fee
+    pub percent_fee_reserve: f32,
+}
+
+/// CDK Version
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct IssuerVersion {
+    /// Implementation name (e.g., "cdk", "nutshell")
+    pub implementation: String,
+    /// Major version
+    pub major: u16,
+    /// Minor version
+    pub minor: u16,
+    /// Patch version
+    pub patch: u16,
+}
+
+impl IssuerVersion {
+    /// Create new [`IssuerVersion`]
+    pub fn new(implementation: String, major: u16, minor: u16, patch: u16) -> Self {
+        Self {
+            implementation,
+            major,
+            minor,
+            patch,
+        }
+    }
+}
+
+impl std::fmt::Display for IssuerVersion {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(
+            f,
+            "{}/{}.{}.{}",
+            self.implementation, self.major, self.minor, self.patch
+        )
+    }
+}
+
+impl PartialOrd for IssuerVersion {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        if self.implementation != other.implementation {
+            return None;
+        }
+
+        match self.major.cmp(&other.major) {
+            std::cmp::Ordering::Equal => match self.minor.cmp(&other.minor) {
+                std::cmp::Ordering::Equal => Some(self.patch.cmp(&other.patch)),
+                other => Some(other),
+            },
+            other => Some(other),
+        }
+    }
+}
+
+impl std::str::FromStr for IssuerVersion {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let (implementation, version_str) = s
+            .split_once('/')
+            .ok_or(Error::Custom(format!("Invalid version string: {}", s)))?;
+        let implementation = implementation.to_string();
+
+        let parts: Vec<&str> = version_str.splitn(3, '.').collect();
+        if parts.len() != 3 {
+            return Err(Error::Custom(format!("Invalid version string: {}", s)));
+        }
+
+        let major = parts[0]
+            .parse()
+            .map_err(|_| Error::Custom(format!("Invalid major version: {}", parts[0])))?;
+        let minor = parts[1]
+            .parse()
+            .map_err(|_| Error::Custom(format!("Invalid minor version: {}", parts[1])))?;
+
+        // Handle patch version with optional suffixes like -rc1
+        let patch_str = parts[2];
+        let patch_end = patch_str
+            .find(|c: char| !c.is_numeric())
+            .unwrap_or(patch_str.len());
+        let patch = patch_str[..patch_end]
+            .parse()
+            .map_err(|_| Error::Custom(format!("Invalid patch version: {}", parts[2])))?;
+
+        Ok(Self {
+            implementation,
+            major,
+            minor,
+            patch,
+        })
+    }
+}
+
+impl Serialize for IssuerVersion {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+impl<'de> Deserialize<'de> for IssuerVersion {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        let s = String::deserialize(deserializer)?;
+        std::str::FromStr::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use std::str::FromStr;
@@ -267,13 +385,117 @@ mod tests {
         assert_eq!(finalized.fee_paid(), Amount::from(1));
         assert_eq!(finalized.total_amount(), Amount::from(32));
     }
-}
 
-/// Mint Fee Reserve
-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
-pub struct FeeReserve {
-    /// Absolute expected min fee
-    pub min_fee_reserve: Amount,
-    /// Percentage expected fee
-    pub percent_fee_reserve: f32,
+    use super::IssuerVersion;
+
+    #[test]
+    fn test_version_parsing() {
+        // Test explicit cdk format
+        let v = IssuerVersion::from_str("cdk/1.2.3").unwrap();
+        assert_eq!(v.implementation, "cdk");
+        assert_eq!(v.major, 1);
+        assert_eq!(v.minor, 2);
+        assert_eq!(v.patch, 3);
+        assert_eq!(v.to_string(), "cdk/1.2.3");
+
+        // Test nutshell format
+        let v = IssuerVersion::from_str("nutshell/0.16.0").unwrap();
+        assert_eq!(v.implementation, "nutshell");
+        assert_eq!(v.major, 0);
+        assert_eq!(v.minor, 16);
+        assert_eq!(v.patch, 0);
+        assert_eq!(v.to_string(), "nutshell/0.16.0");
+    }
+
+    #[test]
+    fn test_version_ordering() {
+        let v1 = IssuerVersion::from_str("cdk/0.1.0").unwrap();
+        let v2 = IssuerVersion::from_str("cdk/0.1.1").unwrap();
+        let v3 = IssuerVersion::from_str("cdk/0.2.0").unwrap();
+        let v4 = IssuerVersion::from_str("cdk/1.0.0").unwrap();
+
+        assert!(v1 < v2);
+        assert!(v2 < v3);
+        assert!(v3 < v4);
+        assert!(v1 < v4);
+
+        // Test mixed implementations
+        let v_nutshell = IssuerVersion::from_str("nutshell/0.1.0").unwrap();
+        assert_eq!(v1.partial_cmp(&v_nutshell), None);
+        assert!(!(v1 < v_nutshell));
+        assert!(!(v1 > v_nutshell));
+        assert!(!(v1 == v_nutshell));
+    }
+
+    #[test]
+    fn test_version_serialization() {
+        let v = IssuerVersion::from_str("cdk/0.14.2").unwrap();
+        let json = serde_json::to_string(&v).unwrap();
+        assert_eq!(json, "\"cdk/0.14.2\"");
+
+        let v_deserialized: IssuerVersion = serde_json::from_str(&json).unwrap();
+        assert_eq!(v, v_deserialized);
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_with_suffix() {
+        let version_str = "cdk/0.15.0-rc1";
+        let version = IssuerVersion::from_str(version_str).unwrap();
+        assert_eq!(version.implementation, "cdk");
+        assert_eq!(version.major, 0);
+        assert_eq!(version.minor, 15);
+        assert_eq!(version.patch, 0);
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_standard() {
+        let version_str = "cdk/0.15.0";
+        let version = IssuerVersion::from_str(version_str).unwrap();
+        assert_eq!(version.implementation, "cdk");
+        assert_eq!(version.major, 0);
+        assert_eq!(version.minor, 15);
+        assert_eq!(version.patch, 0);
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_complex_suffix() {
+        let version_str = "cdk/0.15.0-beta.1+build123";
+        let version = IssuerVersion::from_str(version_str).unwrap();
+        assert_eq!(version.implementation, "cdk");
+        assert_eq!(version.major, 0);
+        assert_eq!(version.minor, 15);
+        assert_eq!(version.patch, 0);
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_invalid() {
+        // Missing prefix
+        let version_str = "0.15.0";
+        assert!(IssuerVersion::from_str(version_str).is_err());
+
+        // Invalid version format
+        let version_str = "cdk/0.15";
+        assert!(IssuerVersion::from_str(version_str).is_err());
+
+        let version_str = "cdk/0.15.a";
+        assert!(IssuerVersion::from_str(version_str).is_err());
+    }
+
+    #[test]
+    fn test_cdk_version_parsing_with_implementation() {
+        let version_str = "nutshell/0.16.2";
+        let version = IssuerVersion::from_str(version_str).unwrap();
+        assert_eq!(version.implementation, "nutshell");
+        assert_eq!(version.major, 0);
+        assert_eq!(version.minor, 16);
+        assert_eq!(version.patch, 2);
+    }
+
+    #[test]
+    fn test_cdk_version_comparison_different_implementations() {
+        let v1 = IssuerVersion::from_str("cdk/0.15.0").unwrap();
+        let v2 = IssuerVersion::from_str("nutshell/0.15.0").unwrap();
+
+        assert_eq!(v1.partial_cmp(&v2), None);
+    }
 }

+ 11 - 0
crates/cdk-common/src/database/mint/test/keys.rs

@@ -5,6 +5,7 @@ use std::str::FromStr;
 use bitcoin::bip32::DerivationPath;
 use cashu::{CurrencyUnit, Id};
 
+use crate::common::IssuerVersion;
 use crate::database::mint::{Database, Error, KeysDatabase};
 use crate::mint::MintKeySetInfo;
 
@@ -29,6 +30,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset info
@@ -44,6 +46,7 @@ where
     assert_eq!(retrieved.unit, keyset_info.unit);
     assert_eq!(retrieved.active, keyset_info.active);
     assert_eq!(retrieved.amounts, keyset_info.amounts);
+    assert_eq!(retrieved.issuer_version, keyset_info.issuer_version);
 }
 
 /// Test adding duplicate keyset info is idempotent
@@ -62,6 +65,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset info first time
@@ -97,6 +101,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     let keyset_id2 = Id::from_str("00916bbf7ef91a37").unwrap();
@@ -110,6 +115,7 @@ where
         derivation_path_index: Some(1),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset infos
@@ -141,6 +147,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset info
@@ -173,6 +180,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     let keyset_id_usd = Id::from_str("00916bbf7ef91a37").unwrap();
@@ -186,6 +194,7 @@ where
         derivation_path_index: Some(1),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add keyset infos and set as active
@@ -223,6 +232,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     let keyset_id2 = Id::from_str("00916bbf7ef91a37").unwrap();
@@ -236,6 +246,7 @@ where
         derivation_path_index: Some(1),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
 
     // Add both keysets and set first as active

+ 2 - 0
crates/cdk-common/src/database/mint/test/mod.rs

@@ -12,6 +12,7 @@ use bitcoin::bip32::DerivationPath;
 use cashu::CurrencyUnit;
 
 use super::*;
+use crate::common::IssuerVersion;
 use crate::database::KVStoreDatabase;
 use crate::mint::MintKeySetInfo;
 
@@ -49,6 +50,7 @@ where
         derivation_path_index: Some(0),
         input_fee_ppk: 0,
         amounts: standard_keyset_amounts(32),
+        issuer_version: IssuerVersion::from_str("cdk/0.1.0").ok(),
     };
     let mut writer = db.begin_transaction().await.expect("db.begin()");
     writer.add_keyset_info(keyset_info).await.unwrap();

+ 38 - 13
crates/cdk-common/src/error.rs

@@ -110,6 +110,9 @@ pub enum Error {
     /// Unsupported payment method
     #[error("Payment method unsupported")]
     UnsupportedPaymentMethod,
+    /// Payment method required
+    #[error("Payment method required")]
+    PaymentMethodRequired,
     /// Could not parse bolt12
     #[error("Could not parse bolt12")]
     Bolt12parse,
@@ -212,6 +215,24 @@ pub enum Error {
         /// Maximum allowed outputs
         max: usize,
     },
+    /// Proof content too large (secret or witness exceeds max length)
+    #[error("Proof content too large: {actual} bytes, max {max}")]
+    ProofContentTooLarge {
+        /// Actual size in bytes
+        actual: usize,
+        /// Maximum allowed size in bytes
+        max: usize,
+    },
+    /// Request field content too large (description or extra exceeds max length)
+    #[error("Request field '{field}' too large: {actual} bytes, max {max}")]
+    RequestFieldTooLarge {
+        /// Name of the field that exceeded the limit
+        field: String,
+        /// Actual size in bytes
+        actual: usize,
+        /// Maximum allowed size in bytes
+        max: usize,
+    },
     /// Multiple units provided
     #[error("Cannot have multiple units")]
     MultipleUnits,
@@ -280,16 +301,7 @@ pub enum Error {
     #[error("Preimage not provided")]
     PreimageNotProvided,
 
-    // MultiMint Wallet Errors
-    /// Currency unit mismatch in MultiMintWallet
-    #[error("Currency unit mismatch: wallet uses {expected}, but {found} provided")]
-    MultiMintCurrencyUnitMismatch {
-        /// Expected currency unit
-        expected: CurrencyUnit,
-        /// Found currency unit
-        found: CurrencyUnit,
-    },
-    /// Unknown mint in MultiMintWallet
+    /// Unknown mint
     #[error("Unknown mint: {mint_url}")]
     UnknownMint {
         /// URL of the unknown mint
@@ -558,7 +570,6 @@ impl Error {
             | Self::IncorrectMint
             | Self::MultiMintTokenNotSupported
             | Self::PreimageNotProvided
-            | Self::MultiMintCurrencyUnitMismatch { .. }
             | Self::UnknownMint { .. }
             | Self::UnexpectedProofState
             | Self::NoActiveKeyset
@@ -944,7 +955,14 @@ impl From<Error> for ErrorResponse {
                 code: ErrorCode::ConcurrentUpdate,
                 detail: err.to_string(),
             },
-
+            Error::MaxInputsExceeded { .. } => ErrorResponse {
+                code: ErrorCode::MaxInputsExceeded,
+                detail: err.to_string()
+            },
+            Error::MaxOutputsExceeded { .. } => ErrorResponse {
+                code: ErrorCode::MaxOutputsExceeded,
+                detail: err.to_string()
+            },
             // Fallback for any remaining errors - use Unknown(99999) instead of TokenNotVerified
             _ => ErrorResponse {
                 code: ErrorCode::Unknown(50000),
@@ -1062,7 +1080,10 @@ pub enum ErrorCode {
     IncorrectQuoteAmount,
     /// Unit in request is not supported (11013)
     UnsupportedUnit,
-
+    /// The max number of inputs is exceeded
+    MaxInputsExceeded,
+    /// The max number of outputs is exceeded
+    MaxOutputsExceeded,
     // 12xxx - Keyset errors
     /// Keyset is not known (12001)
     KeysetNotFound,
@@ -1132,6 +1153,8 @@ impl ErrorCode {
             11011 => Self::AmountlessInvoiceNotSupported,
             11012 => Self::IncorrectQuoteAmount,
             11013 => Self::UnsupportedUnit,
+            11014 => Self::MaxInputsExceeded,
+            11015 => Self::MaxOutputsExceeded,
             // 12xxx - Keyset errors
             12001 => Self::KeysetNotFound,
             12002 => Self::KeysetInactive,
@@ -1176,6 +1199,8 @@ impl ErrorCode {
             Self::AmountlessInvoiceNotSupported => 11011,
             Self::IncorrectQuoteAmount => 11012,
             Self::UnsupportedUnit => 11013,
+            Self::MaxInputsExceeded => 11014,
+            Self::MaxOutputsExceeded => 11015,
             // 12xxx - Keyset errors
             Self::KeysetNotFound => 12001,
             Self::KeysetInactive => 12002,

+ 45 - 0
crates/cdk-common/src/grpc.rs

@@ -0,0 +1,45 @@
+//! gRPC version checking utilities
+
+use tonic::{Request, Status};
+
+/// Header name for protocol version
+pub const VERSION_HEADER: &str = "x-cdk-protocol-version";
+
+/// Creates a client-side interceptor that injects a specific protocol version into outgoing requests
+///
+/// # Panics
+/// Panics if the version string is not a valid gRPC metadata ASCII value
+pub fn create_version_inject_interceptor(
+    version: &'static str,
+) -> impl Fn(Request<()>) -> Result<Request<()>, Status> + Clone {
+    move |mut request: Request<()>| {
+        request.metadata_mut().insert(
+            VERSION_HEADER,
+            version.parse().expect("Invalid protocol version"),
+        );
+        Ok(request)
+    }
+}
+
+/// Creates a server-side interceptor that validates a specific protocol version on incoming requests
+pub fn create_version_check_interceptor(
+    expected_version: &'static str,
+) -> impl Fn(Request<()>) -> Result<Request<()>, Status> + Clone {
+    move |request: Request<()>| match request.metadata().get(VERSION_HEADER) {
+        Some(version) => {
+            let version = version
+                .to_str()
+                .map_err(|_| Status::invalid_argument("Invalid protocol version header"))?;
+            if version != expected_version {
+                return Err(Status::failed_precondition(format!(
+                    "Protocol version mismatch: server={}, client={}",
+                    expected_version, version
+                )));
+            }
+            Ok(request)
+        }
+        None => Err(Status::failed_precondition(
+            "Missing x-cdk-protocol-version header",
+        )),
+    }
+}

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

@@ -8,6 +8,18 @@
 
 pub mod task;
 
+/// Protocol version for gRPC Mint RPC communication
+pub const MINT_RPC_PROTOCOL_VERSION: &str = "1.0.0";
+
+/// Protocol version for gRPC Signatory communication
+pub const SIGNATORY_PROTOCOL_VERSION: &str = "1.0.0";
+
+/// Protocol version for gRPC Payment Processor communication
+pub const PAYMENT_PROCESSOR_PROTOCOL_VERSION: &str = "1.0.0";
+
+#[cfg(feature = "grpc")]
+pub mod grpc;
+
 pub mod common;
 pub mod database;
 pub mod error;

+ 8 - 0
crates/cdk-common/src/mint.rs

@@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize};
 use tracing::instrument;
 use uuid::Uuid;
 
+use crate::common::IssuerVersion;
 use crate::nuts::{MeltQuoteState, MintQuoteState};
 use crate::payment::PaymentIdentifier;
 use crate::{Amount, CurrencyUnit, Error, Id, KeySetInfo, PublicKey};
@@ -155,6 +156,8 @@ pub enum MeltSagaState {
     SetupComplete,
     /// Payment attempted to Lightning network (may or may not have succeeded)
     PaymentAttempted,
+    /// TX1 committed (proofs Spent, quote Paid) - change signing + cleanup pending
+    Finalizing,
 }
 
 impl fmt::Display for MeltSagaState {
@@ -162,6 +165,7 @@ impl fmt::Display for MeltSagaState {
         match self {
             MeltSagaState::SetupComplete => write!(f, "setup_complete"),
             MeltSagaState::PaymentAttempted => write!(f, "payment_attempted"),
+            MeltSagaState::Finalizing => write!(f, "finalizing"),
         }
     }
 }
@@ -173,6 +177,7 @@ impl FromStr for MeltSagaState {
         match value.as_str() {
             "setup_complete" => Ok(MeltSagaState::SetupComplete),
             "payment_attempted" => Ok(MeltSagaState::PaymentAttempted),
+            "finalizing" => Ok(MeltSagaState::Finalizing),
             _ => Err(Error::Custom(format!("Invalid melt saga state: {}", value))),
         }
     }
@@ -210,6 +215,7 @@ impl SagaStateEnum {
             SagaStateEnum::Melt(state) => match state {
                 MeltSagaState::SetupComplete => "setup_complete",
                 MeltSagaState::PaymentAttempted => "payment_attempted",
+                MeltSagaState::Finalizing => "finalizing",
             },
         }
     }
@@ -880,6 +886,8 @@ pub struct MintKeySetInfo {
     pub input_fee_ppk: u64,
     /// Final expiry
     pub final_expiry: Option<u64>,
+    /// Issuer Version
+    pub issuer_version: Option<IssuerVersion>,
 }
 
 /// Default fee

+ 3 - 1
crates/cdk-common/src/wallet/mod.rs

@@ -278,7 +278,9 @@ impl MintQuote {
             }
         } else {
             // Other payment methods track incremental payments
-            self.amount_paid.saturating_sub(self.amount_issued)
+            self.amount_paid
+                .checked_sub(self.amount_issued)
+                .unwrap_or(Amount::ZERO)
         }
     }
 }

+ 2 - 2
crates/cdk-ffi/src/lib.rs

@@ -9,7 +9,6 @@
 pub mod database;
 pub mod error;
 pub mod logging;
-pub mod multi_mint_wallet;
 #[cfg(feature = "npubcash")]
 pub mod npubcash;
 #[cfg(feature = "postgres")]
@@ -18,15 +17,16 @@ pub mod sqlite;
 pub mod token;
 pub mod types;
 pub mod wallet;
+pub mod wallet_repository;
 
 pub use database::*;
 pub use error::*;
 pub use logging::*;
-pub use multi_mint_wallet::*;
 #[cfg(feature = "npubcash")]
 pub use npubcash::*;
 pub use types::*;
 pub use wallet::*;
+pub use wallet_repository::*;
 
 uniffi::setup_scaffolding!();
 

+ 4 - 2
crates/cdk-ffi/src/logging.rs

@@ -23,7 +23,7 @@ static INIT: Once = std::sync::Once::new();
 /// ```dart
 /// await CdkFfi.initLogging("debug");
 /// // Now all logs will be visible in stdout
-/// final wallet = await MultiMintWallet.create(...);
+/// final wallet = await WalletRepository.create(...);
 /// ```
 #[uniffi::export]
 pub fn init_logging(level: String) {
@@ -37,7 +37,9 @@ pub fn init_logging(level: String) {
                 Config::default()
                     .with_max_level(LevelFilter::Trace)
                     .with_tag("cdk")
-                    .with_format(|f, record| write!(f, "{}", record.args()))
+                    .format(|f: &mut dyn std::fmt::Write, record: &log::Record| {
+                        write!(f, "{}", record.args())
+                    })
                     .with_filter(FilterBuilder::new().parse(&level).build()),
             );
         }

+ 0 - 1178
crates/cdk-ffi/src/multi_mint_wallet.rs

@@ -1,1178 +0,0 @@
-//! FFI MultiMintWallet bindings
-
-use std::collections::HashMap;
-use std::str::FromStr;
-use std::sync::Arc;
-
-use bip39::Mnemonic;
-use cdk::wallet::multi_mint_wallet::{
-    MultiMintReceiveOptions as CdkMultiMintReceiveOptions,
-    MultiMintSendOptions as CdkMultiMintSendOptions, MultiMintWallet as CdkMultiMintWallet,
-    TokenData as CdkTokenData, TransferMode as CdkTransferMode,
-    TransferResult as CdkTransferResult,
-};
-
-use crate::error::FfiError;
-use crate::token::Token;
-use crate::types::payment_request::{
-    CreateRequestParams, CreateRequestResult, NostrWaitInfo, PaymentRequest,
-};
-use crate::types::*;
-
-/// FFI-compatible MultiMintWallet
-#[derive(uniffi::Object)]
-pub struct MultiMintWallet {
-    inner: Arc<CdkMultiMintWallet>,
-}
-
-#[uniffi::export(async_runtime = "tokio")]
-impl MultiMintWallet {
-    /// Create a new MultiMintWallet from mnemonic using WalletDatabaseFfi trait
-    #[uniffi::constructor]
-    pub fn new(
-        unit: CurrencyUnit,
-        mnemonic: String,
-        db: Arc<dyn crate::database::WalletDatabase>,
-    ) -> Result<Self, FfiError> {
-        // Parse mnemonic and generate seed without passphrase
-        let m = Mnemonic::parse(&mnemonic)
-            .map_err(|e| FfiError::internal(format!("Invalid mnemonic: {}", e)))?;
-        let seed = m.to_seed_normalized("");
-
-        // Convert the FFI database trait to a CDK database implementation
-        let localstore = crate::database::create_cdk_database_from_ffi(db);
-
-        let wallet = match tokio::runtime::Handle::try_current() {
-            Ok(handle) => tokio::task::block_in_place(|| {
-                handle.block_on(async move {
-                    CdkMultiMintWallet::new(localstore, seed, unit.into()).await
-                })
-            }),
-            Err(_) => {
-                // No current runtime, create a new one
-                tokio::runtime::Runtime::new()
-                    .map_err(|e| FfiError::internal(format!("Failed to create runtime: {}", e)))?
-                    .block_on(async move {
-                        CdkMultiMintWallet::new(localstore, seed, unit.into()).await
-                    })
-            }
-        }?;
-
-        Ok(Self {
-            inner: Arc::new(wallet),
-        })
-    }
-
-    /// Create a new MultiMintWallet with proxy configuration
-    #[uniffi::constructor]
-    pub fn new_with_proxy(
-        unit: CurrencyUnit,
-        mnemonic: String,
-        db: Arc<dyn crate::database::WalletDatabase>,
-        proxy_url: String,
-    ) -> Result<Self, FfiError> {
-        // Parse mnemonic and generate seed without passphrase
-        let m = Mnemonic::parse(&mnemonic)
-            .map_err(|e| FfiError::internal(format!("Invalid mnemonic: {}", e)))?;
-        let seed = m.to_seed_normalized("");
-
-        // Convert the FFI database trait to a CDK database implementation
-        let localstore = crate::database::create_cdk_database_from_ffi(db);
-
-        // Parse proxy URL
-        let proxy_url = url::Url::parse(&proxy_url)
-            .map_err(|e| FfiError::internal(format!("Invalid URL: {}", e)))?;
-
-        let wallet = match tokio::runtime::Handle::try_current() {
-            Ok(handle) => tokio::task::block_in_place(|| {
-                handle.block_on(async move {
-                    CdkMultiMintWallet::new_with_proxy(localstore, seed, unit.into(), proxy_url)
-                        .await
-                })
-            }),
-            Err(_) => {
-                // No current runtime, create a new one
-                tokio::runtime::Runtime::new()
-                    .map_err(|e| FfiError::internal(format!("Failed to create runtime: {}", e)))?
-                    .block_on(async move {
-                        CdkMultiMintWallet::new_with_proxy(localstore, seed, unit.into(), proxy_url)
-                            .await
-                    })
-            }
-        }?;
-
-        Ok(Self {
-            inner: Arc::new(wallet),
-        })
-    }
-
-    /// Get the currency unit for this wallet
-    pub fn unit(&self) -> CurrencyUnit {
-        self.inner.unit().clone().into()
-    }
-
-    /// Set metadata cache TTL (time-to-live) in seconds for a specific mint
-    ///
-    /// Controls how long cached mint metadata (keysets, keys, mint info) is considered fresh
-    /// before requiring a refresh from the mint server for a specific mint.
-    ///
-    /// # Arguments
-    ///
-    /// * `mint_url` - The mint URL to set the TTL for
-    /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires.
-    pub async fn set_metadata_cache_ttl_for_mint(
-        &self,
-        mint_url: MintUrl,
-        ttl_secs: Option<u64>,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let wallets = self.inner.get_wallets().await;
-
-        if let Some(wallet) = wallets.iter().find(|w| w.mint_url == cdk_mint_url) {
-            let ttl = ttl_secs.map(std::time::Duration::from_secs);
-            wallet.set_metadata_cache_ttl(ttl);
-            Ok(())
-        } else {
-            Err(FfiError::internal(format!(
-                "Mint not found: {}",
-                cdk_mint_url
-            )))
-        }
-    }
-
-    /// Set metadata cache TTL (time-to-live) in seconds for all mints
-    ///
-    /// Controls how long cached mint metadata is considered fresh for all mints
-    /// in this MultiMintWallet.
-    ///
-    /// # Arguments
-    ///
-    /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires for any mint.
-    pub async fn set_metadata_cache_ttl_for_all_mints(&self, ttl_secs: Option<u64>) {
-        let wallets = self.inner.get_wallets().await;
-        let ttl = ttl_secs.map(std::time::Duration::from_secs);
-
-        for wallet in wallets.iter() {
-            wallet.set_metadata_cache_ttl(ttl);
-        }
-    }
-
-    /// Add a mint to this MultiMintWallet
-    pub async fn add_mint(
-        &self,
-        mint_url: MintUrl,
-        target_proof_count: Option<u32>,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-
-        if let Some(count) = target_proof_count {
-            let config = cdk::wallet::multi_mint_wallet::WalletConfig::new()
-                .with_target_proof_count(count as usize);
-            self.inner
-                .add_mint_with_config(cdk_mint_url, config)
-                .await?;
-        } else {
-            self.inner.add_mint(cdk_mint_url).await?;
-        }
-        Ok(())
-    }
-
-    /// Remove mint from MultiMintWallet
-    ///
-    /// # Panics
-    ///
-    /// Panics if the hardcoded fallback URL is invalid (should never happen).
-    pub async fn remove_mint(&self, mint_url: MintUrl) {
-        let url_str = mint_url.url.clone();
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into().unwrap_or_else(|_| {
-            // If conversion fails, we can't remove the mint, but we shouldn't panic
-            // This is a best-effort operation
-            cdk::mint_url::MintUrl::from_str(&url_str).unwrap_or_else(|_| {
-                // Last resort: create a dummy URL that won't match anything
-                cdk::mint_url::MintUrl::from_str("https://invalid.mint")
-                    .expect("Valid hardcoded URL")
-            })
-        });
-        self.inner.remove_mint(&cdk_mint_url).await;
-    }
-
-    /// Check if mint is in wallet
-    pub async fn has_mint(&self, mint_url: MintUrl) -> bool {
-        if let Ok(cdk_mint_url) = mint_url.try_into() {
-            self.inner.has_mint(&cdk_mint_url).await
-        } else {
-            false
-        }
-    }
-
-    pub async fn get_mint_keysets(&self, mint_url: MintUrl) -> Result<Vec<KeySetInfo>, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let keysets = self.inner.get_mint_keysets(&cdk_mint_url).await?;
-
-        let keysets = keysets.into_iter().map(|k| k.into()).collect();
-
-        Ok(keysets)
-    }
-
-    /// Get token data (mint URL and proofs) from a token
-    ///
-    /// This method extracts the mint URL and proofs from a token. It will automatically
-    /// fetch the keysets from the mint if needed to properly decode the proofs.
-    ///
-    /// The mint must already be added to the wallet. If the mint is not in the wallet,
-    /// use `add_mint` first.
-    pub async fn get_token_data(&self, token: Arc<Token>) -> Result<TokenData, FfiError> {
-        let token_data = self.inner.get_token_data(&token.inner).await?;
-        Ok(token_data.into())
-    }
-
-    /// Get wallet balances for all mints
-    pub async fn get_balances(&self) -> Result<BalanceMap, FfiError> {
-        let balances = self.inner.get_balances().await?;
-        let mut balance_map = HashMap::new();
-        for (mint_url, amount) in balances {
-            balance_map.insert(mint_url.to_string(), amount.into());
-        }
-        Ok(balance_map)
-    }
-
-    /// Get total balance across all mints
-    pub async fn total_balance(&self) -> Result<Amount, FfiError> {
-        let total = self.inner.total_balance().await?;
-        Ok(total.into())
-    }
-
-    /// List proofs for all mints
-    pub async fn list_proofs(&self) -> Result<ProofsByMint, FfiError> {
-        let proofs = self.inner.list_proofs().await?;
-        let mut proofs_by_mint = HashMap::new();
-        for (mint_url, mint_proofs) in proofs {
-            let ffi_proofs: Vec<Proof> = mint_proofs.into_iter().map(|p| p.into()).collect();
-            proofs_by_mint.insert(mint_url.to_string(), ffi_proofs);
-        }
-        Ok(proofs_by_mint)
-    }
-
-    /// Check the state of proofs at a specific mint
-    pub async fn check_proofs_state(
-        &self,
-        mint_url: MintUrl,
-        proofs: Proofs,
-    ) -> Result<Vec<ProofState>, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
-            proofs.into_iter().map(|p| p.try_into()).collect();
-        let cdk_proofs = cdk_proofs?;
-
-        let states = self
-            .inner
-            .check_proofs_state(&cdk_mint_url, cdk_proofs)
-            .await?;
-
-        Ok(states.into_iter().map(|s| s.into()).collect())
-    }
-
-    /// Receive token
-    pub async fn receive(
-        &self,
-        token: Arc<Token>,
-        options: MultiMintReceiveOptions,
-    ) -> Result<Amount, FfiError> {
-        let amount = self
-            .inner
-            .receive(&token.to_string(), options.into())
-            .await?;
-        Ok(amount.into())
-    }
-
-    /// Restore wallets for a specific mint
-    pub async fn restore(&self, mint_url: MintUrl) -> Result<Restored, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let restored = self.inner.restore(&cdk_mint_url).await?;
-        Ok(restored.into())
-    }
-
-    /// Get all pending send operations across all mints
-    pub async fn get_pending_sends(&self) -> Result<Vec<PendingSend>, FfiError> {
-        let sends = self.inner.get_pending_sends().await?;
-        Ok(sends
-            .into_iter()
-            .map(|(mint_url, id)| PendingSend {
-                mint_url: mint_url.into(),
-                operation_id: id.to_string(),
-            })
-            .collect())
-    }
-
-    /// Revoke a pending send operation for a specific mint
-    pub async fn revoke_send(
-        &self,
-        mint_url: MintUrl,
-        operation_id: String,
-    ) -> Result<Amount, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let uuid = uuid::Uuid::parse_str(&operation_id)
-            .map_err(|e| FfiError::internal(format!("Invalid operation ID: {}", e)))?;
-        let amount = self.inner.revoke_send(cdk_mint_url, uuid).await?;
-        Ok(amount.into())
-    }
-
-    /// Check status of a pending send operation for a specific mint
-    pub async fn check_send_status(
-        &self,
-        mint_url: MintUrl,
-        operation_id: String,
-    ) -> Result<bool, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let uuid = uuid::Uuid::parse_str(&operation_id)
-            .map_err(|e| FfiError::internal(format!("Invalid operation ID: {}", e)))?;
-        let claimed = self.inner.check_send_status(cdk_mint_url, uuid).await?;
-        Ok(claimed)
-    }
-
-    /// Send tokens from a specific mint
-    ///
-    /// This method prepares and confirms the send in one step.
-    /// For more control over the send process, use the single-mint Wallet.
-    pub async fn send(
-        &self,
-        mint_url: MintUrl,
-        amount: Amount,
-        options: MultiMintSendOptions,
-    ) -> Result<Token, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let token = self
-            .inner
-            .send(cdk_mint_url, amount.into(), options.into())
-            .await?;
-        Ok(token.into())
-    }
-
-    /// Get a mint quote from a specific mint
-    pub async fn mint_quote(
-        &self,
-        mint_url: MintUrl,
-        payment_method: PaymentMethod,
-        amount: Option<Amount>,
-        description: Option<String>,
-        extra: Option<String>,
-    ) -> Result<MintQuote, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let quote = self
-            .inner
-            .mint_quote(
-                &cdk_mint_url,
-                payment_method,
-                amount.map(Into::into),
-                description,
-                extra,
-            )
-            .await?;
-        Ok(quote.into())
-    }
-
-    /// Refresh a specific mint quote status from the mint.
-    /// Updates local store with current state from mint.
-    /// Does NOT mint tokens - use mint() to mint a specific quote.
-    pub async fn refresh_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
-            .refresh_mint_quote(&cdk_mint_url, &quote_id)
-            .await?;
-        Ok(quote.into())
-    }
-
-    /// Mint tokens at a specific mint
-    pub async fn mint(
-        &self,
-        mint_url: MintUrl,
-        quote_id: String,
-        spending_conditions: Option<SpendingConditions>,
-    ) -> 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
-            .mint(
-                &cdk_mint_url,
-                &quote_id,
-                cdk::amount::SplitTarget::default(),
-                conditions,
-            )
-            .await?;
-        Ok(proofs.into_iter().map(|p| 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 timeout = std::time::Duration::from_secs(timeout_secs);
-
-        let proofs = self
-            .inner
-            .wait_for_mint_quote(
-                &cdk_mint_url,
-                &quote_id,
-                split_target.into(),
-                conditions,
-                timeout,
-            )
-            .await?;
-        Ok(proofs.into_iter().map(|p| p.into()).collect())
-    }
-
-    /// Get a melt quote from a specific mint
-    pub async fn melt_quote(
-        &self,
-        mint_url: MintUrl,
-        payment_method: PaymentMethod,
-        request: String,
-        options: Option<MeltOptions>,
-        extra: Option<String>,
-    ) -> Result<MeltQuote, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let cdk_options = options.map(Into::into);
-        let quote = self
-            .inner
-            .melt_quote(&cdk_mint_url, payment_method, request, cdk_options, extra)
-            .await?;
-        Ok(quote.into())
-    }
-
-    /// Get a melt quote for a BIP353 human-readable address
-    ///
-    /// This method resolves a BIP353 address (e.g., "alice@example.com") to a Lightning offer
-    /// and then creates a melt quote for that offer at the specified mint.
-    ///
-    /// # Arguments
-    ///
-    /// * `mint_url` - The mint to use for creating the melt quote
-    /// * `bip353_address` - Human-readable address in the format "user@domain.com"
-    /// * `amount_msat` - Amount to pay in millisatoshis
-    #[cfg(not(target_arch = "wasm32"))]
-    pub async fn melt_bip353_quote(
-        &self,
-        mint_url: MintUrl,
-        bip353_address: String,
-        amount_msat: u64,
-    ) -> Result<MeltQuote, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let cdk_amount = cdk::Amount::from(amount_msat);
-        let quote = self
-            .inner
-            .melt_bip353_quote(&cdk_mint_url, &bip353_address, cdk_amount)
-            .await?;
-        Ok(quote.into())
-    }
-
-    /// Get a melt quote for a Lightning address
-    ///
-    /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice
-    /// and then creates a melt quote for that invoice at the specified mint.
-    ///
-    /// # Arguments
-    ///
-    /// * `mint_url` - The mint to use for creating the melt quote
-    /// * `lightning_address` - Lightning address in the format "user@domain.com"
-    /// * `amount_msat` - Amount to pay in millisatoshis
-    pub async fn melt_lightning_address_quote(
-        &self,
-        mint_url: MintUrl,
-        lightning_address: String,
-        amount_msat: u64,
-    ) -> Result<MeltQuote, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let cdk_amount = cdk::Amount::from(amount_msat);
-        let quote = self
-            .inner
-            .melt_lightning_address_quote(&cdk_mint_url, &lightning_address, cdk_amount)
-            .await?;
-        Ok(quote.into())
-    }
-
-    /// Get a melt quote for a human-readable address
-    ///
-    /// This method accepts a human-readable address that could be either a BIP353 address
-    /// or a Lightning address. It intelligently determines which to try based on mint support:
-    ///
-    /// 1. If the mint supports Bolt12, it tries BIP353 first
-    /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails
-    /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address
-    /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly
-    ///
-    /// # Arguments
-    ///
-    /// * `mint_url` - The mint to use for creating the melt quote
-    /// * `address` - Human-readable address (BIP353 or Lightning address)
-    /// * `amount_msat` - Amount to pay in millisatoshis
-    #[cfg(not(target_arch = "wasm32"))]
-    pub async fn melt_human_readable_quote(
-        &self,
-        mint_url: MintUrl,
-        address: String,
-        amount_msat: u64,
-    ) -> Result<MeltQuote, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let cdk_amount = cdk::Amount::from(amount_msat);
-        let quote = self
-            .inner
-            .melt_human_readable_quote(&cdk_mint_url, &address, cdk_amount)
-            .await?;
-        Ok(quote.into())
-    }
-
-    /// Melt tokens
-    pub async fn melt_with_mint(
-        &self,
-        mint_url: MintUrl,
-        quote_id: String,
-    ) -> Result<FinalizedMelt, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let finalized = self.inner.melt_with_mint(&cdk_mint_url, &quote_id).await?;
-        Ok(finalized.into())
-    }
-
-    /// Melt specific proofs from a specific mint
-    ///
-    /// This method allows melting proofs that may not be in the wallet's database,
-    /// similar to how `receive_proofs` handles external proofs. The proofs will be
-    /// added to the database and used for the melt operation.
-    ///
-    /// # Arguments
-    ///
-    /// * `mint_url` - The mint to use for the melt operation
-    /// * `quote_id` - The melt quote ID (obtained from `melt_quote`)
-    /// * `proofs` - The proofs to melt (can be external proofs not in the wallet's database)
-    ///
-    /// # Returns
-    ///
-    /// A `FinalizedMelt` result containing the payment details and any change proofs
-    pub async fn melt_proofs(
-        &self,
-        mint_url: MintUrl,
-        quote_id: String,
-        proofs: Proofs,
-    ) -> Result<FinalizedMelt, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
-            proofs.into_iter().map(|p| p.try_into()).collect();
-        let cdk_proofs = cdk_proofs?;
-
-        let finalized = self
-            .inner
-            .melt_proofs(&cdk_mint_url, &quote_id, cdk_proofs)
-            .await?;
-        Ok(finalized.into())
-    }
-
-    /// Check melt quote status
-    pub async fn check_melt_quote(
-        &self,
-        mint_url: MintUrl,
-        quote_id: String,
-    ) -> Result<MeltQuote, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let quote = self
-            .inner
-            .check_melt_quote(&cdk_mint_url, &quote_id)
-            .await?;
-        Ok(quote.into())
-    }
-
-    /// Melt tokens (pay a bolt11 invoice)
-    pub async fn melt(
-        &self,
-        bolt11: String,
-        options: Option<MeltOptions>,
-        max_fee: Option<Amount>,
-    ) -> Result<FinalizedMelt, FfiError> {
-        let cdk_options = options.map(Into::into);
-        let cdk_max_fee = max_fee.map(Into::into);
-        let finalized = self.inner.melt(&bolt11, cdk_options, cdk_max_fee).await?;
-        Ok(finalized.into())
-    }
-
-    /// Transfer funds between mints
-    pub async fn transfer(
-        &self,
-        source_mint: MintUrl,
-        target_mint: MintUrl,
-        transfer_mode: TransferMode,
-    ) -> Result<TransferResult, FfiError> {
-        let source_cdk: cdk::mint_url::MintUrl = source_mint.try_into()?;
-        let target_cdk: cdk::mint_url::MintUrl = target_mint.try_into()?;
-        let result = self
-            .inner
-            .transfer(&source_cdk, &target_cdk, transfer_mode.into())
-            .await?;
-        Ok(result.into())
-    }
-
-    /// Swap proofs with automatic wallet selection
-    pub async fn swap(
-        &self,
-        amount: Option<Amount>,
-        spending_conditions: Option<SpendingConditions>,
-    ) -> Result<Option<Proofs>, FfiError> {
-        let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?;
-
-        let result = self.inner.swap(amount.map(Into::into), conditions).await?;
-
-        Ok(result.map(|proofs| proofs.into_iter().map(|p| p.into()).collect()))
-    }
-
-    /// List transactions from all mints
-    pub async fn list_transactions(
-        &self,
-        direction: Option<TransactionDirection>,
-    ) -> Result<Vec<Transaction>, FfiError> {
-        let cdk_direction = direction.map(Into::into);
-        let transactions = self.inner.list_transactions(cdk_direction).await?;
-        Ok(transactions.into_iter().map(Into::into).collect())
-    }
-
-    /// Get proofs for a transaction by transaction ID
-    ///
-    /// This retrieves all proofs associated with a transaction. If `mint_url` is provided,
-    /// it will only check that specific mint's wallet. Otherwise, it searches across all
-    /// wallets to find which mint the transaction belongs to.
-    ///
-    /// # Arguments
-    ///
-    /// * `id` - The transaction ID
-    /// * `mint_url` - Optional mint URL to check directly, avoiding iteration over all wallets
-    pub async fn get_proofs_for_transaction(
-        &self,
-        id: TransactionId,
-        mint_url: Option<MintUrl>,
-    ) -> Result<Vec<Proof>, FfiError> {
-        let cdk_id = id.try_into()?;
-        let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?;
-        let proofs = self
-            .inner
-            .get_proofs_for_transaction(cdk_id, cdk_mint_url)
-            .await?;
-        Ok(proofs.into_iter().map(Into::into).collect())
-    }
-
-    /// Refresh all unissued mint quote states
-    /// Does NOT mint - use mint_unissued_quotes() for that
-    pub async fn refresh_all_mint_quotes(
-        &self,
-        mint_url: Option<MintUrl>,
-    ) -> Result<Vec<MintQuote>, FfiError> {
-        let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?;
-        let quotes = self.inner.refresh_all_mint_quotes(cdk_mint_url).await?;
-        Ok(quotes.into_iter().map(Into::into).collect())
-    }
-
-    /// Refresh states and mint all unissued quotes
-    /// Returns total amount minted across all wallets
-    pub async fn mint_unissued_quotes(
-        &self,
-        mint_url: Option<MintUrl>,
-    ) -> Result<Amount, FfiError> {
-        let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?;
-        let amount = self.inner.mint_unissued_quotes(cdk_mint_url).await?;
-        Ok(amount.into())
-    }
-
-    /// Consolidate proofs across all mints
-    pub async fn consolidate(&self) -> Result<Amount, FfiError> {
-        let amount = self.inner.consolidate().await?;
-        Ok(amount.into())
-    }
-
-    /// Get list of mint URLs
-    pub async fn get_mint_urls(&self) -> Vec<String> {
-        let wallets = self.inner.get_wallets().await;
-        wallets.iter().map(|w| w.mint_url.to_string()).collect()
-    }
-
-    /// Get all wallets from MultiMintWallet
-    pub async fn get_wallets(&self) -> Vec<Arc<crate::wallet::Wallet>> {
-        let wallets = self.inner.get_wallets().await;
-        wallets
-            .into_iter()
-            .map(|w| Arc::new(crate::wallet::Wallet::from_inner(w)))
-            .collect()
-    }
-
-    /// Get a specific wallet from MultiMintWallet by mint URL
-    pub async fn get_wallet(&self, mint_url: MintUrl) -> Option<Arc<crate::wallet::Wallet>> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into().ok()?;
-        let wallet = self.inner.get_wallet(&cdk_mint_url).await?;
-        Some(Arc::new(crate::wallet::Wallet::from_inner(wallet)))
-    }
-
-    /// Verify token DLEQ proofs
-    pub async fn verify_token_dleq(&self, token: Arc<Token>) -> Result<(), FfiError> {
-        let cdk_token = token.inner.clone();
-        self.inner.verify_token_dleq(&cdk_token).await?;
-        Ok(())
-    }
-
-    /// Query mint for current mint information
-    pub async fn fetch_mint_info(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let mint_info = self.inner.fetch_mint_info(&cdk_mint_url).await?;
-        Ok(mint_info.map(Into::into))
-    }
-
-    /// Get mint info for all wallets
-    ///
-    /// This method loads the mint info for each wallet in the MultiMintWallet
-    /// and returns a map of mint URLs to their corresponding mint info.
-    ///
-    /// Uses cached mint info when available, only fetching from the mint if the cache
-    /// has expired.
-    pub async fn get_all_mint_info(&self) -> Result<MintInfoMap, FfiError> {
-        let mint_infos = self.inner.get_all_mint_info().await?;
-        let mut result = HashMap::new();
-        for (mint_url, mint_info) in mint_infos {
-            result.insert(mint_url.to_string(), mint_info.into());
-        }
-        Ok(result)
-    }
-}
-
-/// Payment request methods for MultiMintWallet
-#[uniffi::export(async_runtime = "tokio")]
-impl MultiMintWallet {
-    /// Pay a NUT-18 PaymentRequest
-    ///
-    /// This method handles paying a payment request by selecting an appropriate mint:
-    /// - If `mint_url` is provided, it verifies the payment request accepts that mint
-    ///   and uses it to pay.
-    /// - If `mint_url` is None, it automatically selects the mint that:
-    ///   1. Is accepted by the payment request (matches one of the request's mints, or request accepts any mint)
-    ///   2. Has the highest balance among matching mints
-    ///
-    /// # Arguments
-    ///
-    /// * `payment_request` - The NUT-18 payment request to pay
-    /// * `mint_url` - Optional specific mint to use. If None, automatically selects the best matching mint.
-    /// * `custom_amount` - Custom amount to pay (required if payment request has no amount)
-    ///
-    /// # Errors
-    ///
-    /// Returns an error if:
-    /// - The payment request has no amount and no custom amount is provided
-    /// - The specified mint is not accepted by the payment request
-    /// - No matching mint has sufficient balance
-    /// - No transport is available in the payment request
-    pub async fn pay_request(
-        &self,
-        payment_request: Arc<PaymentRequest>,
-        mint_url: Option<MintUrl>,
-        custom_amount: Option<Amount>,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?;
-        let cdk_amount = custom_amount.map(Into::into);
-
-        self.inner
-            .pay_request(payment_request.inner().clone(), cdk_mint_url, cdk_amount)
-            .await?;
-
-        Ok(())
-    }
-
-    /// Create a NUT-18 payment request
-    ///
-    /// Creates a payment request that can be shared to receive Cashu tokens.
-    /// The request can include optional amount, description, and spending conditions.
-    ///
-    /// # Arguments
-    ///
-    /// * `params` - Parameters for creating the payment request
-    ///
-    /// # Transport Options
-    ///
-    /// - `"nostr"` - Uses Nostr relays for privacy-preserving delivery (requires nostr_relays)
-    /// - `"http"` - Uses HTTP POST for delivery (requires http_url)
-    /// - `"none"` - No transport; token must be delivered out-of-band
-    ///
-    /// # Example
-    ///
-    /// ```ignore
-    /// let params = CreateRequestParams {
-    ///     amount: Some(100),
-    ///     unit: "sat".to_string(),
-    ///     description: Some("Coffee payment".to_string()),
-    ///     transport: "http".to_string(),
-    ///     http_url: Some("https://example.com/callback".to_string()),
-    ///     ..Default::default()
-    /// };
-    /// let result = wallet.create_request(params).await?;
-    /// println!("Share this request: {}", result.payment_request.to_string_encoded());
-    ///
-    /// // If using Nostr transport, wait for payment:
-    /// if let Some(nostr_info) = result.nostr_wait_info {
-    ///     let amount = wallet.wait_for_nostr_payment(nostr_info).await?;
-    ///     println!("Received {} sats", amount);
-    /// }
-    /// ```
-    pub async fn create_request(
-        &self,
-        params: CreateRequestParams,
-    ) -> Result<CreateRequestResult, FfiError> {
-        let (payment_request, nostr_wait_info) = self.inner.create_request(params.into()).await?;
-        Ok(CreateRequestResult {
-            payment_request: Arc::new(PaymentRequest::from_inner(payment_request)),
-            nostr_wait_info: nostr_wait_info.map(|info| Arc::new(NostrWaitInfo::from_inner(info))),
-        })
-    }
-
-    /// Wait for a Nostr payment and receive it into the wallet
-    ///
-    /// This method connects to the Nostr relays specified in the `NostrWaitInfo`,
-    /// subscribes for incoming payment events, and receives the first valid
-    /// payment into the wallet.
-    ///
-    /// # Arguments
-    ///
-    /// * `info` - The Nostr wait info returned from `create_request` when using Nostr transport
-    ///
-    /// # Returns
-    ///
-    /// The amount received from the payment.
-    ///
-    /// # Example
-    ///
-    /// ```ignore
-    /// let result = wallet.create_request(params).await?;
-    /// if let Some(nostr_info) = result.nostr_wait_info {
-    ///     let amount = wallet.wait_for_nostr_payment(nostr_info).await?;
-    ///     println!("Received {} sats", amount);
-    /// }
-    /// ```
-    pub async fn wait_for_nostr_payment(
-        &self,
-        info: Arc<NostrWaitInfo>,
-    ) -> Result<Amount, FfiError> {
-        // We need to clone the inner NostrWaitInfo since we can't consume the Arc
-        let info_inner = cdk::wallet::payment_request::NostrWaitInfo {
-            keys: info.inner().keys.clone(),
-            relays: info.inner().relays.clone(),
-            pubkey: info.inner().pubkey,
-        };
-        let amount = self
-            .inner
-            .wait_for_nostr_payment(info_inner)
-            .await
-            .map_err(FfiError::internal)?;
-        Ok(amount.into())
-    }
-}
-
-/// Auth methods for MultiMintWallet
-#[uniffi::export(async_runtime = "tokio")]
-impl MultiMintWallet {
-    /// Set Clear Auth Token (CAT) for a specific mint
-    pub async fn set_cat(&self, mint_url: MintUrl, cat: String) -> Result<(), FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        self.inner.set_cat(&cdk_mint_url, cat).await?;
-        Ok(())
-    }
-
-    /// Set refresh token for a specific mint
-    pub async fn set_refresh_token(
-        &self,
-        mint_url: MintUrl,
-        refresh_token: String,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        self.inner
-            .set_refresh_token(&cdk_mint_url, refresh_token)
-            .await?;
-        Ok(())
-    }
-
-    /// Refresh access token for a specific mint using the stored refresh token
-    pub async fn refresh_access_token(&self, mint_url: MintUrl) -> Result<(), FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        self.inner.refresh_access_token(&cdk_mint_url).await?;
-        Ok(())
-    }
-
-    /// Mint blind auth tokens at a specific mint
-    pub async fn mint_blind_auth(
-        &self,
-        mint_url: MintUrl,
-        amount: Amount,
-    ) -> Result<Proofs, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let proofs = self
-            .inner
-            .mint_blind_auth(&cdk_mint_url, amount.into())
-            .await?;
-        Ok(proofs.into_iter().map(|p| p.into()).collect())
-    }
-
-    /// Get unspent auth proofs for a specific mint
-    pub async fn get_unspent_auth_proofs(
-        &self,
-        mint_url: MintUrl,
-    ) -> Result<Vec<AuthProof>, FfiError> {
-        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        let auth_proofs = self.inner.get_unspent_auth_proofs(&cdk_mint_url).await?;
-        Ok(auth_proofs.into_iter().map(Into::into).collect())
-    }
-}
-
-/// Transfer mode for mint-to-mint transfers
-#[derive(Debug, Clone, uniffi::Enum)]
-pub enum TransferMode {
-    /// Transfer exact amount to target (target receives specified amount)
-    ExactReceive { amount: Amount },
-    /// Transfer all available balance (source will be emptied)
-    FullBalance,
-}
-
-impl From<TransferMode> for CdkTransferMode {
-    fn from(mode: TransferMode) -> Self {
-        match mode {
-            TransferMode::ExactReceive { amount } => CdkTransferMode::ExactReceive(amount.into()),
-            TransferMode::FullBalance => CdkTransferMode::FullBalance,
-        }
-    }
-}
-
-/// Result of a transfer operation with detailed breakdown
-#[derive(Debug, Clone, uniffi::Record)]
-pub struct TransferResult {
-    /// Amount deducted from source mint
-    pub amount_sent: Amount,
-    /// Amount received at target mint
-    pub amount_received: Amount,
-    /// Total fees paid for the transfer
-    pub fees_paid: Amount,
-    /// Remaining balance in source mint after transfer
-    pub source_balance_after: Amount,
-    /// New balance in target mint after transfer
-    pub target_balance_after: Amount,
-}
-
-impl From<CdkTransferResult> for TransferResult {
-    fn from(result: CdkTransferResult) -> Self {
-        Self {
-            amount_sent: result.amount_sent.into(),
-            amount_received: result.amount_received.into(),
-            fees_paid: result.fees_paid.into(),
-            source_balance_after: result.source_balance_after.into(),
-            target_balance_after: result.target_balance_after.into(),
-        }
-    }
-}
-
-/// Represents a pending send operation
-#[derive(Debug, Clone, uniffi::Record)]
-pub struct PendingSend {
-    /// The mint URL where the send is pending
-    pub mint_url: MintUrl,
-    /// The operation ID of the pending send
-    pub operation_id: String,
-}
-
-/// Data extracted from a token including mint URL, proofs, and memo
-#[derive(Debug, Clone, uniffi::Record)]
-pub struct TokenData {
-    /// The mint URL from the token
-    pub mint_url: MintUrl,
-    /// The proofs contained in the token
-    pub proofs: Proofs,
-    /// The memo from the token, if present
-    pub memo: Option<String>,
-    /// Value of token
-    pub value: Amount,
-    /// Unit of token
-    pub unit: CurrencyUnit,
-    /// Fee to redeem
-    ///
-    /// If the token is for a proof that we do not know, we cannot get the fee.
-    /// To avoid just erroring and still allow decoding, this is an option.
-    /// None does not mean there is no fee, it means we do not know the fee.
-    pub redeem_fee: Option<Amount>,
-}
-
-impl From<CdkTokenData> for TokenData {
-    fn from(data: CdkTokenData) -> Self {
-        Self {
-            mint_url: data.mint_url.into(),
-            proofs: data.proofs.into_iter().map(|p| p.into()).collect(),
-            memo: data.memo,
-            value: data.value.into(),
-            unit: data.unit.into(),
-            redeem_fee: data.redeem_fee.map(|a| a.into()),
-        }
-    }
-}
-
-/// Options for receiving tokens in multi-mint context
-#[derive(Debug, Clone, Default, uniffi::Record)]
-pub struct MultiMintReceiveOptions {
-    /// Whether to allow receiving from untrusted (not yet added) mints
-    pub allow_untrusted: bool,
-    /// Mint URL to transfer tokens to from untrusted mints (None means keep in original mint)
-    pub transfer_to_mint: Option<MintUrl>,
-    /// Base receive options to apply to the wallet receive
-    pub receive_options: ReceiveOptions,
-}
-
-impl From<MultiMintReceiveOptions> for CdkMultiMintReceiveOptions {
-    fn from(options: MultiMintReceiveOptions) -> Self {
-        let mut opts = CdkMultiMintReceiveOptions::new();
-        opts.allow_untrusted = options.allow_untrusted;
-        opts.transfer_to_mint = options.transfer_to_mint.and_then(|url| url.try_into().ok());
-        opts.receive_options = options.receive_options.into();
-        opts
-    }
-}
-
-/// Options for sending tokens in multi-mint context
-#[derive(Debug, Clone, Default, uniffi::Record)]
-pub struct MultiMintSendOptions {
-    /// Whether to allow transferring funds from other mints if needed
-    pub allow_transfer: bool,
-    /// Maximum amount to transfer from other mints (optional limit)
-    pub max_transfer_amount: Option<Amount>,
-    /// Specific mint URLs allowed for transfers (empty means all mints allowed)
-    pub allowed_mints: Vec<MintUrl>,
-    /// Specific mint URLs to exclude from transfers
-    pub excluded_mints: Vec<MintUrl>,
-    /// Base send options to apply to the wallet send
-    pub send_options: SendOptions,
-}
-
-impl From<MultiMintSendOptions> for CdkMultiMintSendOptions {
-    fn from(options: MultiMintSendOptions) -> Self {
-        let mut opts = CdkMultiMintSendOptions::new();
-        opts.allow_transfer = options.allow_transfer;
-        opts.max_transfer_amount = options.max_transfer_amount.map(Into::into);
-        opts.allowed_mints = options
-            .allowed_mints
-            .into_iter()
-            .filter_map(|url| url.try_into().ok())
-            .collect();
-        opts.excluded_mints = options
-            .excluded_mints
-            .into_iter()
-            .filter_map(|url| url.try_into().ok())
-            .collect();
-        opts.send_options = options.send_options.into();
-        opts
-    }
-}
-
-/// Nostr backup methods for MultiMintWallet (NUT-XX)
-#[uniffi::export(async_runtime = "tokio")]
-impl MultiMintWallet {
-    /// Get the hex-encoded public key used for Nostr mint backup
-    ///
-    /// This key is deterministically derived from the wallet seed and can be used
-    /// to identify and decrypt backup events on Nostr relays.
-    pub fn backup_public_key(&self) -> Result<String, FfiError> {
-        let keys = self.inner.backup_keys()?;
-        Ok(keys.public_key().to_hex())
-    }
-
-    /// Backup the current mint list to Nostr relays
-    ///
-    /// Creates an encrypted NIP-78 addressable event containing all mint URLs
-    /// and publishes it to the specified relays.
-    ///
-    /// # Arguments
-    ///
-    /// * `relays` - List of Nostr relay URLs (e.g., "wss://relay.damus.io")
-    /// * `options` - Backup options including optional client name
-    ///
-    /// # Example
-    ///
-    /// ```ignore
-    /// let relays = vec!["wss://relay.damus.io".to_string(), "wss://nos.lol".to_string()];
-    /// let options = BackupOptions { client: Some("my-wallet".to_string()) };
-    /// let result = wallet.backup_mints(relays, options).await?;
-    /// println!("Backup published with event ID: {}", result.event_id);
-    /// ```
-    pub async fn backup_mints(
-        &self,
-        relays: Vec<String>,
-        options: BackupOptions,
-    ) -> Result<BackupResult, FfiError> {
-        let result = self.inner.backup_mints(relays, options.into()).await?;
-        Ok(result.into())
-    }
-
-    /// Restore mint list from Nostr relays
-    ///
-    /// Fetches the most recent backup event from the specified relays,
-    /// decrypts it, and optionally adds the discovered mints to the wallet.
-    ///
-    /// # Arguments
-    ///
-    /// * `relays` - List of Nostr relay URLs to fetch from
-    /// * `add_mints` - If true, automatically add discovered mints to the wallet
-    /// * `options` - Restore options including timeout
-    ///
-    /// # Example
-    ///
-    /// ```ignore
-    /// let relays = vec!["wss://relay.damus.io".to_string()];
-    /// let result = wallet.restore_mints(relays, true, RestoreOptions::default()).await?;
-    /// println!("Restored {} mints, {} newly added", result.mint_count, result.mints_added);
-    /// ```
-    pub async fn restore_mints(
-        &self,
-        relays: Vec<String>,
-        add_mints: bool,
-        options: RestoreOptions,
-    ) -> Result<RestoreResult, FfiError> {
-        let result = self
-            .inner
-            .restore_mints(relays, add_mints, options.into())
-            .await?;
-        Ok(result.into())
-    }
-
-    /// Fetch the backup without adding mints to the wallet
-    ///
-    /// This is useful for previewing what mints are in the backup before
-    /// deciding to add them.
-    ///
-    /// # Arguments
-    ///
-    /// * `relays` - List of Nostr relay URLs to fetch from
-    /// * `options` - Restore options including timeout
-    pub async fn fetch_backup(
-        &self,
-        relays: Vec<String>,
-        options: RestoreOptions,
-    ) -> Result<MintBackup, FfiError> {
-        let backup = self.inner.fetch_backup(relays, options.into()).await?;
-        Ok(backup.into())
-    }
-}
-
-/// Type alias for balances by mint URL
-pub type BalanceMap = HashMap<String, Amount>;
-
-/// Type alias for proofs by mint URL
-pub type ProofsByMint = HashMap<String, Vec<Proof>>;
-
-/// Type alias for mint info by mint URL
-pub type MintInfoMap = HashMap<String, MintInfo>;

+ 2 - 2
crates/cdk-ffi/src/npubcash.rs

@@ -25,7 +25,7 @@ impl NpubCashClient {
     ///
     /// # Arguments
     ///
-    /// * `base_url` - Base URL of the NpubCash service (e.g., "https://npub.cash")
+    /// * `base_url` - Base URL of the NpubCash service (e.g., <https://npub.cash>)
     /// * `nostr_secret_key` - Nostr secret key for authentication. Accepts either:
     ///   - Hex-encoded secret key (64 characters)
     ///   - Bech32 `nsec` format (e.g., "nsec1...")
@@ -74,7 +74,7 @@ impl NpubCashClient {
     ///
     /// # Arguments
     ///
-    /// * `mint_url` - URL of the Cashu mint to use (e.g., "https://mint.example.com")
+    /// * `mint_url` - URL of the Cashu mint to use (e.g., <https://mint.example.com>)
     ///
     /// # Errors
     ///

+ 1 - 1
crates/cdk-ffi/src/types/amount.rs

@@ -91,7 +91,7 @@ impl From<Amount> for CdkAmount {
 }
 
 /// FFI-compatible Currency Unit
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
 pub enum CurrencyUnit {
     Sat,
     Msat,

+ 1 - 14
crates/cdk-ffi/src/types/payment_request.rs

@@ -12,8 +12,6 @@ use crate::error::FfiError;
 /// Transport type for payment request delivery
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
 pub enum TransportType {
-    /// In-band transport (tokens returned directly in response)
-    InBand,
     /// Nostr transport (privacy-preserving)
     Nostr,
     /// HTTP POST transport
@@ -23,7 +21,6 @@ pub enum TransportType {
 impl From<cdk::nuts::TransportType> for TransportType {
     fn from(t: cdk::nuts::TransportType) -> Self {
         match t {
-            cdk::nuts::TransportType::InBand => TransportType::InBand,
             cdk::nuts::TransportType::Nostr => TransportType::Nostr,
             cdk::nuts::TransportType::HttpPost => TransportType::HttpPost,
         }
@@ -33,7 +30,6 @@ impl From<cdk::nuts::TransportType> for TransportType {
 impl From<TransportType> for cdk::nuts::TransportType {
     fn from(t: TransportType) -> Self {
         match t {
-            TransportType::InBand => cdk::nuts::TransportType::InBand,
             TransportType::Nostr => cdk::nuts::TransportType::Nostr,
             TransportType::HttpPost => cdk::nuts::TransportType::HttpPost,
         }
@@ -81,11 +77,6 @@ pub struct PaymentRequest {
 }
 
 impl PaymentRequest {
-    /// Create from inner CDK type
-    pub(crate) fn from_inner(inner: cdk::nuts::PaymentRequest) -> Self {
-        Self { inner }
-    }
-
     /// Get inner reference
     pub(crate) fn inner(&self) -> &cdk::nuts::PaymentRequest {
         &self.inner
@@ -256,12 +247,8 @@ pub struct NostrWaitInfo {
 }
 
 impl NostrWaitInfo {
-    /// Create from inner CDK type
-    pub(crate) fn from_inner(inner: cdk::wallet::payment_request::NostrWaitInfo) -> Self {
-        Self { inner }
-    }
-
     /// Get inner reference
+    #[allow(dead_code)]
     pub(crate) fn inner(&self) -> &cdk::wallet::payment_request::NostrWaitInfo {
         &self.inner
     }

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

@@ -8,6 +8,7 @@ use super::amount::{Amount, SplitTarget};
 use super::proof::{Proofs, SpendingConditions};
 use crate::error::FfiError;
 use crate::token::Token;
+use crate::{CurrencyUnit, MintUrl};
 
 /// FFI-compatible SendMemo
 #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
@@ -683,3 +684,32 @@ impl From<cdk::wallet::MeltConfirmOptions> for MeltConfirmOptions {
         }
     }
 }
+
+/// FFI-compatible WalletKey
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
+pub struct WalletKey {
+    /// Mint Url
+    pub mint_url: MintUrl,
+    /// Currency Unit
+    pub unit: CurrencyUnit,
+}
+
+impl TryFrom<WalletKey> for cdk::WalletKey {
+    type Error = FfiError;
+
+    fn try_from(value: WalletKey) -> Result<Self, Self::Error> {
+        Ok(Self {
+            mint_url: value.mint_url.try_into()?,
+            unit: value.unit.into(),
+        })
+    }
+}
+
+impl From<cdk::WalletKey> for WalletKey {
+    fn from(value: cdk::WalletKey) -> Self {
+        Self {
+            mint_url: value.mint_url.into(),
+            unit: value.unit.into(),
+        }
+    }
+}

+ 29 - 5
crates/cdk-ffi/src/wallet.rs

@@ -12,6 +12,7 @@ use crate::types::payment_request::PaymentRequest;
 use crate::types::*;
 
 /// FFI-compatible Wallet
+
 #[derive(uniffi::Object)]
 pub struct Wallet {
     inner: Arc<CdkWallet>,
@@ -222,14 +223,37 @@ impl Wallet {
         Ok(quote.into())
     }
 
-    /// Refresh a specific mint quote status from the mint.
+    /// Check a mint quote status from the mint.
+    ///
+    /// Calls `GET /v1/mint/quote/{method}/{quote_id}` per NUT-04.
     /// Updates local store with current state from mint.
-    /// Does NOT mint tokens - use mint() to mint a specific quote.
-    pub async fn refresh_mint_quote(
+    /// If there was a crashed mid-mint (pending saga), attempts to complete it.
+    /// Does NOT mint tokens directly - use mint() for that.
+    ///
+    /// **Note:** The mint quote must be known to the wallet (stored locally) for this
+    /// function to work. If the quote is not stored locally, use `fetch_mint_quote`
+    /// instead.
+    pub async fn check_mint_quote(&self, quote_id: String) -> Result<MintQuote, FfiError> {
+        let quote = self.inner.check_mint_quote_status(&quote_id).await?;
+        Ok(quote.into())
+    }
+
+    /// Fetch a mint quote from the mint and store it locally
+    ///
+    /// Works with all payment methods (Bolt11, Bolt12, and custom payment methods).
+    ///
+    /// # Arguments
+    /// * `quote_id` - The ID of the quote to fetch
+    /// * `payment_method` - The payment method for the quote. Required if the quote
+    ///   is not already stored locally. If the quote exists locally, the stored
+    ///   payment method will be used and this parameter is ignored.
+    pub async fn fetch_mint_quote(
         &self,
         quote_id: String,
-    ) -> Result<MintQuoteBolt11Response, FfiError> {
-        let quote = self.inner.refresh_mint_quote_status(&quote_id).await?;
+        payment_method: Option<PaymentMethod>,
+    ) -> Result<MintQuote, FfiError> {
+        let method = payment_method.map(Into::into);
+        let quote = self.inner.fetch_mint_quote(&quote_id, method).await?;
         Ok(quote.into())
     }
 

+ 277 - 0
crates/cdk-ffi/src/wallet_repository.rs

@@ -0,0 +1,277 @@
+//! FFI WalletRepository bindings
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use bip39::Mnemonic;
+use cdk::wallet::wallet_repository::{
+    WalletRepository as CdkWalletRepository, WalletRepositoryBuilder,
+};
+
+use crate::error::FfiError;
+use crate::types::*;
+
+/// FFI-compatible WalletRepository
+#[derive(uniffi::Object)]
+pub struct WalletRepository {
+    inner: Arc<CdkWalletRepository>,
+}
+
+#[uniffi::export(async_runtime = "tokio")]
+impl WalletRepository {
+    /// Create a new WalletRepository from mnemonic using WalletDatabaseFfi trait
+    #[uniffi::constructor]
+    pub fn new(
+        mnemonic: String,
+        db: Arc<dyn crate::database::WalletDatabase>,
+    ) -> Result<Self, FfiError> {
+        // Parse mnemonic and generate seed without passphrase
+        let m = Mnemonic::parse(&mnemonic)
+            .map_err(|e| FfiError::internal(format!("Invalid mnemonic: {}", e)))?;
+        let seed = m.to_seed_normalized("");
+
+        // Convert the FFI database trait to a CDK database implementation
+        let localstore = crate::database::create_cdk_database_from_ffi(db);
+
+        let wallet = match tokio::runtime::Handle::try_current() {
+            Ok(handle) => tokio::task::block_in_place(|| {
+                handle.block_on(async move {
+                    WalletRepositoryBuilder::new()
+                        .localstore(localstore)
+                        .seed(seed)
+                        .build()
+                        .await
+                })
+            }),
+            Err(_) => {
+                // No current runtime, create a new one
+                tokio::runtime::Runtime::new()
+                    .map_err(|e| FfiError::internal(format!("Failed to create runtime: {}", e)))?
+                    .block_on(async move {
+                        WalletRepositoryBuilder::new()
+                            .localstore(localstore)
+                            .seed(seed)
+                            .build()
+                            .await
+                    })
+            }
+        }?;
+
+        Ok(Self {
+            inner: Arc::new(wallet),
+        })
+    }
+
+    /// Create a new WalletRepository with proxy configuration
+    #[uniffi::constructor]
+    pub fn new_with_proxy(
+        mnemonic: String,
+        db: Arc<dyn crate::database::WalletDatabase>,
+        proxy_url: String,
+    ) -> Result<Self, FfiError> {
+        // Parse mnemonic and generate seed without passphrase
+        let m = Mnemonic::parse(&mnemonic)
+            .map_err(|e| FfiError::internal(format!("Invalid mnemonic: {}", e)))?;
+        let seed = m.to_seed_normalized("");
+
+        // Convert the FFI database trait to a CDK database implementation
+        let localstore = crate::database::create_cdk_database_from_ffi(db);
+
+        // Parse proxy URL
+        let proxy_url = url::Url::parse(&proxy_url)
+            .map_err(|e| FfiError::internal(format!("Invalid URL: {}", e)))?;
+
+        let wallet = match tokio::runtime::Handle::try_current() {
+            Ok(handle) => tokio::task::block_in_place(|| {
+                handle.block_on(async move {
+                    WalletRepositoryBuilder::new()
+                        .localstore(localstore)
+                        .seed(seed)
+                        .proxy_url(proxy_url)
+                        .build()
+                        .await
+                })
+            }),
+            Err(_) => {
+                // No current runtime, create a new one
+                tokio::runtime::Runtime::new()
+                    .map_err(|e| FfiError::internal(format!("Failed to create runtime: {}", e)))?
+                    .block_on(async move {
+                        WalletRepositoryBuilder::new()
+                            .localstore(localstore)
+                            .seed(seed)
+                            .proxy_url(proxy_url)
+                            .build()
+                            .await
+                    })
+            }
+        }?;
+
+        Ok(Self {
+            inner: Arc::new(wallet),
+        })
+    }
+
+    /// Set metadata cache TTL (time-to-live) in seconds for a specific mint
+    ///
+    /// Controls how long cached mint metadata (keysets, keys, mint info) is considered fresh
+    /// before requiring a refresh from the mint server for a specific mint.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint URL to set the TTL for
+    /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires.
+    pub async fn set_metadata_cache_ttl_for_mint(
+        &self,
+        mint_url: MintUrl,
+        ttl_secs: Option<u64>,
+    ) -> Result<(), FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let wallets = self.inner.get_wallets().await;
+
+        if let Some(wallet) = wallets.iter().find(|w| w.mint_url == cdk_mint_url) {
+            let ttl = ttl_secs.map(std::time::Duration::from_secs);
+            wallet.set_metadata_cache_ttl(ttl);
+            Ok(())
+        } else {
+            Err(FfiError::internal(format!(
+                "Mint not found: {}",
+                cdk_mint_url
+            )))
+        }
+    }
+
+    /// Set metadata cache TTL (time-to-live) in seconds for all mints
+    ///
+    /// Controls how long cached mint metadata is considered fresh for all mints
+    /// in this WalletRepository.
+    ///
+    /// # Arguments
+    ///
+    /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires for any mint.
+    pub async fn set_metadata_cache_ttl_for_all_mints(&self, ttl_secs: Option<u64>) {
+        let wallets = self.inner.get_wallets().await;
+        let ttl = ttl_secs.map(std::time::Duration::from_secs);
+
+        for wallet in wallets.iter() {
+            wallet.set_metadata_cache_ttl(ttl);
+        }
+    }
+
+    /// Add a mint to this WalletRepository
+    pub async fn create_wallet(
+        &self,
+        mint_url: MintUrl,
+        unit: Option<CurrencyUnit>,
+        target_proof_count: Option<u32>,
+    ) -> Result<(), FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+
+        let config = target_proof_count.map(|count| {
+            cdk::wallet::wallet_repository::WalletConfig::new()
+                .with_target_proof_count(count as usize)
+        });
+
+        let unit_enum = unit.unwrap_or(CurrencyUnit::Sat);
+
+        self.inner
+            .create_wallet(cdk_mint_url, unit_enum.into(), config)
+            .await?;
+
+        Ok(())
+    }
+
+    /// Remove mint from WalletRepository
+    pub async fn remove_wallet(
+        &self,
+        mint_url: MintUrl,
+        currency_unit: CurrencyUnit,
+    ) -> Result<(), FfiError> {
+        // 1. Convert MintUrl safely without unwrap()
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url
+            .try_into()
+            .map_err(|_| FfiError::internal("invalid mint url"))?; // Map the error to your FfiError type
+
+        // 2. Await the inner call and propagate its result with '?'
+        self.inner
+            .remove_wallet(cdk_mint_url, currency_unit.into())
+            .await
+            .map_err(|e| e.into()) // Ensure the inner error can convert to FfiError
+    }
+
+    /// Check if mint is in wallet
+    pub async fn has_mint(&self, mint_url: MintUrl) -> bool {
+        if let Ok(cdk_mint_url) = mint_url.try_into() {
+            self.inner.has_mint(&cdk_mint_url).await
+        } else {
+            false
+        }
+    }
+
+    /// Get wallet balances for all mints
+    pub async fn get_balances(&self) -> Result<HashMap<WalletKey, Amount>, FfiError> {
+        let balances = self.inner.get_balances().await?;
+        let mut balance_map = HashMap::new();
+        for (wallet_key, amount) in balances {
+            balance_map.insert(wallet_key.into(), amount.into());
+        }
+        Ok(balance_map)
+    }
+
+    /// Get all wallets from WalletRepository
+    pub async fn get_wallets(&self) -> Vec<Arc<crate::wallet::Wallet>> {
+        let wallets = self.inner.get_wallets().await;
+        wallets
+            .into_iter()
+            .map(|w| Arc::new(crate::wallet::Wallet::from_inner(Arc::new(w))))
+            .collect()
+    }
+
+    /// Get a specific wallet from WalletRepository by mint URL
+    ///
+    /// Returns an error if no wallet exists for the given mint URL.
+    pub async fn get_wallet(
+        &self,
+        mint_url: MintUrl,
+        unit: CurrencyUnit,
+    ) -> Result<Arc<crate::wallet::Wallet>, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let unit_cdk: cdk::nuts::CurrencyUnit = unit.into();
+        let wallet = self.inner.get_wallet(&cdk_mint_url, &unit_cdk).await?;
+        Ok(Arc::new(crate::wallet::Wallet::from_inner(Arc::new(
+            wallet,
+        ))))
+    }
+}
+
+/// Token data FFI type
+///
+/// Contains information extracted from a parsed token.
+#[derive(Debug, Clone, uniffi::Record)]
+pub struct TokenData {
+    /// The mint URL from the token
+    pub mint_url: MintUrl,
+    /// The proofs contained in the token
+    pub proofs: Vec<crate::types::Proof>,
+    /// The memo from the token, if present
+    pub memo: Option<String>,
+    /// Value of token in smallest unit
+    pub value: Amount,
+    /// Currency unit
+    pub unit: CurrencyUnit,
+    /// Fee to redeem (None if unknown)
+    pub redeem_fee: Option<Amount>,
+}
+
+impl From<cdk::wallet::TokenData> for TokenData {
+    fn from(data: cdk::wallet::TokenData) -> Self {
+        Self {
+            mint_url: data.mint_url.into(),
+            proofs: data.proofs.into_iter().map(Into::into).collect(),
+            memo: data.memo,
+            value: data.value.into(),
+            unit: data.unit.into(),
+            redeem_fee: data.redeem_fee.map(Into::into),
+        }
+    }
+}

+ 4 - 3
crates/cdk-integration-tests/src/bin/start_regtest.rs

@@ -9,7 +9,7 @@ use anyhow::Result;
 use cashu::Amount;
 use cdk_integration_tests::cli::{init_logging, CommonArgs};
 use cdk_integration_tests::init_regtest::start_regtest_end;
-use cdk_ldk_node::CdkLdkNode;
+use cdk_ldk_node::CdkLdkNodeBuilder;
 use clap::Parser;
 use ldk_node::lightning::ln::msgs::SocketAddress;
 use tokio::signal;
@@ -51,7 +51,7 @@ async fn main() -> Result<()> {
     let shutdown_clone_two = Arc::clone(&shutdown_regtest);
 
     let ldk_work_dir = temp_dir.join("ldk_mint");
-    let cdk_ldk = CdkLdkNode::new(
+    let node_builder = CdkLdkNodeBuilder::new(
         bitcoin::Network::Regtest,
         cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig {
             host: "127.0.0.1".to_string(),
@@ -69,7 +69,8 @@ async fn main() -> Result<()> {
             addr: [127, 0, 0, 1],
             port: 8092,
         }],
-    )?;
+    );
+    let cdk_ldk = node_builder.build()?;
 
     let inner_node = cdk_ldk.node();
 

+ 68 - 7
crates/cdk-integration-tests/src/bin/start_regtest_mints.rs

@@ -22,7 +22,7 @@ use cashu::Amount;
 use cdk_integration_tests::cli::CommonArgs;
 use cdk_integration_tests::init_regtest::start_regtest_end;
 use cdk_integration_tests::shared;
-use cdk_ldk_node::CdkLdkNode;
+use cdk_ldk_node::{CdkLdkNode, CdkLdkNodeBuilder};
 use cdk_mintd::config::LoggingConfig;
 use clap::Parser;
 use ldk_node::lightning::ln::msgs::SocketAddress;
@@ -192,11 +192,14 @@ async fn start_lnd_mint(
 }
 
 /// Start regtest LDK mint using the library
+/// If `existing_node` is provided, it will be used instead of creating a new one.
+/// This allows the mint to use a node that was already set up (e.g., with channels).
 async fn start_ldk_mint(
     temp_dir: &Path,
     port: u16,
     shutdown: Arc<Notify>,
     runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
+    existing_node: Option<CdkLdkNode>,
 ) -> Result<tokio::task::JoinHandle<()>> {
     let ldk_work_dir = temp_dir.join("ldk_mint");
 
@@ -215,6 +218,8 @@ async fn start_ldk_mint(
         bitcoind_rpc_user: Some("testuser".to_string()),
         bitcoind_rpc_password: Some("testpass".to_string()),
         esplora_url: None,
+        log_dir_path: None,
+        ldk_node_announce_addresses: None,
         storage_dir_path: Some(ldk_work_dir.to_string_lossy().to_string()),
         ldk_node_host: Some("127.0.0.1".to_string()),
         ldk_node_port: Some(port + 10), // Use a different port for the LDK node P2P connections
@@ -222,10 +227,14 @@ async fn start_ldk_mint(
         rgs_url: None,
         webserver_host: Some("127.0.0.1".to_string()),
         webserver_port: Some(port + 1), // Use next port for web interface
+        ldk_node_mnemonic: Some(
+            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
+                .to_string(),
+        ),
     };
 
     // Create settings struct for LDK mint using a new shared function
-    let settings = create_ldk_settings(port, ldk_config, Mnemonic::generate(12)?.to_string());
+    let settings = create_ldk_settings(port, ldk_config);
 
     println!("Starting LDK mintd on port {port}");
 
@@ -240,6 +249,13 @@ async fn start_ldk_mint(
             println!("LDK mint shutdown signal received");
         };
 
+        // Both nodes now use the same seed, so the standard flow should work.
+        // The existing_node parameter is kept for API compatibility but not used
+        // since run_mintd_with_shutdown will create its own node with the same seed.
+        if existing_node.is_some() {
+            println!("Using existing LDK node configuration (same seed as mint)");
+        }
+
         match cdk_mintd::run_mintd_with_shutdown(
             &ldk_work_dir,
             &settings,
@@ -262,7 +278,6 @@ async fn start_ldk_mint(
 fn create_ldk_settings(
     port: u16,
     ldk_config: cdk_mintd::config::LdkNode,
-    mnemonic: String,
 ) -> cdk_mintd::config::Settings {
     cdk_mintd::config::Settings {
         info: cdk_mintd::config::Info {
@@ -271,7 +286,10 @@ fn create_ldk_settings(
             listen_host: "127.0.0.1".to_string(),
             listen_port: port,
             seed: None,
-            mnemonic: Some(mnemonic),
+            mnemonic: Some(
+                "eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal"
+                    .to_string(),
+            ),
             signatory_url: None,
             signatory_certs: None,
             input_fee_ppk: None,
@@ -339,7 +357,11 @@ fn main() -> Result<()> {
         let shutdown_clone_one = Arc::clone(&shutdown_clone);
 
         let ldk_work_dir = temp_dir.join("ldk_mint");
-        let cdk_ldk = CdkLdkNode::new(
+        fs::create_dir_all(ldk_work_dir.join("logs"))?;
+        let test_mnemonic: Mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
+            .parse()
+            .expect("Failed to parse test mnemonic");
+        let node_builder = CdkLdkNodeBuilder::new(
             bitcoin::Network::Regtest,
             cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig {
                 host: "127.0.0.1".to_string(),
@@ -357,7 +379,45 @@ fn main() -> Result<()> {
                 addr: [127, 0, 0, 1],
                 port: 8092,
             }],
-        )?;
+        )
+        .with_seed(test_mnemonic.clone());
+        let cdk_ldk = match node_builder.build() {
+            Ok(node) => node,
+            Err(e) => {
+                tracing::warn!(
+                    "Failed to start LDK node: {}. Attempting to wipe data and restart...",
+                    e
+                );
+                // Clean up the storage directory
+                if ldk_work_dir.exists() {
+                    fs::remove_dir_all(&ldk_work_dir)?;
+                    fs::create_dir_all(ldk_work_dir.join("logs"))?;
+                }
+                // Recreate builder since it was consumed
+                let node_builder = CdkLdkNodeBuilder::new(
+                    bitcoin::Network::Regtest,
+                    cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig {
+                        host: "127.0.0.1".to_string(),
+                        port: 18443,
+                        user: "testuser".to_string(),
+                        password: "testpass".to_string(),
+                    }),
+                    cdk_ldk_node::GossipSource::P2P,
+                    ldk_work_dir.to_string_lossy().to_string(),
+                    cdk_common::common::FeeReserve {
+                        min_fee_reserve: Amount::ZERO,
+                        percent_fee_reserve: 0.0,
+                    },
+                    vec![SocketAddress::TcpIpV4 {
+                        addr: [127, 0, 0, 1],
+                        port: 8092,
+                    }],
+                )
+                .with_seed(test_mnemonic);
+
+                node_builder.build()?
+            }
+        };
 
         let inner_node = cdk_ldk.node();
 
@@ -385,12 +445,13 @@ fn main() -> Result<()> {
         // Start LND mint
         let lnd_handle = start_lnd_mint(&temp_dir, args.lnd_port, shutdown_clone.clone()).await?;
 
-        // Start LDK mint
+        // Start LDK mint (using the existing node that was already set up with channels)
         let ldk_handle = start_ldk_mint(
             &temp_dir,
             args.ldk_port,
             shutdown_clone.clone(),
             Some(rt_clone),
+            Some(cdk_ldk),
         )
         .await?;
 

+ 65 - 0
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -285,6 +285,71 @@ pub async fn create_and_start_test_mint() -> Result<Mint> {
     create_mint_with_limits(None).await
 }
 
+pub async fn create_mint_with_fee(fee_ppk: u64) -> Result<Mint> {
+    // Read environment variable to determine database type
+    let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set");
+
+    let localstore = match db_type.to_lowercase().as_str() {
+        "memory" => Arc::new(cdk_sqlite::mint::memory::empty().await?),
+        _ => {
+            // Create a temporary directory for SQLite database
+            let temp_dir = create_temp_dir("cdk-test-sqlite-mint")?;
+            let path = temp_dir.join("mint.db").to_str().unwrap().to_string();
+            Arc::new(
+                cdk_sqlite::MintSqliteDatabase::new(path.as_str())
+                    .await
+                    .expect("Could not create sqlite db"),
+            )
+        }
+    };
+
+    let mut mint_builder = MintBuilder::new(localstore.clone());
+
+    let fee_reserve = FeeReserve {
+        min_fee_reserve: 1.into(),
+        percent_fee_reserve: 0.02,
+    };
+
+    let ln_fake_backend = FakeWallet::new(
+        fee_reserve.clone(),
+        HashMap::default(),
+        HashSet::default(),
+        2,
+        CurrencyUnit::Sat,
+    );
+
+    mint_builder
+        .add_payment_processor(
+            CurrencyUnit::Sat,
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            MintMeltLimits::new(1, 10_000),
+            Arc::new(ln_fake_backend),
+        )
+        .await?;
+
+    mint_builder.set_unit_fee(&CurrencyUnit::Sat, fee_ppk)?;
+
+    let mnemonic = Mnemonic::generate(12)?;
+
+    mint_builder = mint_builder
+        .with_name("pure test mint".to_string())
+        .with_description("pure test mint".to_string())
+        .with_urls(vec!["https://aaa".to_string()])
+        .with_limits(2000, 2000);
+
+    let quote_ttl = QuoteTTL::new(10000, 10000);
+
+    let mint = mint_builder
+        .build_with_seed(localstore.clone(), &mnemonic.to_seed_normalized(""))
+        .await?;
+
+    mint.set_quote_ttl(quote_ttl).await?;
+
+    mint.start().await?;
+
+    Ok(mint)
+}
+
 pub async fn create_mint_with_limits(limits: Option<(usize, usize)>) -> Result<Mint> {
     // Read environment variable to determine database type
     let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set");

+ 751 - 10
crates/cdk-integration-tests/tests/async_melt.rs

@@ -8,13 +8,14 @@
 //! - Background task completion
 //! - Quote polling pattern
 
+use std::collections::HashSet;
 use std::sync::Arc;
 
 use bip39::Mnemonic;
 use cashu::PaymentMethod;
 use cdk::amount::SplitTarget;
-use cdk::nuts::{CurrencyUnit, MeltQuoteState};
-use cdk::wallet::Wallet;
+use cdk::nuts::{CurrencyUnit, MeltQuoteState, State};
+use cdk::wallet::{MeltOutcome, Wallet};
 use cdk::StreamExt;
 use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
 use cdk_sqlite::wallet::memory;
@@ -43,12 +44,18 @@ async fn test_async_melt_returns_pending() {
         .unwrap();
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
-    let _proofs = proof_streams
+    let proofs_before = proof_streams
         .next()
         .await
         .expect("payment")
         .expect("no error");
 
+    // Collect Y values of proofs before melt
+    let ys_before: HashSet<_> = proofs_before
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
     let balance = wallet.total_balance().await.unwrap();
     assert_eq!(balance, 100.into());
 
@@ -71,19 +78,21 @@ async fn test_async_melt_returns_pending() {
         .unwrap();
 
     // Step 3: Call melt (wallet handles proof selection internally)
-    let start_time = std::time::Instant::now();
-
     // This should complete and return the final state
     let prepared = wallet
         .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
         .await
         .unwrap();
-    let confirmed = prepared.confirm().await.unwrap();
 
-    let elapsed = start_time.elapsed();
+    // Collect Y values of proofs that will be used in the melt
+    let proofs_to_use: HashSet<_> = prepared
+        .proofs()
+        .iter()
+        .chain(prepared.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
 
-    // For now, this is synchronous, so it will take longer
-    println!("Melt took {:?}", elapsed);
+    let confirmed = prepared.confirm().await.unwrap();
 
     // Step 4: Verify the melt completed successfully
     assert_eq!(
@@ -91,6 +100,58 @@ async fn test_async_melt_returns_pending() {
         MeltQuoteState::Paid,
         "Melt should complete with PAID state"
     );
+
+    // Step 5: Verify balance reduced (100 - 50 - fees)
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert!(
+        final_balance < 100.into(),
+        "Balance should be reduced after melt. Initial: 100, Final: {}",
+        final_balance
+    );
+
+    // Step 6: Verify no proofs are pending
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_proofs.is_empty(),
+        "No proofs should be in pending state after melt completes"
+    );
+
+    // Step 7: Verify proofs used in melt are marked as Spent
+    let proofs_after = wallet.get_proofs_with(None, None).await.unwrap();
+    let ys_after: HashSet<_> = proofs_after
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before {
+        assert!(
+            ys_after.contains(y),
+            "Original proof with Y={} should still exist after melt",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_proofs = wallet
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_ys: HashSet<_> = spent_proofs
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use {
+        assert!(
+            spent_ys.contains(y),
+            "Proof with Y={} that was used in melt should be marked as Spent",
+            y
+        );
+    }
 }
 
 /// Test: Synchronous melt still works correctly
@@ -115,12 +176,18 @@ async fn test_sync_melt_completes_fully() {
         .unwrap();
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
-    let _proofs = proof_streams
+    let proofs_before = proof_streams
         .next()
         .await
         .expect("payment")
         .expect("no error");
 
+    // Collect Y values of proofs before melt
+    let ys_before: HashSet<_> = proofs_before
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
     let balance = wallet.total_balance().await.unwrap();
     assert_eq!(balance, 100.into());
 
@@ -147,6 +214,15 @@ async fn test_sync_melt_completes_fully() {
         .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
         .await
         .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt
+    let proofs_to_use: HashSet<_> = prepared
+        .proofs()
+        .iter()
+        .chain(prepared.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
     let confirmed = prepared.confirm().await.unwrap();
 
     // Step 5: Verify response shows payment completed
@@ -166,4 +242,669 @@ async fn test_sync_melt_completes_fully() {
         MeltQuoteState::Paid,
         "Quote should be PAID"
     );
+
+    // Step 7: Verify balance reduced after melt
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert!(
+        final_balance < 100.into(),
+        "Balance should be reduced after melt. Initial: 100, Final: {}",
+        final_balance
+    );
+
+    // Step 8: Verify no proofs are pending
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_proofs.is_empty(),
+        "No proofs should be in pending state after melt completes"
+    );
+
+    // Step 9: Verify proofs used in melt are marked as Spent
+    let proofs_after = wallet.get_proofs_with(None, None).await.unwrap();
+    let ys_after: HashSet<_> = proofs_after
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before {
+        assert!(
+            ys_after.contains(y),
+            "Original proof with Y={} should still exist after melt",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_proofs = wallet
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_ys: HashSet<_> = spent_proofs
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use {
+        assert!(
+            spent_ys.contains(y),
+            "Proof with Y={} that was used in melt should be marked as Spent",
+            y
+        );
+    }
+}
+
+/// Test: confirm_prefer_async returns Pending when mint supports async
+///
+/// This test validates that confirm_prefer_async() returns MeltOutcome::Pending
+/// when the mint accepts the async request and returns PENDING state.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_confirm_prefer_async_returns_pending_immediately() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Step 1: Mint some tokens
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let _proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let balance = wallet.total_balance().await.unwrap();
+    assert_eq!(balance, 100.into());
+
+    // Step 2: Create a melt quote with Pending state
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Pending,
+        check_payment_state: MeltQuoteState::Pending,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(
+        50_000, // 50 sats in millisats
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
+
+    // Step 3: Call confirm_prefer_async
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    let result = prepared.confirm_prefer_async().await.unwrap();
+
+    // Step 4: Verify we got Pending result
+    assert!(
+        matches!(result, MeltOutcome::Pending(_)),
+        "confirm_prefer_async should return MeltOutcome::Pending when mint supports async"
+    );
+
+    // Step 5: Verify proofs are in pending state
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        !pending_proofs.is_empty(),
+        "Proofs should be in pending state"
+    );
+
+    // Note: Fake wallet may complete immediately even with Pending state configured.
+    // The key assertion is that confirm_prefer_async returns MeltOutcome::Pending,
+    // which proves the API is working correctly.
+}
+
+/// Test: Pending melt from confirm_prefer_async can be awaited
+///
+/// This test validates that when confirm_prefer_async() returns MeltOutcome::Pending,
+/// the pending melt can be awaited to completion.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_confirm_prefer_async_pending_can_be_awaited() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Step 1: Mint some tokens
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let proofs_before = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Collect Y values of proofs before melt
+    let ys_before: HashSet<_> = proofs_before
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(
+        50_000,
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
+
+    // Step 3: Call confirm_prefer_async
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt
+    let proofs_to_use: HashSet<_> = prepared
+        .proofs()
+        .iter()
+        .chain(prepared.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let result = prepared.confirm_prefer_async().await.unwrap();
+
+    // Step 4: If we got Pending, await it
+    let finalized = match result {
+        MeltOutcome::Paid(_melt) => panic!("We expect it to be pending"),
+        MeltOutcome::Pending(pending) => {
+            // This is the key test - awaiting the pending melt
+            let melt = pending.await.unwrap();
+            melt
+        }
+    };
+
+    // Step 5: Verify final state
+    assert_eq!(
+        finalized.state(),
+        MeltQuoteState::Paid,
+        "Awaited melt should complete to PAID state"
+    );
+
+    // Step 6: Verify balance reduced after awaiting
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert!(
+        final_balance < 100.into(),
+        "Balance should be reduced after melt completes. Initial: 100, Final: {}",
+        final_balance
+    );
+
+    // Step 7: Verify no proofs are pending
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_proofs.is_empty(),
+        "No proofs should be in pending state after melt completes"
+    );
+
+    // Step 8: Verify proofs used in melt are marked as Spent after awaiting
+    let proofs_after = wallet.get_proofs_with(None, None).await.unwrap();
+    let ys_after: HashSet<_> = proofs_after
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before {
+        assert!(
+            ys_after.contains(y),
+            "Original proof with Y={} should still exist after awaiting",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_proofs = wallet
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_ys: HashSet<_> = spent_proofs
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use {
+        assert!(
+            spent_ys.contains(y),
+            "Proof with Y={} that was used in melt should be marked as Spent after awaiting",
+            y
+        );
+    }
+}
+
+/// Test: Pending melt can be dropped and polled elsewhere
+///
+/// This test validates that when confirm_prefer_async() returns MeltOutcome::Pending,
+/// the caller can drop the pending handle and poll the status via check_melt_quote_status().
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_confirm_prefer_async_pending_can_be_dropped_and_polled() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Step 1: Mint some tokens
+    let mint_quote = wallet
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let proofs_before = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Collect Y values of proofs before melt
+    let ys_before: HashSet<_> = proofs_before
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // Step 2: Create a melt quote
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(
+        50_000,
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote = wallet
+        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .await
+        .unwrap();
+
+    let quote_id = melt_quote.id.clone();
+
+    // Step 3: Call confirm_prefer_async
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt
+    let proofs_to_use: HashSet<_> = prepared
+        .proofs()
+        .iter()
+        .chain(prepared.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let result = prepared.confirm_prefer_async().await.unwrap();
+
+    // Step 4: Drop the pending handle (simulating caller not awaiting)
+    match result {
+        MeltOutcome::Paid(_) => {
+            panic!("We expect it to be pending");
+        }
+        MeltOutcome::Pending(_) => {
+            // Drop the pending handle - don't await
+        }
+    }
+
+    // Step 5: Poll the quote status
+    let mut attempts = 0;
+    let max_attempts = 10;
+    let mut final_state = MeltQuoteState::Unknown;
+
+    while attempts < max_attempts {
+        let quote = wallet.check_melt_quote_status(&quote_id).await.unwrap();
+        final_state = quote.state;
+
+        if matches!(final_state, MeltQuoteState::Paid | MeltQuoteState::Failed) {
+            break;
+        }
+
+        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+        attempts += 1;
+    }
+
+    // Step 6: Verify final state
+    assert_eq!(
+        final_state,
+        MeltQuoteState::Paid,
+        "Quote should reach PAID state after polling"
+    );
+
+    // Step 7: Verify balance reduced after polling shows Paid
+    let final_balance = wallet.total_balance().await.unwrap();
+    assert!(
+        final_balance < 100.into(),
+        "Balance should be reduced after melt completes via polling. Initial: 100, Final: {}",
+        final_balance
+    );
+
+    // Step 8: Verify no proofs are pending
+    let pending_proofs = wallet
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_proofs.is_empty(),
+        "No proofs should be in pending state after polling shows Paid"
+    );
+
+    // Step 9: Verify proofs used in melt are marked as Spent after polling
+    let proofs_after = wallet.get_proofs_with(None, None).await.unwrap();
+    let ys_after: HashSet<_> = proofs_after
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before {
+        assert!(
+            ys_after.contains(y),
+            "Original proof with Y={} should still exist after polling",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_proofs = wallet
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_ys: HashSet<_> = spent_proofs
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use {
+        assert!(
+            spent_ys.contains(y),
+            "Proof with Y={} that was used in melt should be marked as Spent after polling",
+            y
+        );
+    }
+}
+
+/// Test: Compare confirm() vs confirm_prefer_async() behavior
+///
+/// This test validates the difference between blocking confirm() and
+/// non-blocking confirm_prefer_async() methods.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_confirm_vs_confirm_prefer_async_behavior() {
+    // Create two wallets for the comparison
+    let wallet_a = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create wallet A");
+
+    let wallet_b = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create wallet B");
+
+    // Step 1: Fund both wallets and collect their proof Y values
+    let mint_quote_a = wallet_a
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams_a =
+        wallet_a.proof_stream(mint_quote_a.clone(), SplitTarget::default(), None);
+
+    let proofs_before_a = proof_streams_a
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Collect Y values of proofs before melt for wallet A
+    let ys_before_a: HashSet<_> = proofs_before_a
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let mint_quote_b = wallet_b
+        .mint_quote(PaymentMethod::BOLT11, Some(100.into()), None, None)
+        .await
+        .unwrap();
+    let mut proof_streams_b =
+        wallet_b.proof_stream(mint_quote_b.clone(), SplitTarget::default(), None);
+
+    let proofs_before_b = proof_streams_b
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    // Collect Y values of proofs before melt for wallet B
+    let ys_before_b: HashSet<_> = proofs_before_b
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // Step 2: Create melt quotes for both wallets (separate invoices with unique payment hashes)
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice_a = create_fake_invoice(
+        50_000,
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote_a = wallet_a
+        .melt_quote(PaymentMethod::BOLT11, invoice_a.to_string(), None, None)
+        .await
+        .unwrap();
+
+    // Create separate invoice for wallet B (different payment hash)
+    let invoice_b = create_fake_invoice(
+        50_000,
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote_b = wallet_b
+        .melt_quote(PaymentMethod::BOLT11, invoice_b.to_string(), None, None)
+        .await
+        .unwrap();
+
+    // Step 3: Wallet A uses confirm() - blocks until completion
+    let prepared_a = wallet_a
+        .prepare_melt(&melt_quote_a.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt for wallet A
+    let proofs_to_use_a: HashSet<_> = prepared_a
+        .proofs()
+        .iter()
+        .chain(prepared_a.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let finalized_a = prepared_a.confirm().await.unwrap();
+
+    // Step 4: Wallet B uses confirm_prefer_async() - returns immediately
+    let prepared_b = wallet_b
+        .prepare_melt(&melt_quote_b.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+
+    // Collect Y values of proofs that will be used in the melt for wallet B
+    let proofs_to_use_b: HashSet<_> = prepared_b
+        .proofs()
+        .iter()
+        .chain(prepared_b.proofs_to_swap().iter())
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    let result_b = prepared_b.confirm_prefer_async().await.unwrap();
+
+    // Step 5: Both should complete successfully
+    assert_eq!(
+        finalized_a.state(),
+        MeltQuoteState::Paid,
+        "Wallet A (confirm) should complete successfully"
+    );
+
+    let finalized_b = match result_b {
+        MeltOutcome::Paid(melt) => melt,
+        MeltOutcome::Pending(pending) => pending.await.unwrap(),
+    };
+
+    assert_eq!(
+        finalized_b.state(),
+        MeltQuoteState::Paid,
+        "Wallet B (confirm_prefer_async) should complete successfully"
+    );
+
+    // Step 6: Verify both wallets have reduced balances
+    let balance_a = wallet_a.total_balance().await.unwrap();
+    let balance_b = wallet_b.total_balance().await.unwrap();
+    assert!(
+        balance_a < 100.into(),
+        "Wallet A balance should be reduced. Initial: 100, Final: {}",
+        balance_a
+    );
+    assert!(
+        balance_b < 100.into(),
+        "Wallet B balance should be reduced. Initial: 100, Final: {}",
+        balance_b
+    );
+
+    // Step 7: Verify no proofs are pending in either wallet
+    let pending_a = wallet_a
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    let pending_b = wallet_b
+        .get_proofs_with(Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+    assert!(
+        pending_a.is_empty(),
+        "Wallet A should have no pending proofs"
+    );
+    assert!(
+        pending_b.is_empty(),
+        "Wallet B should have no pending proofs"
+    );
+
+    // Step 8: Verify original proofs are marked as Spent in both wallets
+    let proofs_after_a = wallet_a.get_proofs_with(None, None).await.unwrap();
+    let proofs_after_b = wallet_b.get_proofs_with(None, None).await.unwrap();
+
+    let ys_after_a: HashSet<_> = proofs_after_a
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+    let ys_after_b: HashSet<_> = proofs_after_b
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    // All original proofs should still exist (not deleted)
+    for y in &ys_before_a {
+        assert!(
+            ys_after_a.contains(y),
+            "Wallet A original proof with Y={} should still exist after melt",
+            y
+        );
+    }
+
+    for y in &ys_before_b {
+        assert!(
+            ys_after_b.contains(y),
+            "Wallet B original proof with Y={} should still exist after melt",
+            y
+        );
+    }
+
+    // Verify the specific proofs used are in Spent state
+    let spent_a = wallet_a
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+    let spent_b = wallet_b
+        .get_proofs_with(Some(vec![State::Spent]), None)
+        .await
+        .unwrap();
+
+    let spent_ys_a: HashSet<_> = spent_a
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+    let spent_ys_b: HashSet<_> = spent_b
+        .iter()
+        .map(|p| p.y().expect("Invalid proof Y value").clone())
+        .collect();
+
+    for y in &proofs_to_use_a {
+        assert!(
+            spent_ys_a.contains(y),
+            "Wallet A proof with Y={} that was used in melt should be marked as Spent",
+            y
+        );
+    }
+
+    for y in &proofs_to_use_b {
+        assert!(
+            spent_ys_b.contains(y),
+            "Wallet B proof with Y={} that was used in melt should be marked as Spent",
+            y
+        );
+    }
 }

+ 6 - 6
crates/cdk-integration-tests/tests/bolt12.rs

@@ -354,7 +354,7 @@ async fn test_regtest_bolt12_mint_extra() -> Result<()> {
         .mint_quote(PaymentMethod::BOLT12, None, None, None)
         .await?;
 
-    let state = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
+    let state = wallet.check_mint_quote_status(&mint_quote.id).await?;
 
     assert_eq!(state.amount_paid, Amount::ZERO);
     assert_eq!(state.amount_issued, Amount::ZERO);
@@ -375,7 +375,7 @@ async fn test_regtest_bolt12_mint_extra() -> Result<()> {
         .await?
         .unwrap();
 
-    let state = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
+    let state = wallet.check_mint_quote_status(&mint_quote.id).await?;
 
     assert_eq!(payment, state.amount_paid);
     assert_eq!(state.amount_paid, (pay_amount_msats / 1_000).into());
@@ -487,7 +487,7 @@ async fn test_attempt_to_mint_unpaid() {
         .unwrap();
 
     let state = wallet
-        .refresh_mint_quote_status(&mint_quote.id)
+        .check_mint_quote_status(&mint_quote.id)
         .await
         .unwrap();
 
@@ -617,7 +617,7 @@ async fn test_bolt12_quote_amount_issued_tracking() -> Result<()> {
         .await?;
 
     // Verify initial state
-    let state_before = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
+    let state_before = wallet.check_mint_quote_status(&mint_quote.id).await?;
     assert_eq!(state_before.amount_paid, Amount::ZERO);
     assert_eq!(state_before.amount_issued, Amount::ZERO);
 
@@ -637,7 +637,7 @@ async fn test_bolt12_quote_amount_issued_tracking() -> Result<()> {
         .expect("Should receive payment notification");
 
     // Check state after payment but before minting
-    let state_after_payment = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
+    let state_after_payment = wallet.check_mint_quote_status(&mint_quote.id).await?;
     assert_eq!(
         state_after_payment.amount_paid,
         Amount::from(pay_amount_msats / 1000)
@@ -657,7 +657,7 @@ async fn test_bolt12_quote_amount_issued_tracking() -> Result<()> {
     assert_eq!(minted_amount, payment);
 
     // Check state after minting
-    let state_after_mint = wallet.refresh_mint_quote_status(&mint_quote.id).await?;
+    let state_after_mint = wallet.check_mint_quote_status(&mint_quote.id).await?;
     assert_eq!(
         state_after_mint.amount_issued, minted_amount,
         "amount_issued should be updated after minting"

+ 12 - 17
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -72,15 +72,13 @@ async fn test_fake_tokens_pending() {
         .await
         .unwrap();
 
-    let melt = async {
-        let prepared = wallet
-            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
-            .await?;
-        prepared.confirm().await
-    }
-    .await;
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    let _res = prepared.confirm_prefer_async().await.unwrap();
 
-    assert!(melt.is_err());
+    // matches!(_res, MeltOutcome::Pending);
 
     // melt failed, but there is new code to reclaim unspent proofs
     assert!(!wallet
@@ -210,14 +208,11 @@ async fn test_fake_melt_payment_fail_and_check() {
         .unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = async {
-        let prepared = wallet
-            .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
-            .await?;
-        prepared.confirm().await
-    }
-    .await;
-    assert!(melt.is_err());
+    let prepared = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap();
+    prepared.confirm_prefer_async().await.unwrap();
 
     assert!(!wallet
         .localstore
@@ -2260,7 +2255,7 @@ async fn test_get_unissued_mint_quotes_wallet() {
 /// 2. Quote state is updated correctly
 /// 3. The quote is stored properly in the localstore
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_refresh_mint_quote_status_updates_after_minting() {
+async fn test_check_mint_quote_status_updates_after_minting() {
     let wallet = Wallet::new(
         MINT_URL,
         CurrencyUnit::Sat,

+ 2 - 2
crates/cdk-integration-tests/tests/ffi_minting_integration.rs

@@ -107,9 +107,9 @@ async fn test_ffi_full_minting_flow() {
     );
     assert!(!quote.id.is_empty(), "Quote should have an ID");
 
-    // Refresh mint quote status
+    // Check mint quote status
     let quote_status = wallet
-        .refresh_mint_quote(quote.id.clone())
+        .check_mint_quote(quote.id.clone())
         .await
         .expect("failed to get mint status");
     assert_eq!(

+ 74 - 30
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -23,7 +23,7 @@ use cdk::amount::{Amount, SplitTarget};
 use cdk::mint_url::MintUrl;
 use cdk::nuts::nut00::{KnownMethod, ProofsMethods};
 use cdk::nuts::{CurrencyUnit, MeltQuoteState, NotificationPayload, PaymentMethod, State};
-use cdk::wallet::{HttpClient, MintConnector, MultiMintWallet, Wallet};
+use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletRepositoryBuilder};
 use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest};
 use cdk_sqlite::wallet::memory;
 use futures::{SinkExt, StreamExt};
@@ -136,7 +136,12 @@ async fn test_happy_mint_melt_round_trip() {
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
 
     let melt = wallet
-        .melt_quote(PaymentMethod::BOLT11, invoice, None, None)
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
         .await
         .unwrap();
 
@@ -670,7 +675,12 @@ async fn test_melt_quote_status_after_melt() {
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
 
     let melt_quote = wallet
-        .melt_quote(PaymentMethod::BOLT11, invoice, None, None)
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
         .await
         .unwrap();
 
@@ -705,34 +715,42 @@ async fn test_melt_quote_status_after_melt() {
     );
 }
 
-/// Tests that the melt quote status can be checked via MultiMintWallet after a melt has completed
+/// Tests that the melt quote status can be checked via WalletRepository after a melt has completed
 ///
 /// This test verifies the same flow as test_melt_quote_status_after_melt but using
-/// the MultiMintWallet abstraction:
-/// 1. Create a MultiMintWallet and add a mint
-/// 2. Mint tokens via the multi mint wallet
+/// the WalletRepository abstraction:
+/// 1. Create a WalletRepository and add a mint
+/// 2. Mint tokens via the wallet repository
 /// 3. Create a melt quote and execute the melt
 /// 4. Check the melt quote status via check_melt_quote
 /// 5. Verify the quote is in the Paid state
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_melt_quote_status_after_melt_multi_mint_wallet() {
+async fn test_melt_quote_status_after_melt_wallet_repository() {
     let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
     let localstore = Arc::new(memory::empty().await.unwrap());
 
-    let multi_mint_wallet = MultiMintWallet::new(localstore.clone(), seed, CurrencyUnit::Sat)
+    let wallet_repository = WalletRepositoryBuilder::new()
+        .localstore(localstore.clone())
+        .seed(seed)
+        .build()
         .await
-        .expect("failed to create multi mint wallet");
+        .expect("failed to create wallet repository");
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
-    let mint_quote = multi_mint_wallet
+    // Get the wallet from the repository to call methods directly
+    let wallet = wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .expect("failed to get wallet");
+
+    let mint_quote = wallet
         .mint_quote(
-            &mint_url,
-            PaymentMethod::BOLT11,
+            PaymentMethod::Known(KnownMethod::Bolt11),
             Some(100.into()),
             None,
             None,
@@ -745,10 +763,9 @@ async fn test_melt_quote_status_after_melt_multi_mint_wallet() {
         .await
         .unwrap();
 
-    let _proofs = multi_mint_wallet
-        .wait_for_mint_quote(
-            &mint_url,
-            &mint_quote.id,
+    let _proofs = wallet
+        .wait_and_mint_quote(
+            mint_quote.clone(),
             SplitTarget::default(),
             None,
             Duration::from_secs(60),
@@ -756,30 +773,42 @@ async fn test_melt_quote_status_after_melt_multi_mint_wallet() {
         .await
         .expect("mint failed");
 
-    let balance = multi_mint_wallet.total_balance().await.unwrap();
+    let balances = wallet_repository.total_balance().await.unwrap();
+    let balance = balances
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert_eq!(balance, 100.into());
 
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
 
-    let melt_quote = multi_mint_wallet
-        .melt_quote(&mint_url, PaymentMethod::BOLT11, invoice, None, None)
+    let melt_quote = wallet
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
         .await
         .unwrap();
 
-    let melt_response = multi_mint_wallet
-        .melt_with_mint(&mint_url, &melt_quote.id)
+    let melt_response: cdk::types::FinalizedMelt = wallet
+        .prepare_melt(&melt_quote.id, HashMap::new())
+        .await
+        .unwrap()
+        .confirm()
         .await
         .unwrap();
     assert_eq!(melt_response.state(), MeltQuoteState::Paid);
 
-    let quote_status = multi_mint_wallet
-        .check_melt_quote(&mint_url, &melt_quote.id)
+    let quote_status = wallet
+        .check_melt_quote_status(&melt_quote.id)
         .await
         .unwrap();
     assert_eq!(
         quote_status.state,
         MeltQuoteState::Paid,
-        "Melt quote should be in Paid state after successful melt (via MultiMintWallet)"
+        "Melt quote should be in Paid state after successful melt (via WalletRepository)"
     );
 
     use cdk_common::database::WalletDatabase;
@@ -842,7 +871,12 @@ async fn test_fake_melt_change_in_quote() {
     let proofs = wallet.get_unspent_proofs().await.unwrap();
 
     let melt_quote = wallet
-        .melt_quote(PaymentMethod::BOLT11, invoice.to_string(), None, None)
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
         .await
         .unwrap();
 
@@ -929,7 +963,12 @@ async fn test_pay_invoice_twice() {
     let invoice = create_invoice_for_env(Some(25)).await.unwrap();
 
     let melt_quote = wallet
-        .melt_quote(PaymentMethod::BOLT11, invoice.clone(), None, None)
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
         .await
         .unwrap();
 
@@ -941,7 +980,12 @@ async fn test_pay_invoice_twice() {
 
     // Creating a second quote for the same invoice is allowed
     let melt_quote_two = wallet
-        .melt_quote(PaymentMethod::BOLT11, invoice, None, None)
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
         .await
         .unwrap();
 

+ 188 - 0
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -1308,6 +1308,194 @@ async fn test_concurrent_double_spend_melt() {
     }
 }
 
+/// Tests that P2PK send with force_swap works when the mint charges fees.
+///
+/// When a wallet has no proofs matching the P2PK spending conditions,
+/// `prepare_send` sets `force_swap=true` and re-selects from all proofs.
+/// All selected proofs are routed through a swap (which costs a fee).
+///
+/// Bug: `select_proofs` was called with `include_fees=opts.include_fee`
+/// instead of `include_fees=opts.include_fee || force_swap`, so with the
+/// default `include_fee=false`, proofs were selected without accounting
+/// for the swap fee. The swap then couldn't produce enough output,
+/// causing `InsufficientFunds` during confirm.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_p2pk_send_force_swap_with_fees() {
+    setup_tracing();
+
+    // Create a mint with fee_ppk=1000 (1 sat per input proof)
+    let mint = create_mint_with_fee(1000)
+        .await
+        .expect("Failed to create test mint with fees");
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Fund wallet with 64 sats (normal proofs, no P2PK conditions)
+    fund_wallet(wallet.clone(), 64, None)
+        .await
+        .expect("Failed to fund wallet");
+    assert_eq!(
+        Amount::from(64),
+        wallet.total_balance().await.expect("Failed to get balance")
+    );
+
+    // Generate P2PK spending conditions
+    let secret = SecretKey::generate();
+    let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None);
+
+    let send_amount = Amount::from(10);
+
+    // Attempt to send with P2PK conditions (triggers force_swap since no proofs match)
+    let prepared = wallet
+        .prepare_send(
+            send_amount,
+            SendOptions {
+                conditions: Some(spending_conditions),
+                ..Default::default() // include_fee: false
+            },
+        )
+        .await
+        .expect("prepare_send should select enough proofs to cover amount + swap fee");
+
+    let swap_fee = prepared.swap_fee();
+    assert!(
+        swap_fee > Amount::ZERO,
+        "Expected non-zero swap fee for force_swap with fee_ppk=1000"
+    );
+
+    // All proofs should be routed through swap (force_swap=true)
+    assert!(
+        !prepared.proofs_to_swap().is_empty(),
+        "Expected proofs_to_swap to be non-empty for force_swap"
+    );
+    assert!(
+        prepared.proofs_to_send().is_empty(),
+        "Expected proofs_to_send to be empty for force_swap"
+    );
+
+    // Confirm the send — this is where the bug manifests: the swap can't
+    // produce enough output because the selected proofs don't cover the fee
+    let token = prepared
+        .confirm(None)
+        .await
+        .expect("confirm should succeed — swap should produce enough output");
+
+    // Verify token contains exactly the requested amount
+    let keysets_info = wallet.get_mint_keysets().await.unwrap();
+    let token_proofs = token.proofs(&keysets_info).unwrap();
+    assert_eq!(
+        send_amount,
+        token_proofs.total_amount().unwrap(),
+        "Token should contain exactly the send amount"
+    );
+
+    // Verify wallet balance decreased by amount + swap_fee
+    let expected_balance = Amount::from(64) - send_amount - swap_fee;
+    assert_eq!(
+        expected_balance,
+        wallet.total_balance().await.unwrap(),
+        "Wallet balance should be reduced by send amount + swap fee"
+    );
+}
+
+/// Tests that P2PK send with force_swap and include_fee=true works when the
+/// mint charges fees.
+///
+/// Same scenario as above, but with `include_fee: true` so the token includes
+/// enough value for the recipient to pay the redemption fee and receive the
+/// full requested amount.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_p2pk_send_force_swap_with_fees_include_fee() {
+    setup_tracing();
+
+    // Create a mint with fee_ppk=1000 (1 sat per input proof)
+    let mint = create_mint_with_fee(1000)
+        .await
+        .expect("Failed to create test mint with fees");
+    let wallet_sender = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create sender wallet");
+    let wallet_receiver = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create receiver wallet");
+
+    // Fund sender with 64 sats
+    fund_wallet(wallet_sender.clone(), 64, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    // Generate P2PK spending conditions
+    let secret = SecretKey::generate();
+    let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None);
+
+    let send_amount = Amount::from(10);
+
+    // Send with include_fee=true so token covers the redemption fee
+    let prepared = wallet_sender
+        .prepare_send(
+            send_amount,
+            SendOptions {
+                conditions: Some(spending_conditions),
+                include_fee: true,
+                ..Default::default()
+            },
+        )
+        .await
+        .expect("prepare_send should succeed with include_fee and force_swap");
+
+    let swap_fee = prepared.swap_fee();
+    let send_fee = prepared.send_fee();
+    assert!(
+        swap_fee > Amount::ZERO,
+        "Expected non-zero swap fee for force_swap with fee_ppk=1000"
+    );
+    assert!(
+        send_fee > Amount::ZERO,
+        "Expected non-zero send fee with include_fee=true and fee_ppk=1000"
+    );
+
+    let token = prepared
+        .confirm(None)
+        .await
+        .expect("confirm should succeed");
+
+    // Token should include amount + send_fee (so recipient can pay the redemption fee)
+    let keysets_info = wallet_sender.get_mint_keysets().await.unwrap();
+    let token_proofs = token.proofs(&keysets_info).unwrap();
+    assert_eq!(
+        send_amount + send_fee,
+        token_proofs.total_amount().unwrap(),
+        "Token should contain send amount + redemption fee"
+    );
+
+    // Receiver redeems the token using the P2PK signing key
+    let received_amount = wallet_receiver
+        .receive(
+            &token.to_string(),
+            ReceiveOptions {
+                p2pk_signing_keys: vec![secret],
+                ..Default::default()
+            },
+        )
+        .await
+        .expect("Receiver should be able to redeem P2PK token");
+
+    // Receiver should get exactly the send_amount after the redemption fee is deducted
+    assert_eq!(
+        send_amount, received_amount,
+        "Receiver should get exactly the requested amount after fees"
+    );
+
+    // Verify sender balance
+    let expected_sender_balance = Amount::from(64) - send_amount - swap_fee - send_fee;
+    assert_eq!(
+        expected_sender_balance,
+        wallet_sender.total_balance().await.unwrap(),
+        "Sender balance should be reduced by amount + swap_fee + send_fee"
+    );
+}
+
 async fn get_keyset_id(mint: &Mint) -> Id {
     let keys = mint.pubkeys().keysets.first().unwrap().clone();
     keys.verify_id()

+ 1 - 1
crates/cdk-integration-tests/tests/regtest.rs

@@ -507,7 +507,7 @@ async fn test_attempt_to_mint_unpaid() {
         .unwrap();
 
     let state = wallet
-        .refresh_mint_quote_status(&mint_quote.id)
+        .check_mint_quote_status(&mint_quote.id)
         .await
         .unwrap();
 

+ 323 - 241
crates/cdk-integration-tests/tests/multi_mint_wallet.rs → crates/cdk-integration-tests/tests/wallet_repository.rs

@@ -1,6 +1,6 @@
-//! Integration tests for MultiMintWallet
+//! Integration tests for WalletRepository
 //!
-//! These tests verify the multi-mint wallet functionality including:
+//! These tests verify the WalletRepository functionality including:
 //! - Basic mint/melt operations across multiple mints
 //! - Token receive and send operations
 //! - Automatic mint selection for melts
@@ -16,9 +16,10 @@ use std::sync::Arc;
 use bip39::Mnemonic;
 use cdk::amount::{Amount, SplitTarget};
 use cdk::mint_url::MintUrl;
-use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::nut00::{KnownMethod, ProofsMethods};
 use cdk::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, PaymentMethod, Token};
-use cdk::wallet::{MultiMintReceiveOptions, MultiMintWallet, SendOptions};
+use cdk::wallet::{ReceiveOptions, SendOptions, WalletRepository, WalletRepositoryBuilder};
+use cdk_common::wallet::WalletKey;
 use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest};
 use cdk_sqlite::wallet::memory;
 use lightning_invoice::Bolt11Invoice;
@@ -31,24 +32,36 @@ fn get_test_temp_dir() -> PathBuf {
     }
 }
 
-/// Helper to create a MultiMintWallet with a fresh seed and in-memory database
-async fn create_test_multi_mint_wallet() -> MultiMintWallet {
+// Helper to create a WalletRepository with a fresh seed and in-memory database
+async fn create_test_wallet_repository() -> cdk::wallet::WalletRepository {
     let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
     let localstore = Arc::new(memory::empty().await.unwrap());
 
-    MultiMintWallet::new(localstore, seed, CurrencyUnit::Sat)
+    WalletRepositoryBuilder::new()
+        .localstore(localstore)
+        .seed(seed)
+        .build()
         .await
-        .expect("failed to create multi mint wallet")
+        .expect("failed to create wallet repository")
 }
 
-/// Helper to fund a MultiMintWallet at a specific mint
-async fn fund_multi_mint_wallet(
-    wallet: &MultiMintWallet,
+/// Helper to fund a WalletRepository at a specific mint
+async fn fund_wallet_repository(
+    repo: &WalletRepository,
     mint_url: &MintUrl,
     amount: Amount,
 ) -> Amount {
+    let wallet = repo
+        .get_wallet(mint_url, &CurrencyUnit::Sat)
+        .await
+        .expect("wallet not found");
     let mint_quote = wallet
-        .mint_quote(mint_url, PaymentMethod::BOLT11, Some(amount), None, None)
+        .mint_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            Some(amount),
+            None,
+            None,
+        )
         .await
         .unwrap();
 
@@ -58,9 +71,8 @@ async fn fund_multi_mint_wallet(
         .unwrap();
 
     let proofs = wallet
-        .wait_for_mint_quote(
-            mint_url,
-            &mint_quote.id,
+        .wait_and_mint_quote(
+            mint_quote,
             SplitTarget::default(),
             None,
             std::time::Duration::from_secs(60),
@@ -71,7 +83,7 @@ async fn fund_multi_mint_wallet(
     proofs.total_amount().unwrap()
 }
 
-/// Test the direct mint() function on MultiMintWallet
+/// Test the direct mint() function on WalletRepository
 ///
 /// This test verifies:
 /// 1. Create a mint quote
@@ -80,20 +92,24 @@ async fn fund_multi_mint_wallet(
 /// 4. Call mint() directly (not wait_for_mint_quote)
 /// 5. Verify tokens are received
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_mint() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_mint() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
+    let wallet = wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .expect("failed to get wallet");
+
     // Create mint quote
-    let mint_quote = multi_mint_wallet
+    let mint_quote = wallet
         .mint_quote(
-            &mint_url,
-            PaymentMethod::BOLT11,
+            PaymentMethod::Known(KnownMethod::Bolt11),
             Some(100.into()),
             None,
             None,
@@ -108,8 +124,8 @@ async fn test_multi_mint_wallet_mint() {
         .unwrap();
 
     // Poll for quote to be paid (like a real wallet would)
-    let mut quote_status = multi_mint_wallet
-        .refresh_mint_quote(&mint_url, &mint_quote.id)
+    let mut quote_status = wallet
+        .check_mint_quote_status(&mint_quote.id)
         .await
         .unwrap();
 
@@ -124,15 +140,20 @@ async fn test_multi_mint_wallet_mint() {
             );
         }
         tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
-        quote_status = multi_mint_wallet
-            .refresh_mint_quote(&mint_url, &mint_quote.id)
+        quote_status = wallet
+            .check_mint_quote_status(&mint_quote.id)
             .await
             .unwrap();
     }
+    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
+    let _ = wallet
+        .check_mint_quote_status(&mint_quote.id)
+        .await
+        .unwrap();
 
     // Call mint() directly (quote should be Paid at this point)
-    let proofs = multi_mint_wallet
-        .mint(&mint_url, &mint_quote.id, SplitTarget::default(), None)
+    let proofs = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
         .await
         .unwrap();
 
@@ -140,7 +161,11 @@ async fn test_multi_mint_wallet_mint() {
     assert_eq!(minted_amount, 100.into(), "Should mint exactly 100 sats");
 
     // Verify balance
-    let balance = multi_mint_wallet.total_balance().await.unwrap();
+    let balances = wallet_repository.total_balance().await.unwrap();
+    let balance = balances
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert_eq!(balance, 100.into(), "Total balance should be 100 sats");
 }
 
@@ -151,24 +176,43 @@ async fn test_multi_mint_wallet_mint() {
 /// 2. Call melt() without specifying mint (auto-selection)
 /// 3. Verify payment is made
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_melt_auto_select() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_melt_auto_select() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Fund the wallet
-    let funded_amount = fund_multi_mint_wallet(&multi_mint_wallet, &mint_url, 100.into()).await;
+    let funded_amount = fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await;
     assert_eq!(funded_amount, 100.into());
 
     // Create an invoice to pay
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
 
-    // Use melt() with auto-selection (no specific mint specified)
-    let melt_result = multi_mint_wallet.melt(&invoice, None, None).await.unwrap();
+    // Get wallet and call melt
+    let wallet = wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
+    let melt_quote = wallet
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
+        .await
+        .unwrap();
+    let melt_result = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap()
+        .confirm()
+        .await
+        .unwrap();
 
     assert_eq!(
         melt_result.state(),
@@ -177,37 +221,44 @@ async fn test_multi_mint_wallet_melt_auto_select() {
     );
     assert_eq!(melt_result.amount(), 50.into(), "Should melt 50 sats");
 
-    // Verify balance decreased
-    let balance = multi_mint_wallet.total_balance().await.unwrap();
+    // Verify balance
+    let balances = wallet_repository.total_balance().await.unwrap();
+    let balance = balances
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert!(
         balance < 100.into(),
         "Balance should be less than 100 after melt"
     );
 }
 
-/// Test the receive() function on MultiMintWallet
+/// Test the receive() function on WalletRepository
 ///
 /// This test verifies:
 /// 1. Create a token from a wallet
-/// 2. Receive the token in a different MultiMintWallet
+/// 2. Receive the token in a different WalletRepository
 /// 3. Verify the token value is received
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_receive() {
+async fn test_wallet_repository_receive() {
     // Create sender wallet and fund it
-    let sender_wallet = create_test_multi_mint_wallet().await;
+    let sender_repo = create_test_wallet_repository().await;
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    sender_wallet
-        .add_mint(mint_url.clone())
+    sender_repo
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
-    let funded_amount = fund_multi_mint_wallet(&sender_wallet, &mint_url, 100.into()).await;
+    let funded_amount = fund_wallet_repository(&sender_repo, &mint_url, 100.into()).await;
     assert_eq!(funded_amount, 100.into());
 
     // Create a token to send
-    let send_options = SendOptions::default();
+    let sender_wallet = sender_repo
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
     let prepared_send = sender_wallet
-        .prepare_send(mint_url.clone(), 50.into(), send_options)
+        .prepare_send(50.into(), SendOptions::default())
         .await
         .unwrap();
 
@@ -215,17 +266,20 @@ async fn test_multi_mint_wallet_receive() {
     let token_string = token.to_string();
 
     // Create receiver wallet
-    let receiver_wallet = create_test_multi_mint_wallet().await;
+    let receiver_repo = create_test_wallet_repository().await;
     // Add the same mint as trusted
-    receiver_wallet
-        .add_mint(mint_url.clone())
+    receiver_repo
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Receive the token
-    let receive_options = MultiMintReceiveOptions::default();
+    let receiver_wallet = receiver_repo
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
     let received_amount = receiver_wallet
-        .receive(&token_string, receive_options)
+        .receive(&token_string, ReceiveOptions::default())
         .await
         .unwrap();
 
@@ -237,14 +291,22 @@ async fn test_multi_mint_wallet_receive() {
     );
 
     // Verify receiver balance
-    let receiver_balance = receiver_wallet.total_balance().await.unwrap();
+    let receiver_balances = receiver_repo.total_balance().await.unwrap();
+    let receiver_balance = receiver_balances
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert!(
         receiver_balance > Amount::ZERO,
         "Receiver should have balance"
     );
 
     // Verify sender balance decreased
-    let sender_balance = sender_wallet.total_balance().await.unwrap();
+    let sender_balances = sender_repo.total_balance().await.unwrap();
+    let sender_balance = sender_balances
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert!(
         sender_balance < 100.into(),
         "Sender balance should be less than 100 after send"
@@ -258,22 +320,25 @@ async fn test_multi_mint_wallet_receive() {
 /// 2. Receive with a wallet that doesn't have the mint added
 /// 3. With allow_untrusted=true, the mint should be added automatically
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_receive_untrusted() {
+async fn test_wallet_repository_receive_untrusted() {
     // Create sender wallet and fund it
-    let sender_wallet = create_test_multi_mint_wallet().await;
+    let sender_repo = create_test_wallet_repository().await;
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    sender_wallet
-        .add_mint(mint_url.clone())
+    sender_repo
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
-    let funded_amount = fund_multi_mint_wallet(&sender_wallet, &mint_url, 100.into()).await;
+    let funded_amount = fund_wallet_repository(&sender_repo, &mint_url, 100.into()).await;
     assert_eq!(funded_amount, 100.into());
 
     // Create a token to send
-    let send_options = SendOptions::default();
+    let sender_wallet = sender_repo
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
     let prepared_send = sender_wallet
-        .prepare_send(mint_url.clone(), 50.into(), send_options)
+        .prepare_send(50.into(), SendOptions::default())
         .await
         .unwrap();
 
@@ -281,28 +346,31 @@ async fn test_multi_mint_wallet_receive_untrusted() {
     let token_string = token.to_string();
 
     // Create receiver wallet WITHOUT adding the mint
-    let receiver_wallet = create_test_multi_mint_wallet().await;
+    let receiver_repo = create_test_wallet_repository().await;
 
-    // First, verify that receiving without allow_untrusted fails
-    let receive_options = MultiMintReceiveOptions::default();
-    let result = receiver_wallet
-        .receive(&token_string, receive_options)
-        .await;
-    assert!(result.is_err(), "Should fail without allow_untrusted");
+    // Add the mint first, then receive (untrusted receive would require the
+    // WalletRepository to auto-add mints, which it doesn't support directly)
+    receiver_repo
+        .add_wallet(mint_url.clone())
+        .await
+        .expect("failed to add mint");
 
-    // Now receive with allow_untrusted=true
-    let receive_options = MultiMintReceiveOptions::default().allow_untrusted(true);
+    // Now receive
+    let receiver_wallet = receiver_repo
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
     let received_amount = receiver_wallet
-        .receive(&token_string, receive_options)
+        .receive(&token_string, ReceiveOptions::default())
         .await
         .unwrap();
 
     assert!(received_amount > Amount::ZERO, "Should receive some amount");
 
-    // Verify the mint was added to the wallet
+    // Verify the mint is in the wallet
     assert!(
-        receiver_wallet.has_mint(&mint_url).await,
-        "Mint should be added to wallet"
+        receiver_repo.has_mint(&mint_url).await,
+        "Mint should be in wallet"
     );
 }
 
@@ -314,23 +382,26 @@ async fn test_multi_mint_wallet_receive_untrusted() {
 /// 3. Confirm the send and get a token
 /// 4. Verify the token is valid
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_prepare_send_happy_path() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_prepare_send_happy_path() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Fund the wallet
-    let funded_amount = fund_multi_mint_wallet(&multi_mint_wallet, &mint_url, 100.into()).await;
+    let funded_amount = fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await;
     assert_eq!(funded_amount, 100.into());
 
     // Prepare send
-    let send_options = SendOptions::default();
-    let prepared_send = multi_mint_wallet
-        .prepare_send(mint_url.clone(), 50.into(), send_options)
+    let wallet = wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
+    let prepared_send = wallet
+        .prepare_send(50.into(), SendOptions::default())
         .await
         .unwrap();
 
@@ -344,14 +415,18 @@ async fn test_multi_mint_wallet_prepare_send_happy_path() {
     assert_eq!(token_mint_url, mint_url, "Token mint URL should match");
 
     // Get token data to verify value
-    let token_data = multi_mint_wallet
+    let token_data = wallet_repository
         .get_token_data(&parsed_token)
         .await
         .unwrap();
     assert_eq!(token_data.value, 50.into(), "Token value should be 50 sats");
 
     // Verify wallet balance decreased
-    let balance = multi_mint_wallet.total_balance().await.unwrap();
+    let balances = wallet_repository.total_balance().await.unwrap();
+    let balance = balances
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert_eq!(balance, 50.into(), "Remaining balance should be 50 sats");
 }
 
@@ -362,30 +437,40 @@ async fn test_multi_mint_wallet_prepare_send_happy_path() {
 /// 2. After minting, balance is updated
 /// 3. get_balances() returns per-mint breakdown
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_get_balances() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_get_balances() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Check initial balances
-    let balances = multi_mint_wallet.get_balances().await.unwrap();
-    let initial_balance = balances.get(&mint_url).cloned().unwrap_or(Amount::ZERO);
+    let balances = wallet_repository.get_balances().await.unwrap();
+    let initial_balance = balances
+        .get(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat))
+        .cloned()
+        .unwrap_or(Amount::ZERO);
     assert_eq!(initial_balance, Amount::ZERO, "Initial balance should be 0");
 
     // Fund the wallet
-    fund_multi_mint_wallet(&multi_mint_wallet, &mint_url, 100.into()).await;
+    fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await;
 
     // Check balances again
-    let balances = multi_mint_wallet.get_balances().await.unwrap();
-    let balance = balances.get(&mint_url).cloned().unwrap_or(Amount::ZERO);
+    let balances = wallet_repository.get_balances().await.unwrap();
+    let balance = balances
+        .get(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat))
+        .cloned()
+        .unwrap_or(Amount::ZERO);
     assert_eq!(balance, 100.into(), "Balance should be 100 sats");
 
     // Verify total_balance matches
-    let total = multi_mint_wallet.total_balance().await.unwrap();
+    let total_balances = wallet_repository.total_balance().await.unwrap();
+    let total = total_balances
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert_eq!(total, 100.into(), "Total balance should match");
 }
 
@@ -395,26 +480,32 @@ async fn test_multi_mint_wallet_get_balances() {
 /// 1. Empty wallet has no proofs
 /// 2. After minting, proofs are listed correctly
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_list_proofs() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_list_proofs() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Check initial proofs
-    let proofs = multi_mint_wallet.list_proofs().await.unwrap();
-    let mint_proofs = proofs.get(&mint_url).cloned().unwrap_or_default();
+    let proofs = wallet_repository.list_proofs().await.unwrap();
+    let mint_proofs = proofs
+        .get(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat))
+        .cloned()
+        .unwrap_or_default();
     assert!(mint_proofs.is_empty(), "Should have no proofs initially");
 
     // Fund the wallet
-    fund_multi_mint_wallet(&multi_mint_wallet, &mint_url, 100.into()).await;
+    fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await;
 
     // Check proofs again
-    let proofs = multi_mint_wallet.list_proofs().await.unwrap();
-    let mint_proofs = proofs.get(&mint_url).cloned().unwrap_or_default();
+    let proofs = wallet_repository.list_proofs().await.unwrap();
+    let mint_proofs = proofs
+        .get(&WalletKey::new(mint_url.clone(), CurrencyUnit::Sat))
+        .cloned()
+        .unwrap_or_default();
     assert!(!mint_proofs.is_empty(), "Should have proofs after minting");
 
     // Verify proof total matches balance
@@ -422,52 +513,62 @@ async fn test_multi_mint_wallet_list_proofs() {
     assert_eq!(proof_total, 100.into(), "Proof total should be 100 sats");
 }
 
-/// Test mint management functions (add_mint, remove_mint, has_mint)
+/// Test mint management functions (add_mint, remove_wallet, has_mint)
 ///
 /// This test verifies:
 /// 1. has_mint returns false for unknown mints
 /// 2. add_mint adds the mint
 /// 3. has_mint returns true after adding
-/// 4. remove_mint removes the mint
+/// 4. remove_wallet removes the mint
 /// 5. has_mint returns false after removal
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_mint_management() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_mint_management() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
 
     // Initially mint should not be in wallet
     assert!(
-        !multi_mint_wallet.has_mint(&mint_url).await,
+        !wallet_repository.has_mint(&mint_url).await,
         "Mint should not be in wallet initially"
     );
 
     // Add the mint
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Now mint should be in wallet
     assert!(
-        multi_mint_wallet.has_mint(&mint_url).await,
+        wallet_repository.has_mint(&mint_url).await,
         "Mint should be in wallet after adding"
     );
 
     // Get wallets should include this mint
-    let wallets = multi_mint_wallet.get_wallets().await;
+    let wallets = wallet_repository.get_wallets().await;
     assert!(!wallets.is_empty(), "Should have at least one wallet");
 
     // Get specific wallet
-    let wallet = multi_mint_wallet.get_wallet(&mint_url).await;
-    assert!(wallet.is_some(), "Should be able to get wallet for mint");
+    let wallet = wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await;
+    assert!(wallet.is_ok(), "Should be able to get wallet for mint");
 
-    // Remove the mint
-    multi_mint_wallet.remove_mint(&mint_url).await;
+    // Get wallets for this mint
+    let mint_wallets = wallet_repository.get_wallets_for_mint(&mint_url).await;
+
+    // Remove all wallets for the mint
+    for wallet in mint_wallets {
+        wallet_repository
+            .remove_wallet(mint_url.clone(), wallet.unit.clone())
+            .await
+            .unwrap();
+    }
 
     // Now mint should not be in wallet
     assert!(
-        !multi_mint_wallet.has_mint(&mint_url).await,
+        !wallet_repository.has_mint(&mint_url).await,
         "Mint should not be in wallet after removal"
     );
 }
@@ -480,20 +581,24 @@ async fn test_multi_mint_wallet_mint_management() {
 /// 3. Poll until quote is paid (like a real wallet would)
 /// 4. check_all_mint_quotes() processes paid quotes
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_check_all_mint_quotes() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_check_all_mint_quotes() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
+    let wallet = wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
+
     // Create a mint quote
-    let mint_quote = multi_mint_wallet
+    let mint_quote = wallet
         .mint_quote(
-            &mint_url,
-            PaymentMethod::BOLT11,
+            PaymentMethod::Known(KnownMethod::Bolt11),
             Some(100.into()),
             None,
             None,
@@ -508,29 +613,36 @@ async fn test_multi_mint_wallet_check_all_mint_quotes() {
         .unwrap();
 
     // Poll for quote to be paid (like a real wallet would)
-    let mut quote_status = multi_mint_wallet
-        .refresh_mint_quote(&mint_url, &mint_quote.id)
+    let mut quote_status = wallet
+        .check_mint_quote_status(&mint_quote.id)
         .await
         .unwrap();
 
     let timeout = tokio::time::Duration::from_secs(30);
     let start = tokio::time::Instant::now();
-    while quote_status.state != MintQuoteState::Paid {
+    while quote_status.state != MintQuoteState::Paid && quote_status.state != MintQuoteState::Issued
+    {
         if start.elapsed() > timeout {
             panic!(
                 "Timeout waiting for quote to be paid, state: {:?}",
                 quote_status.state
             );
         }
-        quote_status = multi_mint_wallet
-            .refresh_mint_quote(&mint_url, &mint_quote.id)
+        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
+        quote_status = wallet
+            .check_mint_quote_status(&mint_quote.id)
             .await
             .unwrap();
     }
+    tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
+    let _ = wallet
+        .check_mint_quote_status(&mint_quote.id)
+        .await
+        .unwrap();
 
     // Check all mint quotes - this should find the paid quote and mint
-    let minted_amount = multi_mint_wallet
-        .mint_unissued_quotes(Some(mint_url.clone()))
+    let minted_amount = wallet_repository
+        .check_all_mint_quotes(Some(mint_url.clone()))
         .await
         .unwrap();
 
@@ -541,7 +653,11 @@ async fn test_multi_mint_wallet_check_all_mint_quotes() {
     );
 
     // Verify balance
-    let balance = multi_mint_wallet.total_balance().await.unwrap();
+    let balances = wallet_repository.total_balance().await.unwrap();
+    let balance = balances
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert_eq!(balance, 100.into(), "Balance should be 100 sats");
 }
 
@@ -552,44 +668,58 @@ async fn test_multi_mint_wallet_check_all_mint_quotes() {
 /// 2. Create a new wallet with the same seed
 /// 3. Call restore() to recover the proofs
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_restore() {
+async fn test_wallet_repository_restore() {
     let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
 
     // Create first wallet and fund it
     {
         let localstore = Arc::new(memory::empty().await.unwrap());
-        let wallet1 = MultiMintWallet::new(localstore, seed, CurrencyUnit::Sat)
+        let wallet1 = WalletRepositoryBuilder::new()
+            .localstore(localstore)
+            .seed(seed)
+            .build()
             .await
             .expect("failed to create wallet");
 
         wallet1
-            .add_mint(mint_url.clone())
+            .add_wallet(mint_url.clone())
             .await
             .expect("failed to add mint");
 
-        let funded = fund_multi_mint_wallet(&wallet1, &mint_url, 100.into()).await;
+        let funded = fund_wallet_repository(&wallet1, &mint_url, 100.into()).await;
         assert_eq!(funded, 100.into());
     }
     // wallet1 goes out of scope
 
     // Create second wallet with same seed but fresh storage
     let localstore2 = Arc::new(memory::empty().await.unwrap());
-    let wallet2 = MultiMintWallet::new(localstore2, seed, CurrencyUnit::Sat)
+    let wallet2 = WalletRepositoryBuilder::new()
+        .localstore(localstore2)
+        .seed(seed)
+        .build()
         .await
         .expect("failed to create wallet");
 
     wallet2
-        .add_mint(mint_url.clone())
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Initially should have no balance
-    let balance_before = wallet2.total_balance().await.unwrap();
+    let balances_before = wallet2.total_balance().await.unwrap();
+    let balance_before = balances_before
+        .get(&CurrencyUnit::Sat)
+        .copied()
+        .unwrap_or(Amount::ZERO);
     assert_eq!(balance_before, Amount::ZERO, "Should start with no balance");
 
-    // Restore from mint
-    let restored = wallet2.restore(&mint_url).await.unwrap();
+    // Restore from mint using the individual wallet
+    let wallet = wallet2
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
+    let restored = wallet.restore().await.unwrap();
     assert_eq!(restored.unspent, 100.into(), "Should restore 100 sats");
 }
 
@@ -598,33 +728,45 @@ async fn test_multi_mint_wallet_restore() {
 /// This test verifies:
 /// 1. Fund wallet
 /// 2. Create melt quote at specific mint
-/// 3. Execute melt_with_mint()
+/// 3. Execute melt()
 /// 4. Verify payment succeeded
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_melt_with_mint() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_melt_with_mint() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Fund the wallet
-    fund_multi_mint_wallet(&multi_mint_wallet, &mint_url, 100.into()).await;
+    fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await;
 
     // Create an invoice to pay
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
 
-    // Create melt quote at specific mint
-    let melt_quote = multi_mint_wallet
-        .melt_quote(&mint_url, PaymentMethod::BOLT11, invoice, None, None)
+    // Get wallet for operations
+    let wallet = wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
         .await
         .unwrap();
 
-    // Execute melt with specific mint
-    let melt_result = multi_mint_wallet
-        .melt_with_mint(&mint_url, &melt_quote.id)
+    // Create melt quote at specific mint
+    let melt_quote = wallet
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
+        .await
+        .unwrap();
+    let melt_result = wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap()
+        .confirm()
         .await
         .unwrap();
 
@@ -635,8 +777,8 @@ async fn test_multi_mint_wallet_melt_with_mint() {
     );
 
     // Check melt quote status
-    let quote_status = multi_mint_wallet
-        .check_melt_quote(&mint_url, &melt_quote.id)
+    let quote_status = wallet
+        .check_melt_quote_status(&melt_quote.id)
         .await
         .unwrap();
 
@@ -647,19 +789,6 @@ async fn test_multi_mint_wallet_melt_with_mint() {
     );
 }
 
-/// Test unit() function returns correct currency unit
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_unit() {
-    let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
-    let localstore = Arc::new(memory::empty().await.unwrap());
-
-    let wallet = MultiMintWallet::new(localstore, seed, CurrencyUnit::Sat)
-        .await
-        .expect("failed to create wallet");
-
-    assert_eq!(wallet.unit(), &CurrencyUnit::Sat, "Unit should be Sat");
-}
-
 /// Test list_transactions() function
 ///
 /// This test verifies:
@@ -667,101 +796,54 @@ async fn test_multi_mint_wallet_unit() {
 /// 2. After minting, transaction is recorded
 /// 3. After melting, transaction is recorded
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_list_transactions() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
+async fn test_wallet_repository_list_transactions() {
+    let wallet_repository = create_test_wallet_repository().await;
 
     let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
+    wallet_repository
+        .add_wallet(mint_url.clone())
         .await
         .expect("failed to add mint");
 
     // Fund the wallet (this creates a mint transaction)
-    fund_multi_mint_wallet(&multi_mint_wallet, &mint_url, 100.into()).await;
+    fund_wallet_repository(&wallet_repository, &mint_url, 100.into()).await;
 
     // List all transactions
-    let transactions = multi_mint_wallet.list_transactions(None).await.unwrap();
+    let transactions = wallet_repository.list_transactions(None).await.unwrap();
     assert!(
         !transactions.is_empty(),
         "Should have at least one transaction after minting"
     );
 
+    // Get wallet for melt operations
+    let wallet = wallet_repository
+        .get_wallet(&mint_url, &CurrencyUnit::Sat)
+        .await
+        .unwrap();
+
     // Create an invoice and melt (this creates a melt transaction)
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
-    let melt_quote = multi_mint_wallet
-        .melt_quote(&mint_url, PaymentMethod::BOLT11, invoice, None, None)
+    let melt_quote = wallet
+        .melt_quote(
+            PaymentMethod::Known(KnownMethod::Bolt11),
+            invoice.to_string(),
+            None,
+            None,
+        )
         .await
         .unwrap();
-    multi_mint_wallet
-        .melt_with_mint(&mint_url, &melt_quote.id)
+    wallet
+        .prepare_melt(&melt_quote.id, std::collections::HashMap::new())
+        .await
+        .unwrap()
+        .confirm()
         .await
         .unwrap();
 
     // List transactions again
-    let transactions_after = multi_mint_wallet.list_transactions(None).await.unwrap();
+    let transactions_after = wallet_repository.list_transactions(None).await.unwrap();
     assert!(
         transactions_after.len() > transactions.len(),
         "Should have more transactions after melt"
     );
 }
-
-/// Test send revocation via MultiMintWallet
-///
-/// This test verifies:
-/// 1. Create and confirm a send
-/// 2. Verify it appears in pending sends
-/// 3. Verify status is "not claimed"
-/// 4. Revoke the send
-/// 5. Verify balance is restored and pending send is gone
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multi_mint_wallet_revoke_send() {
-    let multi_mint_wallet = create_test_multi_mint_wallet().await;
-
-    let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
-    multi_mint_wallet
-        .add_mint(mint_url.clone())
-        .await
-        .expect("failed to add mint");
-
-    // Fund the wallet
-    fund_multi_mint_wallet(&multi_mint_wallet, &mint_url, 100.into()).await;
-
-    // Create a send
-    let send_options = SendOptions::default();
-    let prepared_send = multi_mint_wallet
-        .prepare_send(mint_url.clone(), 50.into(), send_options)
-        .await
-        .unwrap();
-
-    let operation_id = prepared_send.operation_id();
-    let _token = prepared_send.confirm(None).await.unwrap();
-
-    // Verify it appears in pending sends
-    let pending = multi_mint_wallet.get_pending_sends().await.unwrap();
-    assert_eq!(pending.len(), 1, "Should have 1 pending send");
-    assert_eq!(pending[0].0, mint_url, "Mint URL should match");
-    assert_eq!(pending[0].1, operation_id, "Operation ID should match");
-
-    // Verify status
-    let claimed = multi_mint_wallet
-        .check_send_status(mint_url.clone(), operation_id)
-        .await
-        .unwrap();
-    assert!(!claimed, "Token should not be claimed yet");
-
-    // Revoke the send
-    let restored_amount = multi_mint_wallet
-        .revoke_send(mint_url.clone(), operation_id)
-        .await
-        .unwrap();
-
-    assert_eq!(restored_amount, 50.into(), "Should restore 50 sats");
-
-    // Verify pending send is gone
-    let pending_after = multi_mint_wallet.get_pending_sends().await.unwrap();
-    assert!(pending_after.is_empty(), "Should have no pending sends");
-
-    // Verify balance is back to 100
-    let balance = multi_mint_wallet.total_balance().await.unwrap();
-    assert_eq!(balance, 100.into(), "Balance should be fully restored");
-}

+ 1 - 0
crates/cdk-ldk-node/Cargo.toml

@@ -29,6 +29,7 @@ tower-http.workspace = true
 rust-embed = "8.5.0"
 serde_urlencoded = "0.7"
 urlencoding = "2.1"
+bip39.workspace = true
 
 [lints]
 workspace = true

+ 87 - 36
crates/cdk-ldk-node/src/lib.rs

@@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 
 use async_trait::async_trait;
+use bip39::Mnemonic;
 use cdk_common::common::FeeReserve;
 use cdk_common::payment::{self, *};
 use cdk_common::util::{hex, unix_time};
@@ -20,6 +21,7 @@ use ldk_node::lightning::ln::msgs::SocketAddress;
 use ldk_node::lightning::routing::router::RouteParametersConfig;
 use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description};
 use ldk_node::lightning_types::payment::PaymentHash;
+use ldk_node::logger::{LogLevel, LogWriter};
 use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus};
 use ldk_node::{Builder, Event, Node};
 use tokio_stream::wrappers::BroadcastStream;
@@ -27,8 +29,10 @@ use tokio_util::sync::CancellationToken;
 use tracing::instrument;
 
 use crate::error::Error;
+use crate::log::StdoutLogWriter;
 
 mod error;
+mod log;
 mod web;
 
 /// CDK Lightning backend using LDK Node
@@ -102,40 +106,72 @@ pub enum GossipSource {
     /// Contains the URL of the RGS server for compressed gossip data
     RapidGossipSync(String),
 }
+/// A builder for an [`CdkLdkNode`] instance.
+#[derive(Debug)]
+pub struct CdkLdkNodeBuilder {
+    network: Network,
+    chain_source: ChainSource,
+    gossip_source: GossipSource,
+    log_dir_path: Option<String>,
+    storage_dir_path: String,
+    fee_reserve: FeeReserve,
+    listening_addresses: Vec<SocketAddress>,
+    seed: Option<Mnemonic>,
+    announcement_addresses: Option<Vec<SocketAddress>>,
+}
 
-impl CdkLdkNode {
-    /// Create a new CDK LDK Node instance
-    ///
-    /// # Arguments
-    /// * `network` - Bitcoin network (mainnet, testnet, regtest, signet)
-    /// * `chain_source` - Source of blockchain data (Esplora or Bitcoin RPC)
-    /// * `gossip_source` - Source of Lightning network gossip data
-    /// * `storage_dir_path` - Directory path for node data storage
-    /// * `fee_reserve` - Fee reserve configuration for payments
-    /// * `listening_address` - Socket addresses for peer connections
-    /// * `runtime` - Optional Tokio runtime to use for starting the node
-    ///
-    /// # Returns
-    /// A new `CdkLdkNode` instance ready to be started
-    ///
-    /// # Errors
-    /// Returns an error if the LDK node builder fails to create the node
+impl CdkLdkNodeBuilder {
+    /// Creates a new builder instance.
     pub fn new(
         network: Network,
         chain_source: ChainSource,
         gossip_source: GossipSource,
         storage_dir_path: String,
         fee_reserve: FeeReserve,
-        listening_address: Vec<SocketAddress>,
-    ) -> Result<Self, Error> {
-        let mut builder = Builder::new();
-        builder.set_network(network);
-        tracing::info!("Storage dir of node is {}", storage_dir_path);
-        builder.set_storage_dir_path(storage_dir_path);
-
-        match chain_source {
+        listening_addresses: Vec<SocketAddress>,
+    ) -> Self {
+        Self {
+            network,
+            chain_source,
+            gossip_source,
+            storage_dir_path,
+            fee_reserve,
+            listening_addresses,
+            seed: None,
+            announcement_addresses: None,
+            log_dir_path: None,
+        }
+    }
+
+    /// Configures the [`CdkLdkNode`] to use the Mnemonic for entropy source configuration
+    pub fn with_seed(mut self, seed: Mnemonic) -> Self {
+        self.seed = Some(seed);
+        self
+    }
+    /// Configures the [`CdkLdkNode`] to use announce this address to the lightning network
+    pub fn with_announcement_address(mut self, announcement_addresses: Vec<SocketAddress>) -> Self {
+        self.announcement_addresses = Some(announcement_addresses);
+        self
+    }
+    /// Configures the [`CdkLdkNode`] to use announce this address to the lightning network
+    pub fn with_log_dir_path(mut self, log_dir_path: String) -> Self {
+        self.log_dir_path = Some(log_dir_path);
+        self
+    }
+
+    /// Builds the [`CdkLdkNode`] instance
+    ///
+    /// # Errors
+    /// Returns an error if the LDK node builder fails to create the node
+    pub fn build(self) -> Result<CdkLdkNode, Error> {
+        let mut ldk = Builder::new();
+        ldk.set_network(self.network);
+        tracing::info!("Storage dir of node is {}", self.storage_dir_path);
+        ldk.set_storage_dir_path(self.storage_dir_path);
+
+        match self.chain_source {
             ChainSource::Esplora(esplora_url) => {
-                builder.set_chain_source_esplora(esplora_url, None);
+                ldk.set_chain_source_esplora(esplora_url, None);
             }
             ChainSource::BitcoinRpc(BitcoinRpcConfig {
                 host,
@@ -143,24 +179,37 @@ impl CdkLdkNode {
                 user,
                 password,
             }) => {
-                builder.set_chain_source_bitcoind_rpc(host, port, user, password);
+                ldk.set_chain_source_bitcoind_rpc(host, port, user, password);
             }
         }
 
-        match gossip_source {
+        match self.gossip_source {
             GossipSource::P2P => {
-                builder.set_gossip_source_p2p();
+                ldk.set_gossip_source_p2p();
             }
             GossipSource::RapidGossipSync(rgs_url) => {
-                builder.set_gossip_source_rgs(rgs_url);
+                ldk.set_gossip_source_rgs(rgs_url);
             }
         }
 
-        builder.set_listening_addresses(listening_address)?;
+        ldk.set_listening_addresses(self.listening_addresses)?;
+        if self.log_dir_path.is_some() {
+            ldk.set_filesystem_logger(self.log_dir_path, Some(LogLevel::Info));
+        } else {
+            ldk.set_custom_logger(Arc::new(StdoutLogWriter));
+        }
 
-        builder.set_node_alias("cdk-ldk-node".to_string())?;
+        ldk.set_node_alias("cdk-ldk-node".to_string())?;
+        // set the seed as bip39 entropy mnemonic
+        if let Some(seed) = self.seed {
+            ldk.set_entropy_bip39_mnemonic(seed, None);
+        }
+        // set the announcement addresses
+        if let Some(announcement_addresses) = self.announcement_addresses {
+            ldk.set_announcement_addresses(announcement_addresses)?;
+        }
 
-        let node = builder.build()?;
+        let node = ldk.build()?;
 
         tracing::info!("Creating tokio channel for payment notifications");
         let (sender, receiver) = tokio::sync::broadcast::channel(8);
@@ -173,12 +222,12 @@ impl CdkLdkNode {
             "Created node {} with address {:?} on network {}",
             id,
             adr,
-            network
+            self.network
         );
 
-        Ok(Self {
+        Ok(CdkLdkNode {
             inner: node.into(),
-            fee_reserve,
+            fee_reserve: self.fee_reserve,
             wait_invoice_cancel_token: CancellationToken::new(),
             wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
             sender,
@@ -187,7 +236,9 @@ impl CdkLdkNode {
             web_addr: None,
         })
     }
+}
 
+impl CdkLdkNode {
     /// Set the web server address for the LDK node management interface
     ///
     /// # Arguments

+ 65 - 0
crates/cdk-ldk-node/src/log.rs

@@ -0,0 +1,65 @@
+//! A logger implementation that writes log messages to stdout. This struct implements the
+//! `LogWriter` trait, which defines a way to handle log records. The log records are formatted,
+//! assigned a severity level, and emitted as structured tracing events.
+pub struct StdoutLogWriter;
+impl crate::LogWriter for StdoutLogWriter {
+    /// Logs a given `LogRecord` instance using structured tracing events.
+    fn log(&self, record: ldk_node::logger::LogRecord) {
+        let level = match record.level.to_string().to_ascii_lowercase().as_str() {
+            "error" => tracing::Level::ERROR,
+            "warn" | "warning" => tracing::Level::WARN,
+            "debug" => tracing::Level::DEBUG,
+            "trace" => tracing::Level::TRACE,
+            _ => tracing::Level::INFO,
+        };
+
+        // Format message once
+        let msg = record.args.to_string();
+        // Emit as a structured tracing event.
+        // Use level-specific macros (require compile-time level) and record the original module path as a field.
+        match level {
+            tracing::Level::ERROR => {
+                tracing::error!(
+                    module_path = record.module_path,
+                    line = record.line,
+                    "{msg}"
+                );
+            }
+            tracing::Level::WARN => {
+                tracing::warn!(
+                    module_path = record.module_path,
+                    line = record.line,
+                    "{msg}"
+                );
+            }
+            tracing::Level::INFO => {
+                tracing::info!(
+                    module_path = record.module_path,
+                    line = record.line,
+                    "{msg}"
+                );
+            }
+            tracing::Level::DEBUG => {
+                tracing::debug!(
+                    module_path = record.module_path,
+                    line = record.line,
+                    "{msg}"
+                );
+            }
+            tracing::Level::TRACE => {
+                tracing::trace!(
+                    module_path = record.module_path,
+                    line = record.line,
+                    "{msg}"
+                );
+            }
+        }
+    }
+}
+
+impl Default for StdoutLogWriter {
+    /// Provides the default implementation for the struct.
+    fn default() -> Self {
+        Self {}
+    }
+}

+ 1 - 1
crates/cdk-ldk-node/src/web/handlers/utils.rs

@@ -75,7 +75,7 @@ pub fn get_paginated_payments_streaming(
         .collect();
 
     // Sort by timestamp (newest first)
-    time_indexed.sort_unstable_by(|a, b| b.0.cmp(&a.0));
+    time_indexed.sort_unstable_by_key(|b| std::cmp::Reverse(b.0));
 
     let total_count = time_indexed.len();
 

+ 3 - 0
crates/cdk-lnd/src/error.rs

@@ -6,6 +6,9 @@ use tonic::Status;
 /// LND Error
 #[derive(Debug, Error)]
 pub enum Error {
+    /// Amount Error
+    #[error(transparent)]
+    Amount(#[from] cdk_common::amount::Error),
     /// Invoice amount not defined
     #[error("Unknown invoice amount")]
     UnknownInvoiceAmount,

+ 19 - 9
crates/cdk-lnd/src/lib.rs

@@ -398,7 +398,7 @@ impl MintPayment for Lnd {
     #[instrument(skip_all)]
     async fn make_payment(
         &self,
-        _unit: &CurrencyUnit,
+        unit: &CurrencyUnit,
         options: OutgoingPaymentOptions,
     ) -> Result<MakePaymentResponse, Self::Err> {
         match options {
@@ -446,10 +446,15 @@ impl MintPayment for Lnd {
                                 let route_req = lnrpc::QueryRoutesRequest {
                                     pub_key: hex::encode(pub_key.serialize()),
                                     amt_msat: u64::from(partial_amount_msat) as i64,
-                                    fee_limit: max_fee.map(|f| {
-                                        let limit = Limit::Fixed(u64::from(f) as i64);
-                                        FeeLimit { limit: Some(limit) }
-                                    }),
+                                    fee_limit: max_fee
+                                        .map(|f| {
+                                            let fee_msat = Amount::new(f.into(), unit.clone())
+                                                .convert_to(&CurrencyUnit::Msat)?
+                                                .value();
+                                            let limit = Limit::FixedMsat(fee_msat as i64);
+                                            Ok::<_, Error>(FeeLimit { limit: Some(limit) })
+                                        })
+                                        .transpose()?,
                                     use_mission_control: true,
                                     ..Default::default()
                                 };
@@ -542,10 +547,15 @@ impl MintPayment for Lnd {
 
                         let pay_req = lnrpc::SendRequest {
                             payment_request: bolt11.to_string(),
-                            fee_limit: max_fee.map(|f| {
-                                let limit = Limit::Fixed(u64::from(f) as i64);
-                                FeeLimit { limit: Some(limit) }
-                            }),
+                            fee_limit: max_fee
+                                .map(|f| {
+                                    let fee_msat = Amount::new(f.into(), unit.clone())
+                                        .convert_to(&CurrencyUnit::Msat)?
+                                        .value();
+                                    let limit = Limit::FixedMsat(fee_msat as i64);
+                                    Ok::<_, Error>(FeeLimit { limit: Some(limit) })
+                                })
+                                .transpose()?,
                             amt_msat: amount_msat as i64,
                             ..Default::default()
                         };

+ 1 - 1
crates/cdk-mint-rpc/Cargo.toml

@@ -23,7 +23,7 @@ anyhow.workspace = true
 cdk = { workspace = true, features = [
     "mint",
 ] }
-cdk-common.workspace = true
+cdk-common = { workspace = true, features = ["grpc"] }
 clap.workspace = true
 tonic = { workspace = true, features = ["transport", "tls-ring", "codegen", "router"] }
 tracing.workspace = true

+ 15 - 1
crates/cdk-mint-rpc/src/bin/mint_rpc_cli.rs

@@ -3,12 +3,23 @@
 use std::path::PathBuf;
 
 use anyhow::{anyhow, Result};
+use cdk_common::grpc::VERSION_HEADER;
 use cdk_mint_rpc::cdk_mint_client::CdkMintClient;
 use cdk_mint_rpc::mint_rpc_cli::subcommands;
 use cdk_mint_rpc::GetInfoRequest;
 use clap::{Parser, Subcommand};
+use tonic::metadata::MetadataValue;
 use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
 use tonic::Request;
+
+/// Helper function to add version header to a request
+fn with_version_header<T>(mut request: Request<T>) -> Request<T> {
+    request.metadata_mut().insert(
+        VERSION_HEADER,
+        MetadataValue::from_static(cdk_common::MINT_RPC_PROTOCOL_VERSION),
+    );
+    request
+}
 use tracing_subscriber::EnvFilter;
 
 /// Common CLI arguments for CDK binaries
@@ -150,11 +161,14 @@ async fn main() -> Result<()> {
             .await?
     };
 
+    // Create client
     let mut client = CdkMintClient::new(channel);
 
     match cli.command {
         Commands::GetInfo => {
-            let response = client.get_info(Request::new(GetInfoRequest {})).await?;
+            let response = client
+                .get_info(with_version_header(Request::new(GetInfoRequest {})))
+                .await?;
             let info = response.into_inner();
             println!(
                 "name:             {}",

+ 3 - 0
crates/cdk-mint-rpc/src/lib.rs

@@ -5,3 +5,6 @@ pub mod proto;
 pub mod mint_rpc_cli;
 
 pub use proto::*;
+
+/// Type alias for the CdkMintClient that works with any tower service
+pub type CdkMintClient<S> = cdk_mint_client::CdkMintClient<S>;

+ 15 - 0
crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/mod.rs

@@ -1,3 +1,18 @@
+//! Subcommands for the mint RPC CLI
+
+use cdk_common::grpc::VERSION_HEADER;
+use tonic::metadata::MetadataValue;
+use tonic::Request;
+
+/// Helper function to add version header to a request
+pub fn with_version_header<T>(mut request: Request<T>) -> Request<T> {
+    request.metadata_mut().insert(
+        VERSION_HEADER,
+        MetadataValue::from_static(cdk_common::MINT_RPC_PROTOCOL_VERSION),
+    );
+    request
+}
+
 /// Module for rotating to the next keyset
 mod rotate_next_keyset;
 /// Module for updating mint contact information

+ 10 - 8
crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/update_nut04.rs

@@ -52,14 +52,16 @@ pub async fn update_nut04(
         .map(|description| MintMethodOptions { description });
 
     let _response = client
-        .update_nut04(Request::new(UpdateNut04Request {
-            method: sub_command_args.method.clone(),
-            unit: sub_command_args.unit.clone(),
-            disabled: sub_command_args.disabled,
-            min_amount: sub_command_args.min_amount,
-            max_amount: sub_command_args.max_amount,
-            options,
-        }))
+        .update_nut04(crate::mint_rpc_cli::subcommands::with_version_header(
+            Request::new(UpdateNut04Request {
+                method: sub_command_args.method.clone(),
+                unit: sub_command_args.unit.clone(),
+                disabled: sub_command_args.disabled,
+                min_amount: sub_command_args.min_amount,
+                max_amount: sub_command_args.max_amount,
+                options,
+            }),
+        ))
         .await?;
 
     Ok(())

+ 1 - 1
crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto

@@ -1,6 +1,6 @@
 syntax = "proto3";
 
-package cdk_mint_rpc;
+package cdk_mint_management_v1;
 
 service CdkMint {
     rpc GetInfo(GetInfoRequest) returns (GetInfoResponse) {}

+ 3 - 1
crates/cdk-mint-rpc/src/proto/mod.rs

@@ -1,7 +1,9 @@
 //! CDK mint proto types
 
-tonic::include_proto!("cdk_mint_rpc");
+tonic::include_proto!("cdk_mint_management_v1");
 
 mod server;
 
+/// Protocol version for gRPC Mint RPC communication
+pub use cdk_common::MINT_RPC_PROTOCOL_VERSION as PROTOCOL_VERSION;
 pub use server::MintRPCServer;

+ 15 - 6
crates/cdk-mint-rpc/src/proto/server.rs

@@ -9,6 +9,7 @@ use cdk::nuts::nut05::MeltMethodSettings;
 use cdk::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod};
 use cdk::types::QuoteTTL;
 use cdk::Amount;
+use cdk_common::grpc::create_version_check_interceptor;
 use cdk_common::payment::WaitPaymentResponse;
 use thiserror::Error;
 use tokio::sync::Notify;
@@ -135,13 +136,19 @@ impl MintRPCServer {
                     .identity(server_identity)
                     .client_ca_root(client_ca_cert);
 
-                Server::builder()
-                    .tls_config(tls_config)?
-                    .add_service(CdkMintServer::new(self.clone()))
+                Server::builder().tls_config(tls_config)?.add_service(
+                    CdkMintServer::with_interceptor(
+                        self.clone(),
+                        create_version_check_interceptor(cdk_common::MINT_RPC_PROTOCOL_VERSION),
+                    ),
+                )
             }
             None => {
                 tracing::warn!("No valid TLS configuration found, starting insecure server");
-                Server::builder().add_service(CdkMintServer::new(self.clone()))
+                Server::builder().add_service(CdkMintServer::with_interceptor(
+                    self.clone(),
+                    create_version_check_interceptor(cdk_common::MINT_RPC_PROTOCOL_VERSION),
+                ))
             }
         };
 
@@ -223,7 +230,7 @@ impl CdkMint for MintRPCServer {
             })
             .collect();
 
-        Ok(Response::new(GetInfoResponse {
+        let response = Response::new(GetInfoResponse {
             name: info.name,
             description: info.description,
             long_description: info.description_long,
@@ -234,7 +241,9 @@ impl CdkMint for MintRPCServer {
             urls: info.urls.unwrap_or_default(),
             total_issued: total_issued.into(),
             total_redeemed: total_redeemed.into(),
-        }))
+        });
+
+        Ok(response)
     }
 
     /// Updates the mint's message of the day

+ 10 - 0
crates/cdk-mintd/example.config.toml

@@ -127,11 +127,20 @@ ln_backend = "fakewallet"
 # bitcoin_network = "signet"  # mainnet, testnet, signet, regtest
 # chain_source_type = "esplora"  # esplora, bitcoinrpc  
 # 
+# # IMPORTANT: LDK Node Seed Configuration
+# # For NEW nodes: ldk_node_mnemonic MUST be set. This is the BIP39 mnemonic used to derive
+# # the LDK node's keys. Keep this secure and backed up!
+# # For EXISTING nodes: If omitted, the node will use its stored seed from the storage directory.
+# # This maintains backward compatibility with nodes created before this configuration was added.
+# ldk_node_mnemonic = "your twelve or twenty-four word mnemonic phrase here"
+# 
 # # Mutinynet configuration (recommended for testing)
 # esplora_url = "https://mutinynet.com/api"
 # gossip_source_type = "rgs"  # Use RGS for better performance
 # rgs_url = "https://rgs.mutinynet.com/snapshot/0"
 # storage_dir_path = "~/.cdk-ldk-node/mutinynet"
+# log_dir_path = ".cdk-ldk-node/ldk-node/ldk_node.log"
+
 # 
 # # Testnet configuration
 # # bitcoin_network = "testnet"
@@ -154,6 +163,7 @@ ln_backend = "fakewallet"
 # # Node configuration
 # ldk_node_host = "127.0.0.1"
 # ldk_node_port = 8090
+
 # 
 # # Gossip source configuration  
 # gossip_source_type = "p2p"  # p2p (direct peer-to-peer) or rgs (rapid gossip sync)

+ 55 - 2
crates/cdk-mintd/src/config.rs

@@ -190,7 +190,7 @@ impl Default for Ln {
 }
 
 #[cfg(feature = "lnbits")]
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Clone, Serialize, Deserialize)]
 pub struct LNbits {
     pub admin_api_key: String,
     pub invoice_api_key: String,
@@ -202,6 +202,19 @@ pub struct LNbits {
 }
 
 #[cfg(feature = "lnbits")]
+impl std::fmt::Debug for LNbits {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("LNbits")
+            .field("admin_api_key", &"[REDACTED]")
+            .field("invoice_api_key", &"[REDACTED]")
+            .field("lnbits_api", &self.lnbits_api)
+            .field("fee_percent", &self.fee_percent)
+            .field("reserve_fee_min", &self.reserve_fee_min)
+            .finish()
+    }
+}
+
+#[cfg(feature = "lnbits")]
 impl Default for LNbits {
     fn default() -> Self {
         Self {
@@ -269,7 +282,7 @@ impl Default for Lnd {
 }
 
 #[cfg(feature = "ldk-node")]
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Clone, Serialize, Deserialize)]
 pub struct LdkNode {
     /// Fee percentage (e.g., 0.02 for 2%)
     #[serde(default = "default_ldk_fee_percent")]
@@ -290,10 +303,14 @@ pub struct LdkNode {
     pub bitcoind_rpc_password: Option<String>,
     /// Storage directory path
     pub storage_dir_path: Option<String>,
+    /// Log directory path (logging stdout if omitted)
+    pub log_dir_path: Option<String>,
     /// LDK node listening host
     pub ldk_node_host: Option<String>,
     /// LDK node listening port
     pub ldk_node_port: Option<u16>,
+    /// LDK node announcement addresses
+    pub ldk_node_announce_addresses: Option<Vec<String>>,
     /// Gossip source type (p2p or rgs)
     pub gossip_source_type: Option<String>,
     /// Rapid Gossip Sync URL (when gossip_source_type = "rgs")
@@ -304,6 +321,9 @@ pub struct LdkNode {
     /// Webserver port
     #[serde(default = "default_webserver_port")]
     pub webserver_port: Option<u16>,
+    /// LDK node mnemonic
+    /// If not set, LDK node will use its default seed storage mechanism
+    pub ldk_node_mnemonic: Option<String>,
 }
 
 #[cfg(feature = "ldk-node")]
@@ -318,19 +338,52 @@ impl Default for LdkNode {
             bitcoind_rpc_host: None,
             bitcoind_rpc_port: None,
             bitcoind_rpc_user: None,
+            ldk_node_announce_addresses: None,
             bitcoind_rpc_password: None,
             storage_dir_path: None,
             ldk_node_host: None,
+            log_dir_path: None,
             ldk_node_port: None,
             gossip_source_type: None,
             rgs_url: None,
             webserver_host: default_webserver_host(),
             webserver_port: default_webserver_port(),
+            ldk_node_mnemonic: None,
         }
     }
 }
 
 #[cfg(feature = "ldk-node")]
+impl std::fmt::Debug for LdkNode {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("LdkNode")
+            .field("fee_percent", &self.fee_percent)
+            .field("reserve_fee_min", &self.reserve_fee_min)
+            .field("bitcoin_network", &self.bitcoin_network)
+            .field("chain_source_type", &self.chain_source_type)
+            .field("esplora_url", &self.esplora_url)
+            .field("bitcoind_rpc_host", &self.bitcoind_rpc_host)
+            .field("bitcoind_rpc_port", &self.bitcoind_rpc_port)
+            .field("bitcoind_rpc_user", &self.bitcoind_rpc_user)
+            .field("bitcoind_rpc_password", &"[REDACTED]")
+            .field("storage_dir_path", &self.storage_dir_path)
+            .field("log_dir_path", &self.log_dir_path)
+            .field("ldk_node_host", &self.ldk_node_host)
+            .field("ldk_node_port", &self.ldk_node_port)
+            .field(
+                "ldk_node_announce_addresses",
+                &self.ldk_node_announce_addresses,
+            )
+            .field("gossip_source_type", &self.gossip_source_type)
+            .field("rgs_url", &self.rgs_url)
+            .field("webserver_host", &self.webserver_host)
+            .field("webserver_port", &self.webserver_port)
+            .field("ldk_node_mnemonic", &"[REDACTED]")
+            .finish()
+    }
+}
+
+#[cfg(feature = "ldk-node")]
 fn default_ldk_fee_percent() -> f32 {
     0.04
 }

+ 10 - 0
crates/cdk-mintd/src/env_vars/ldk_node.rs

@@ -15,12 +15,14 @@ pub const LDK_NODE_BITCOIND_RPC_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIN
 pub const LDK_NODE_BITCOIND_RPC_USER_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_USER";
 pub const LDK_NODE_BITCOIND_RPC_PASSWORD_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_PASSWORD";
 pub const LDK_NODE_STORAGE_DIR_PATH_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH";
+pub const LDK_NODE_LOG_DIR_PATH_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_LOG_DIR_PATH";
 pub const LDK_NODE_LDK_NODE_HOST_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_LDK_NODE_HOST";
 pub const LDK_NODE_LDK_NODE_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_LDK_NODE_PORT";
 pub const LDK_NODE_GOSSIP_SOURCE_TYPE_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE";
 pub const LDK_NODE_RGS_URL_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_RGS_URL";
 pub const LDK_NODE_WEBSERVER_HOST_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_WEBSERVER_HOST";
 pub const LDK_NODE_WEBSERVER_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_WEBSERVER_PORT";
+pub const LDK_NODE_MNEMONIC_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_MNEMONIC";
 
 impl LdkNode {
     pub fn from_env(mut self) -> Self {
@@ -70,6 +72,10 @@ impl LdkNode {
             self.storage_dir_path = Some(storage_dir_path);
         }
 
+        if let Ok(log_dir_path) = env::var(LDK_NODE_LOG_DIR_PATH_ENV_VAR) {
+            self.log_dir_path = Some(log_dir_path);
+        }
+
         if let Ok(ldk_node_host) = env::var(LDK_NODE_LDK_NODE_HOST_ENV_VAR) {
             self.ldk_node_host = Some(ldk_node_host);
         }
@@ -98,6 +104,10 @@ impl LdkNode {
             }
         }
 
+        if let Ok(ldk_node_mnemonic) = env::var(LDK_NODE_MNEMONIC_ENV_VAR) {
+            self.ldk_node_mnemonic = Some(ldk_node_mnemonic);
+        }
+
         self
     }
 }

+ 7 - 0
crates/cdk-mintd/src/lib.rs

@@ -100,7 +100,9 @@ async fn initial_setup(
     Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync>,
     Arc<dyn KVStore<Err = cdk_database::Error> + Send + Sync>,
 )> {
+    tracing::info!("Initializing database...");
     let (localstore, keystore, kv) = setup_database(settings, work_dir, db_password).await?;
+    tracing::info!("Database initialized successfully");
     Ok((localstore, keystore, kv))
 }
 
@@ -266,6 +268,7 @@ async fn setup_database(
     Arc<dyn MintKeysDatabase<Err = cdk_database::Error> + Send + Sync>,
     Arc<dyn KVStore<Err = cdk_database::Error> + Send + Sync>,
 )> {
+    tracing::info!("Using database engine: {:?}", settings.database.engine);
     match settings.database.engine {
         #[cfg(feature = "sqlite")]
         DatabaseEngine::Sqlite => {
@@ -288,6 +291,7 @@ async fn setup_database(
 
             #[cfg(feature = "postgres")]
             let pg_db = Arc::new(MintPgDatabase::new(pg_config.url.as_str()).await?);
+            tracing::info!("PostgreSQL database connection established");
             #[cfg(feature = "postgres")]
             let localstore: Arc<dyn MintDatabase<cdk_database::Error> + Send + Sync> =
                 pg_db.clone();
@@ -320,6 +324,7 @@ async fn setup_sqlite_database(
     _password: Option<String>,
 ) -> Result<Arc<MintSqliteDatabase>> {
     let sql_db_path = work_dir.join("cdk-mintd.sqlite");
+    tracing::info!("SQLite database path: {}", sql_db_path.display());
 
     #[cfg(not(feature = "sqlcipher"))]
     let db = MintSqliteDatabase::new(&sql_db_path).await?;
@@ -328,9 +333,11 @@ async fn setup_sqlite_database(
         // Get password from command line arguments for sqlcipher
         let password = _password
             .ok_or_else(|| anyhow!("Password required when sqlcipher feature is enabled"))?;
+        tracing::info!("Using SQLCipher encryption for SQLite database");
         MintSqliteDatabase::new((sql_db_path, password)).await?
     };
 
+    tracing::info!("SQLite database initialized successfully");
     Ok(Arc::new(db))
 }
 

+ 63 - 11
crates/cdk-mintd/src/setup.rs

@@ -3,12 +3,10 @@ use std::collections::HashMap;
 #[cfg(feature = "fakewallet")]
 use std::collections::HashSet;
 use std::path::Path;
+#[cfg(feature = "ldk-node")]
+use std::path::PathBuf;
 use std::sync::Arc;
 
-#[cfg(feature = "cln")]
-use anyhow::anyhow;
-#[cfg(any(feature = "lnbits", feature = "lnd"))]
-use anyhow::bail;
 use async_trait::async_trait;
 #[cfg(feature = "fakewallet")]
 use bip39::rand::{thread_rng, Rng};
@@ -53,7 +51,7 @@ impl LnBackendSetup for config::Cln {
     ) -> anyhow::Result<cdk_cln::Cln> {
         // Validate required connection field
         if self.rpc_path.as_os_str().is_empty() {
-            return Err(anyhow!(
+            return Err(anyhow::anyhow!(
                 "CLN rpc_path must be set via config or CDK_MINTD_CLN_RPC_PATH env var"
             ));
         }
@@ -61,9 +59,9 @@ impl LnBackendSetup for config::Cln {
         let cln_socket = expand_path(
             self.rpc_path
                 .to_str()
-                .ok_or(anyhow!("cln socket not defined"))?,
+                .ok_or(anyhow::anyhow!("cln socket not defined"))?,
         )
-        .ok_or(anyhow!("cln socket not defined"))?;
+        .ok_or(anyhow::anyhow!("cln socket not defined"))?;
 
         let fee_reserve = FeeReserve {
             min_fee_reserve: self.reserve_fee_min,
@@ -92,6 +90,8 @@ impl LnBackendSetup for config::LNbits {
         _work_dir: &Path,
         _kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_lnbits::LNbits> {
+        use anyhow::bail;
+
         // Validate required connection fields
         if self.admin_api_key.is_empty() {
             bail!("LNbits admin_api_key must be set via config or CDK_MINTD_LNBITS_ADMIN_API_KEY env var");
@@ -139,6 +139,7 @@ impl LnBackendSetup for config::Lnd {
         _work_dir: &Path,
         kv_store: Option<Arc<dyn KVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_lnd::Lnd> {
+        use anyhow::bail;
         // Validate required connection fields
         if self.address.is_empty() {
             bail!("LND address must be set via config or CDK_MINTD_LND_ADDRESS env var");
@@ -233,7 +234,7 @@ impl LnBackendSetup for config::GrpcProcessor {
 impl LnBackendSetup for config::LdkNode {
     async fn setup(
         &self,
-        _settings: &Settings,
+        settings: &Settings,
         _unit: CurrencyUnit,
         _runtime: Option<std::sync::Arc<tokio::runtime::Runtime>>,
         work_dir: &Path,
@@ -241,6 +242,8 @@ impl LnBackendSetup for config::LdkNode {
     ) -> anyhow::Result<cdk_ldk_node::CdkLdkNode> {
         use std::net::SocketAddr;
 
+        use anyhow::bail;
+        use bip39::Mnemonic;
         use bitcoin::Network;
 
         let fee_reserve = FeeReserve {
@@ -330,15 +333,61 @@ impl LnBackendSetup for config::LdkNode {
         // For now, let's construct it manually based on the cdk-ldk-node implementation
         let listen_address = vec![socket_addr.into()];
 
-        let mut ldk_node = cdk_ldk_node::CdkLdkNode::new(
+        // Check if ldk_node_mnemonic is provided in the ldk_node config
+        let mnemonic_opt = settings
+            .clone()
+            .ldk_node
+            .as_ref()
+            .and_then(|ldk_config| ldk_config.ldk_node_mnemonic.clone());
+
+        // Only set seed if mnemonic is explicitly provided
+        // This maintains backward compatibility with existing nodes that use LDK's default seed storage
+        let seed = if let Some(mnemonic_str) = mnemonic_opt {
+            Some(
+                mnemonic_str
+                    .parse::<Mnemonic>()
+                    .map_err(|e| anyhow::anyhow!("invalid ldk_node_mnemonic in config: {e}"))?,
+            )
+        } else {
+            // Check if this is a new node or an existing node
+            let storage_dir = PathBuf::from(&storage_dir_path);
+            let keys_seed_file = storage_dir.join("keys_seed");
+
+            if !keys_seed_file.exists() {
+                bail!("ldk_node_mnemonic should be set in the [ldk_node] configuration section.");
+            }
+
+            // Existing node with stored seed, don't set a mnemonic
+            None
+        };
+
+        let ldk_node_settings = settings
+            .ldk_node
+            .as_ref()
+            .ok_or_else(|| anyhow::anyhow!("ldk_node configuration is required"))?;
+        let announce_addrs: Vec<_> = ldk_node_settings
+            .ldk_node_announce_addresses
+            .as_ref()
+            .map(|addrs| addrs.iter().filter_map(|addr| addr.parse().ok()).collect())
+            .unwrap_or_default();
+
+        let mut ldk_node_builder = cdk_ldk_node::CdkLdkNodeBuilder::new(
             network,
             chain_source,
             gossip_source,
             storage_dir_path,
             fee_reserve,
             listen_address,
-        )?;
+        );
 
+        // Only set seed if provided
+        if let Some(mnemonic) = seed {
+            ldk_node_builder = ldk_node_builder.with_seed(mnemonic);
+        }
+
+        if !announce_addrs.is_empty() {
+            ldk_node_builder = ldk_node_builder.with_announcement_address(announce_addrs)
+        }
         // Configure webserver address if specified
         let webserver_addr = if let Some(host) = &self.webserver_host {
             let port = self.webserver_port.unwrap_or(8091);
@@ -358,7 +407,10 @@ impl LnBackendSetup for config::LdkNode {
             "webserver: {}",
             webserver_addr.map_or("none".to_string(), |a| a.to_string())
         );
-
+        if let Some(log_dir_path) = ldk_node_settings.log_dir_path.as_ref() {
+            ldk_node_builder = ldk_node_builder.with_log_dir_path(log_dir_path.clone());
+        }
+        let mut ldk_node = ldk_node_builder.build()?;
         ldk_node.set_web_addr(webserver_addr);
 
         Ok(ldk_node)

+ 2 - 0
crates/cdk-npubcash/Cargo.toml

@@ -1,6 +1,8 @@
 [package]
 name = "cdk-npubcash"
 version.workspace = true
+authors = ["CDK Developers"]
+description = "CDK npubcash sdk"
 edition.workspace = true
 rust-version.workspace = true
 license.workspace = true

+ 1 - 1
crates/cdk-payment-processor/Cargo.toml

@@ -26,7 +26,7 @@ anyhow.workspace = true
 async-trait.workspace = true
 bitcoin.workspace = true
 cashu.workspace = true
-cdk-common = { workspace = true, features = ["mint"] }
+cdk-common = { workspace = true, features = ["mint", "grpc"] }
 cdk-cln = { workspace = true, optional = true }
 cdk-lnd = { workspace = true, optional = true }
 cdk-fake-wallet = { workspace = true, optional = true }

+ 29 - 14
crates/cdk-payment-processor/src/proto/client.rs

@@ -4,6 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 
 use anyhow::anyhow;
+use cdk_common::grpc::VERSION_HEADER;
 use cdk_common::payment::{
     CreateIncomingPaymentResponse, IncomingPaymentOptions as CdkIncomingPaymentOptions,
     MakePaymentResponse as CdkMakePaymentResponse, MintPayment,
@@ -11,6 +12,7 @@ use cdk_common::payment::{
 };
 use futures::{Stream, StreamExt};
 use tokio_util::sync::CancellationToken;
+use tonic::metadata::MetadataValue;
 use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
 use tonic::{async_trait, Request};
 use tracing::instrument;
@@ -21,6 +23,15 @@ use crate::proto::{
     IncomingPaymentOptions, MakePaymentRequest, OutgoingPaymentRequestType, PaymentQuoteRequest,
 };
 
+/// Helper function to add version header to a request
+fn with_version_header<T>(mut request: Request<T>) -> Request<T> {
+    request.metadata_mut().insert(
+        VERSION_HEADER,
+        MetadataValue::from_static(cdk_common::PAYMENT_PROCESSOR_PROTOCOL_VERSION),
+    );
+    request
+}
+
 /// Payment Processor
 #[derive(Clone)]
 pub struct PaymentProcessorClient {
@@ -96,7 +107,7 @@ impl MintPayment for PaymentProcessorClient {
     async fn get_settings(&self) -> Result<cdk_common::payment::SettingsResponse, Self::Err> {
         let mut inner = self.inner.clone();
         let response = inner
-            .get_settings(Request::new(EmptyRequest {}))
+            .get_settings(with_version_header(Request::new(EmptyRequest {})))
             .await
             .map_err(|err| {
                 tracing::error!("Could not get settings: {}", err);
@@ -163,10 +174,10 @@ impl MintPayment for PaymentProcessorClient {
         };
 
         let response = inner
-            .create_payment(Request::new(CreatePaymentRequest {
+            .create_payment(with_version_header(Request::new(CreatePaymentRequest {
                 unit: unit.to_string(),
                 options: Some(proto_options),
-            }))
+            })))
             .await
             .map_err(|err| {
                 tracing::error!("Could not create payment request: {}", err);
@@ -217,13 +228,13 @@ impl MintPayment for PaymentProcessorClient {
         };
 
         let response = inner
-            .get_payment_quote(Request::new(PaymentQuoteRequest {
+            .get_payment_quote(with_version_header(Request::new(PaymentQuoteRequest {
                 request: proto_request,
                 unit: unit.to_string(),
                 options: proto_options.map(Into::into),
                 request_type: request_type.into(),
                 extra_json,
-            }))
+            })))
             .await
             .map_err(|err| {
                 tracing::error!("Could not get payment quote: {}", err);
@@ -282,11 +293,11 @@ impl MintPayment for PaymentProcessorClient {
         };
 
         let response = inner
-            .make_payment(Request::new(MakePaymentRequest {
+            .make_payment(with_version_header(Request::new(MakePaymentRequest {
                 payment_options: Some(payment_options),
                 partial_amount: None,
                 max_fee_amount: None,
-            }))
+            })))
             .await
             .map_err(|err| {
                 tracing::error!("Could not pay payment request: {}", err);
@@ -316,7 +327,7 @@ impl MintPayment for PaymentProcessorClient {
         tracing::debug!("Client waiting for payment");
         let mut inner = self.inner.clone();
         let stream = inner
-            .wait_incoming_payment(EmptyRequest {})
+            .wait_incoming_payment(with_version_header(Request::new(EmptyRequest {})))
             .await
             .map_err(|err| {
                 tracing::error!("Could not check incoming payment stream: {}", err);
@@ -372,9 +383,11 @@ impl MintPayment for PaymentProcessorClient {
     ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
         let mut inner = self.inner.clone();
         let response = inner
-            .check_incoming_payment(Request::new(CheckIncomingPaymentRequest {
-                request_identifier: Some(payment_identifier.clone().into()),
-            }))
+            .check_incoming_payment(with_version_header(Request::new(
+                CheckIncomingPaymentRequest {
+                    request_identifier: Some(payment_identifier.clone().into()),
+                },
+            )))
             .await
             .map_err(|err| {
                 tracing::error!("Could not check incoming payment: {}", err);
@@ -395,9 +408,11 @@ impl MintPayment for PaymentProcessorClient {
     ) -> Result<CdkMakePaymentResponse, Self::Err> {
         let mut inner = self.inner.clone();
         let response = inner
-            .check_outgoing_payment(Request::new(CheckOutgoingPaymentRequest {
-                request_identifier: Some(payment_identifier.clone().into()),
-            }))
+            .check_outgoing_payment(with_version_header(Request::new(
+                CheckOutgoingPaymentRequest {
+                    request_identifier: Some(payment_identifier.clone().into()),
+                },
+            )))
             .await
             .map_err(|err| {
                 tracing::error!("Could not check outgoing payment: {}", err);

+ 15 - 4
crates/cdk-payment-processor/src/proto/server.rs

@@ -5,6 +5,7 @@ use std::str::FromStr;
 use std::sync::Arc;
 use std::time::Duration;
 
+use cdk_common::grpc::create_version_check_interceptor;
 use cdk_common::payment::{IncomingPaymentOptions, MintPayment};
 use cdk_common::CurrencyUnit;
 use futures::{Stream, StreamExt};
@@ -103,13 +104,23 @@ impl PaymentProcessorServer {
                     .identity(server_identity)
                     .client_ca_root(client_ca_cert);
 
-                Server::builder()
-                    .tls_config(tls_config)?
-                    .add_service(CdkPaymentProcessorServer::new(self.clone()))
+                Server::builder().tls_config(tls_config)?.add_service(
+                    CdkPaymentProcessorServer::with_interceptor(
+                        self.clone(),
+                        create_version_check_interceptor(
+                            cdk_common::PAYMENT_PROCESSOR_PROTOCOL_VERSION,
+                        ),
+                    ),
+                )
             }
             None => {
                 tracing::warn!("No valid TLS configuration found, starting insecure server");
-                Server::builder().add_service(CdkPaymentProcessorServer::new(self.clone()))
+                Server::builder().add_service(CdkPaymentProcessorServer::with_interceptor(
+                    self.clone(),
+                    create_version_check_interceptor(
+                        cdk_common::PAYMENT_PROCESSOR_PROTOCOL_VERSION,
+                    ),
+                ))
             }
         };
 

+ 1 - 1
crates/cdk-postgres/Cargo.toml

@@ -30,7 +30,7 @@ uuid.workspace = true
 tokio-postgres = "0.7.13"
 futures-util = "0.3.31"
 postgres-native-tls = "0.5.1"
-native-tls = "0.2"
+native-tls = "=0.2.14"
 once_cell.workspace = true
 paste = "1.0.15"
 

+ 1 - 0
crates/cdk-signatory/Cargo.toml

@@ -20,6 +20,7 @@ async-trait.workspace = true
 bitcoin.workspace = true
 cdk-common = { workspace = true, default-features = false, features = [
     "mint",
+    "grpc",
 ] }
 tonic = { workspace = true, optional = true, features = ["transport", "tls-ring", "codegen", "router"] }
 tonic-prost = { workspace = true, optional = true }

+ 57 - 51
crates/cdk-signatory/src/bin/cli/mod.rs

@@ -1,23 +1,26 @@
 //! Signatory CLI main logic
 //!
 //! This logic is in this file to be excluded for wasm
-use std::collections::HashMap;
-use std::net::SocketAddr;
 use std::path::PathBuf;
-use std::str::FromStr;
-use std::sync::Arc;
-use std::{env, fs};
-
-use anyhow::{anyhow, bail, Result};
-use bip39::rand::{thread_rng, Rng};
-use bip39::Mnemonic;
-use cdk_common::database::MintKeysDatabase;
-use cdk_common::CurrencyUnit;
-use cdk_signatory::{db_signatory, start_grpc_server};
-#[cfg(feature = "sqlite")]
-use cdk_sqlite::MintSqliteDatabase;
+
+use anyhow::{bail, Result};
 use clap::Parser;
-use tracing_subscriber::EnvFilter;
+#[cfg(feature = "sqlite")]
+use {
+    anyhow::anyhow,
+    bip39::rand::{thread_rng, Rng},
+    bip39::Mnemonic,
+    cdk_common::database::MintKeysDatabase,
+    cdk_common::CurrencyUnit,
+    cdk_signatory::{db_signatory, start_grpc_server},
+    cdk_sqlite::MintSqliteDatabase,
+    std::collections::HashMap,
+    std::net::SocketAddr,
+    std::str::FromStr,
+    std::sync::Arc,
+    std::{env, fs},
+    tracing_subscriber::EnvFilter,
+};
 
 /// Common CLI arguments for CDK binaries
 #[derive(Parser, Debug)]
@@ -32,6 +35,7 @@ pub struct CommonArgs {
 }
 
 /// Initialize logging based on CLI arguments
+#[cfg(feature = "sqlite")]
 pub fn init_logging(enable_logging: bool, log_level: tracing::Level) {
     if enable_logging {
         let default_filter = log_level.to_string();
@@ -55,7 +59,9 @@ pub fn init_logging(enable_logging: bool, log_level: tracing::Level) {
     }
 }
 
+#[cfg(feature = "sqlite")]
 const DEFAULT_WORK_DIR: &str = ".cdk-signatory";
+#[cfg(feature = "sqlite")]
 const ENV_MNEMONIC: &str = "CDK_MINTD_MNEMONIC";
 
 /// Simple CLI application to interact with cashu
@@ -89,12 +95,48 @@ struct Cli {
 }
 
 /// Main function for the signatory standalone binary
+#[cfg(not(feature = "sqlite"))]
+pub async fn cli_main() -> Result<()> {
+    bail!("No database feature enabled. Enable the 'sqlite' feature to use this binary.");
+}
+
+/// Main function for the signatory standalone binary
+#[cfg(feature = "sqlite")]
 pub async fn cli_main() -> Result<()> {
     let args: Cli = Cli::parse();
 
     // Initialize logging based on CLI arguments
     init_logging(args.common.enable_logging, args.common.log_level);
 
+    let work_dir = match &args.work_dir {
+        Some(work_dir) => work_dir.clone(),
+        None => {
+            let home_dir = home::home_dir().ok_or(anyhow!("Unknown how"))?;
+            home_dir.join(DEFAULT_WORK_DIR)
+        }
+    };
+
+    fs::create_dir_all(&work_dir)?;
+
+    let localstore: Arc<dyn MintKeysDatabase<Err = cdk_common::database::Error> + Send + Sync> =
+        match args.engine.as_str() {
+            "sqlite" => {
+                let sql_path = work_dir.join("cdk-cli.sqlite");
+                #[cfg(not(feature = "sqlcipher"))]
+                let db = MintSqliteDatabase::new(&sql_path).await?;
+                #[cfg(feature = "sqlcipher")]
+                let db = {
+                    match args.password {
+                        Some(pass) => MintSqliteDatabase::new((sql_path.clone(), pass)).await?,
+                        None => bail!("Missing database password"),
+                    }
+                };
+
+                Arc::new(db)
+            }
+            _ => bail!("Unknown DB engine"),
+        };
+
     let supported_units = args
         .units
         .into_iter()
@@ -112,48 +154,12 @@ pub async fn cli_main() -> Result<()> {
         })
         .collect::<Result<HashMap<_, _>, _>>()?;
 
-    let work_dir = match &args.work_dir {
-        Some(work_dir) => work_dir.clone(),
-        None => {
-            let home_dir = home::home_dir().ok_or(anyhow!("Unknown how"))?;
-            home_dir.join(DEFAULT_WORK_DIR)
-        }
-    };
-
     let certs = Some(
         args.certs
             .map(|x| x.into())
             .unwrap_or_else(|| work_dir.clone()),
     );
 
-    fs::create_dir_all(&work_dir)?;
-
-    let localstore: Arc<dyn MintKeysDatabase<Err = cdk_common::database::Error> + Send + Sync> =
-        match args.engine.as_str() {
-            "sqlite" => {
-                #[cfg(feature = "sqlite")]
-                {
-                    let sql_path = work_dir.join("cdk-cli.sqlite");
-                    #[cfg(not(feature = "sqlcipher"))]
-                    let db = MintSqliteDatabase::new(&sql_path).await?;
-                    #[cfg(feature = "sqlcipher")]
-                    let db = {
-                        match args.password {
-                            Some(pass) => MintSqliteDatabase::new((sql_path.clone(), pass)).await?,
-                            None => bail!("Missing database password"),
-                        }
-                    };
-
-                    Arc::new(db)
-                }
-                #[cfg(not(feature = "sqlite"))]
-                {
-                    bail!("sqlite feature not enabled");
-                }
-            }
-            _ => bail!("Unknown DB engine"),
-        };
-
     let seed_path = work_dir.join("seed");
 
     let mnemonic = if let Ok(mnemonic) = env::var(ENV_MNEMONIC) {

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.