1
0

80 Commits 15e10c0e90 ... 5a6b28816a

Autor SHA1 Nachricht Datum
  C 5a6b28816a Migrate from `sqlx` to rusqlite (#783) vor 2 Wochen
  thesimplekid a335b269b7 Update ln-bits to support v1 api (#802) vor 2 Wochen
  thesimplekid edae2abaf2 Merge pull request #807 from crodas/feature/rwlock-instead-of-mutex vor 2 Wochen
  Cesar Rodas 2e2ce0f621 Use `RwLock` instead of `Mutex`. vor 2 Wochen
  thesimplekid 319133a088 Merge pull request #805 from thesimplekid/docker_publish vor 3 Wochen
  thesimplekid 3b87fff218 chore: changelog and remove flake box vor 3 Wochen
  thesimplekid 14efe4a8b8 fix: prevent docker publish from overwritting other arc vor 3 Wochen
  thesimplekid 7e8913a1cf Merge pull request #804 from coderwander/main vor 3 Wochen
  coderwander 8f6210dba4 chore: add the missing right bracket vor 3 Wochen
  thesimplekid 26efb79e65 Merge pull request #803 from thesimplekid/mint_info_version vor 3 Wochen
  thesimplekid 86b03dca6d fix: set mint version vor 3 Wochen
  thesimplekid 5ae5d10948 chore: bump v0.10.0 (#797) vor 3 Wochen
  thesimplekid f7846f65c2 chore: cdk-signatory metadata vor 3 Wochen
  thesimplekid 9e8f5a1e7d chore: bump v0.10.0 vor 3 Wochen
  thesimplekid 21a5ac9406 feat(cli): enhance check-pending to reclaim proofs (#795) vor 3 Wochen
  thesimplekid 0b2b3c6d53 chore: update changelog v0.9.3 (#796) vor 3 Wochen
  thesimplekid 4e7d45bb98 chore: update changelog v0.9.3 vor 3 Wochen
  C 83a919ccd6 Merge pull request #793 from crodas/feature/wallet-swap-before-melt vor 3 Wochen
  thesimplekid 9a62777883 Merge pull request #794 from thesimplekid/mint_info_seq vor 3 Wochen
  thesimplekid 9c3a64b029 fix: handle old nut15 spec vor 3 Wochen
  thesimplekid 3c39bd0aec refactor: remove unused fn (#790) vor 3 Wochen
  thesimplekid 3c9ceed5e0 refactor: remove unused fn vor 3 Wochen
  asmo d9652d7f53 refactor: fixing pre commit hooks checks (#789) vor 3 Wochen
  thesimplekid 9f4d5ba424 chore: publish docker image to cashubtc (#788) vor 3 Wochen
  asmo 548bbf1b40 Secret remove pub properties (#782) vor 3 Wochen
  thesimplekid d665e2fc38 Merge pull request #786 from thesimplekid/cdk_sqlite_msrv vor 3 Wochen
  thesimplekid 54b061340f chore: fix msrv shell vor 3 Wochen
  thesimplekid 35137424c3 Merge pull request #784 from stefanbitcr/restore_redundant vor 3 Wochen
  thesimplekid 7251121d8e chore: update readme got bolt11 vor 3 Wochen
  thesimplekid 58d36adc45 Merge pull request #785 from thesimplekid/increase_ci_time vor 3 Wochen
  thesimplekid c37b218618 chore: update ci timeout vor 3 Wochen
  stefanbitcr 97abdd97e7 Remove redundant filter during restoration vor 3 Wochen
  thesimplekid 1f2654d974 Merge pull request #779 from asmogo/fix_config_example vor 1 Monat
  thesimplekid 0d0c1ff17c Merge pull request #780 from gandlafbtc/patch-3 vor 1 Monat
  thesimplekid c8741123c6 Merge pull request #781 from gandlafbtc/patch-4 vor 1 Monat
  gandlafbtc 4c1e3a5941 Update example.config.toml vor 1 Monat
  gandlafbtc af72d56558 fix typo in main.rs vor 1 Monat
  David Caseria fe118d180f Signatory Loader (#777) vor 1 Monat
  dd dd 1e4d118950 chore: disabled mint_management_rpc. update ln_backend vor 1 Monat
  David Caseria 0a832ff161 Allow Signatory to be run with custom incoming stream (#776) vor 1 Monat
  David Caseria 98440b436c Make sqlite dependency optional for signatory (#775) vor 1 Monat
  David Caseria 30d6b20c99 Reclaim unspent proofs by reverting transaction (#774) vor 1 Monat
  thesimplekid 9beb0b4256 chore: update readmes (#773) vor 1 Monat
  thesimplekid 8c8d321a15 Merge pull request #772 from thesimplekid/update_ch vor 1 Monat
  thesimplekid 15b02fd2ee chore: update change log vor 1 Monat
  asmo 19da3ac268 adding docker build workflow for arm64 images (#770) vor 1 Monat
  C ade48cd8a9 Introduce a SignatoryManager service. (#509) vor 1 Monat
  thesimplekid d1e5b378cd chore: update flake 25.05 remove nix cache (#769) vor 1 Monat
  thesimplekid de5f9111e1 feat: mint url flag (#765) vor 1 Monat
  David Caseria 0e250af87a Export NUT-06 supported settings field (#764) vor 1 Monat
  thesimplekid abf10da330 chore: update deps (#761) vor 1 Monat
  thesimplekid b63dc1045d refactor: nut04 and nut05 (#749) vor 1 Monat
  thesimplekid fc2b0b3ea2 chore: bump version to 0.9.2 (#760) vor 1 Monat
  thesimplekid 3920c6f9bc fix: nut18 payment request encoding/decoding (#758) vor 1 Monat
  thesimplekid 70944500fc chore: clippy mint_url (#759) vor 1 Monat
  thesimplekid c001375b32 fix: mint url trailing slash (#757) vor 1 Monat
  thesimplekid df0de05571 fix: get spendable to return witness (#756) vor 1 Monat
  thesimplekid 67342ec793 feat: htlc from hash (#753) vor 1 Monat
  thesimplekid 385ec4d295 feat: optional transport and nut10 secret on payment request (#744) vor 1 Monat
  thesimplekid 9ac387ae3d Ln use common (#751) vor 1 Monat
  thesimplekid 65e785561c Update README.md vor 1 Monat
  thesimplekid e268866446 chore: clippy (#750) vor 1 Monat
  thesimplekid 056ddecfeb chore: update flake vor 1 Monat
  lollerfirst a4c2454e94 [PATCH + BUGFIX] Multinut LND re-query and `use_mission_control` (#746) vor 1 Monat
  thesimplekid 5a3a274875 fix: melt start up check (#745) vor 1 Monat
  thesimplekid 34eb10fd9e Mpp cdk cli (#743) vor 1 Monat
  thesimplekid c1d6880daa Merge pull request #740 from KnowWhoami/test/remove_anyhow vor 2 Monaten
  KnowWhoami ffce3a8aef test: use expect/unwrap instead of anyhow vor 2 Monaten
  thesimplekid 4c80108ace Merge pull request #739 from thesimplekid/fix_ws_commands vor 2 Monaten
  thesimplekid d880bb13cc refactor: Update SupportedMethods with WsCommand enum and new constructor vor 2 Monaten
  thesimplekid 052f4e812d Merge pull request #737 from thesimplekid/replace_testnut vor 2 Monaten
  thesimplekid a113bb16c4 Merge pull request #738 from crodas/fix/race-condition-tests vor 2 Monaten
  Cesar Rodas 2dec11e1e4 Fix race conditions vor 2 Monaten
  thesimplekid 63e6a4d610 feat: use tsk testmint vor 2 Monaten
  thesimplekid 7ae5a0c8b9 Merge pull request #736 from thesimplekid/prepare_v0.9.1 vor 2 Monaten
  thesimplekid (aider) d8dad5dc7c chore: bump CDK version from 0.9.0 to 0.9.1 vor 2 Monaten
  thesimplekid d57abea048 Merge pull request #734 from thesimplekid/fix_remove_urls vor 2 Monaten
  thesimplekid 1fbb6c7aa9 fix: remove urls grpc vor 2 Monaten
  thesimplekid 1683bc10a7 Merge pull request #733 from aki-mizu/main vor 2 Monaten
  Darrell bac711d9fd update lnbits-rs to 0.5.0 vor 2 Monaten
100 geänderte Dateien mit 3659 neuen und 2001 gelöschten Zeilen
  1. 0 1
      .config/flakebox/.gitignore
  2. 0 12
      .config/flakebox/bin/flakebox-in-each-cargo-workspace
  3. 0 1
      .config/flakebox/id
  4. 0 32
      .config/flakebox/shellHook.sh
  5. 0 16
      .config/semgrep.yaml
  6. 29 55
      .github/workflows/ci.yml
  7. 85 0
      .github/workflows/docker-publish-arm.yml
  8. 27 3
      .github/workflows/docker-publish.yml
  9. 5 7
      .github/workflows/nutshell_itest.yml
  10. 57 0
      CHANGELOG.md
  11. 19 19
      Cargo.toml
  12. 43 0
      Dockerfile.arm
  13. 0 16
      LICENSES/BDK-LICENSE-MIT
  14. 0 29
      LICENSES/CASHU-CRAB-BSD-3
  15. 0 21
      LICENSES/CASHU-RS-MIT
  16. 0 21
      LICENSES/MOKSHA-MIT
  17. 0 21
      LICENSES/NOSTR-MIT
  18. 4 22
      README.md
  19. 70 7
      crates/cashu/src/mint_url.rs
  20. 1 1
      crates/cashu/src/nuts/auth/nut21.rs
  21. 1 1
      crates/cashu/src/nuts/auth/nut22.rs
  22. 7 6
      crates/cashu/src/nuts/mod.rs
  23. 13 1
      crates/cashu/src/nuts/nut00/mod.rs
  24. 3 3
      crates/cashu/src/nuts/nut00/token.rs
  25. 8 0
      crates/cashu/src/nuts/nut01/mod.rs
  26. 238 126
      crates/cashu/src/nuts/nut04.rs
  27. 241 292
      crates/cashu/src/nuts/nut05.rs
  28. 19 3
      crates/cashu/src/nuts/nut06.rs
  29. 3 2
      crates/cashu/src/nuts/nut07.rs
  30. 3 2
      crates/cashu/src/nuts/nut08.rs
  31. 158 13
      crates/cashu/src/nuts/nut10.rs
  32. 34 14
      crates/cashu/src/nuts/nut11/mod.rs
  33. 6 4
      crates/cashu/src/nuts/nut14/mod.rs
  34. 59 2
      crates/cashu/src/nuts/nut15.rs
  35. 30 13
      crates/cashu/src/nuts/nut17/mod.rs
  36. 17 0
      crates/cashu/src/nuts/nut18/error.rs
  37. 11 0
      crates/cashu/src/nuts/nut18/mod.rs
  38. 200 171
      crates/cashu/src/nuts/nut18/payment_request.rs
  39. 137 0
      crates/cashu/src/nuts/nut18/secret.rs
  40. 126 0
      crates/cashu/src/nuts/nut18/transport.rs
  41. 8 8
      crates/cashu/src/nuts/nut20.rs
  42. 411 0
      crates/cashu/src/nuts/nut23.rs
  43. 1 1
      crates/cashu/src/secret.rs
  44. 1 1
      crates/cashu/src/util/hex.rs
  45. 2 8
      crates/cdk-axum/Cargo.toml
  46. 7 16
      crates/cdk-axum/README.md
  47. 5 10
      crates/cdk-axum/src/auth.rs
  48. 1 1
      crates/cdk-axum/src/cache/backend/memory.rs
  49. 1 1
      crates/cdk-axum/src/cache/backend/redis.rs
  50. 9 10
      crates/cdk-axum/src/lib.rs
  51. 14 28
      crates/cdk-axum/src/router_handlers.rs
  52. 2 2
      crates/cdk-cli/Cargo.toml
  53. 17 6
      crates/cdk-cli/src/main.rs
  54. 2 2
      crates/cdk-cli/src/nostr_storage.rs
  55. 5 1
      crates/cdk-cli/src/sub_commands/balance.rs
  56. 7 10
      crates/cdk-cli/src/sub_commands/cat_device_login.rs
  57. 3 3
      crates/cdk-cli/src/sub_commands/cat_login.rs
  58. 33 0
      crates/cdk-cli/src/sub_commands/check_pending.rs
  59. 0 12
      crates/cdk-cli/src/sub_commands/check_spent.rs
  60. 246 50
      crates/cdk-cli/src/sub_commands/create_request.rs
  61. 41 13
      crates/cdk-cli/src/sub_commands/list_mint_proofs.rs
  62. 168 59
      crates/cdk-cli/src/sub_commands/melt.rs
  63. 4 14
      crates/cdk-cli/src/sub_commands/mint.rs
  64. 1 1
      crates/cdk-cli/src/sub_commands/mint_blind_auth.rs
  65. 1 1
      crates/cdk-cli/src/sub_commands/mint_info.rs
  66. 1 1
      crates/cdk-cli/src/sub_commands/mod.rs
  67. 78 66
      crates/cdk-cli/src/sub_commands/pay_request.rs
  68. 1 1
      crates/cdk-cli/src/sub_commands/pending_mints.rs
  69. 14 21
      crates/cdk-cli/src/sub_commands/receive.rs
  70. 1 1
      crates/cdk-cli/src/sub_commands/restore.rs
  71. 72 36
      crates/cdk-cli/src/sub_commands/send.rs
  72. 1 1
      crates/cdk-cli/src/sub_commands/update_mint_url.rs
  73. 100 0
      crates/cdk-cli/src/utils.rs
  74. 2 2
      crates/cdk-cln/Cargo.toml
  75. 15 11
      crates/cdk-cln/README.md
  76. 2 2
      crates/cdk-cln/src/error.rs
  77. 7 7
      crates/cdk-cln/src/lib.rs
  78. 10 20
      crates/cdk-common/README.md
  79. 6 8
      crates/cdk-common/src/database/mint/mod.rs
  80. 2 2
      crates/cdk-common/src/database/mint/test.rs
  81. 17 4
      crates/cdk-common/src/error.rs
  82. 3 0
      crates/cdk-common/src/payment.rs
  83. 0 2
      crates/cdk-common/src/pub_sub/index.rs
  84. 8 17
      crates/cdk-fake-wallet/README.md
  85. 1 2
      crates/cdk-integration-tests/src/bin/start_regtest.rs
  86. 7 3
      crates/cdk-integration-tests/src/init_auth_mint.rs
  87. 48 49
      crates/cdk-integration-tests/src/init_pure_tests.rs
  88. 3 3
      crates/cdk-integration-tests/src/init_regtest.rs
  89. 4 8
      crates/cdk-integration-tests/src/lib.rs
  90. 5 5
      crates/cdk-integration-tests/tests/fake_auth.rs
  91. 268 211
      crates/cdk-integration-tests/tests/fake_wallet.rs
  92. 115 99
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  93. 25 63
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  94. 27 26
      crates/cdk-integration-tests/tests/mint.rs
  95. 0 2
      crates/cdk-integration-tests/tests/nutshell_wallet.rs
  96. 119 79
      crates/cdk-integration-tests/tests/regtest.rs
  97. 42 35
      crates/cdk-integration-tests/tests/test_fees.rs
  98. 2 2
      crates/cdk-lnbits/Cargo.toml
  99. 8 16
      crates/cdk-lnbits/README.md
  100. 1 1
      crates/cdk-lnbits/src/error.rs

+ 0 - 1
.config/flakebox/.gitignore

@@ -1 +0,0 @@
-tmp/

+ 0 - 12
.config/flakebox/bin/flakebox-in-each-cargo-workspace

@@ -1,12 +0,0 @@
-#!/usr/bin/env bash
-# Run a given command in every directory that contains cargo workspace
-# Right now it just scans for `Cargo.lock`
-
-set -euo pipefail
-
-find . -name Cargo.lock | while read -r path ; do
-  (
-    cd "$(dirname "$path")"
-    "$@"
-  )
-done

+ 0 - 1
.config/flakebox/id

@@ -1 +0,0 @@
-e760eb996cb4d04ca4d503ecff1950521bf674ec1ddf8f8a7dc156ab5104fe11a67317fa5efcf97478fd58e0a62ac49800c14052ba95519f6e60e98e4f7a763b

+ 0 - 32
.config/flakebox/shellHook.sh

@@ -1,32 +0,0 @@
-#!/usr/bin/env bash
-root="$(git rev-parse --show-toplevel)"
-dot_git="$(git rev-parse --git-common-dir)"
-if [[ ! -d "${dot_git}/hooks" ]]; then mkdir -p "${dot_git}/hooks"; fi
-# fix old bug
-rm -f "${dot_git}/hooks/comit-msg"
-rm -f "${dot_git}/hooks/commit-msg"
-ln -sf "${root}/misc/git-hooks/commit-msg" "${dot_git}/hooks/commit-msg"
-
-root="$(git rev-parse --show-toplevel)"
-dot_git="$(git rev-parse --git-common-dir)"
-if [[ ! -d "${dot_git}/hooks" ]]; then mkdir -p "${dot_git}/hooks"; fi
-# fix old bug
-rm -f "${dot_git}/hooks/pre-comit"
-rm -f "${dot_git}/hooks/pre-commit"
-ln -sf "${root}/misc/git-hooks/pre-commit" "${dot_git}/hooks/pre-commit"
-
-# set template
-git config commit.template misc/git-hooks/commit-template.txt
-
-if ! flakebox lint --silent; then
-  >&2 echo "ℹ️  Project recommendations detected. Run 'flakebox lint' for more info."
-fi
-
-if [ -n "${DIRENV_IN_ENVRC:-}" ]; then
-  # and not set DIRENV_LOG_FORMAT
-  if [ -n "${DIRENV_LOG_FORMAT:-}" ]; then
-    >&2 echo "💡 Set 'DIRENV_LOG_FORMAT=\"\"' in your shell environment variables for a cleaner output of direnv"
-  fi
-fi
-
->&2 echo "💡 Run 'just' for a list of available 'just ...' helper recipes"

+ 0 - 16
.config/semgrep.yaml

@@ -1,16 +0,0 @@
-rules:
-  # - id: use-of-unwrap
-  #   pattern: $X.unwrap()
-  #   message: "Found use of unwrap(). Consider using more robust error handling."
-  #   languages: [rust]
-  #   severity: WARNING
-  # - id: use-of-expect
-  #   pattern: $X.expect(...)
-  #   message: "Found use of expect(). Consider providing clearer error messages or using more robust error handling."
-  #   languages: [rust]
-  #   severity: WARNING
-  - id: direct-panic
-    pattern: panic!(...)
-    message: "Direct use of panic!(). Consider if there's a more graceful way to handle this error case."
-    languages: [rust]
-    severity: ERROR

+ 29 - 55
.github/workflows/ci.yml

@@ -15,25 +15,23 @@ jobs:
   self-care:
     name: Flake self-check
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     steps:
       - uses: actions/checkout@v4
       - name: Check Nix flake inputs
-        uses: DeterminateSystems/flake-checker-action@v7
+        uses: DeterminateSystems/flake-checker-action@v9
         with:
           fail-mode: true
 
   pre-commit-checks:
     name: "Cargo fmt, typos"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     steps:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Cargo fmt
@@ -49,7 +47,7 @@ jobs:
   examples:
     name: "Run examples"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy]
     strategy:
       matrix:
@@ -65,9 +63,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Run example
@@ -76,7 +72,7 @@ jobs:
   clippy:
     name: "Stable build, clippy and test"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: pre-commit-checks
     strategy:
       matrix:
@@ -150,9 +146,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Clippy
@@ -163,7 +157,7 @@ jobs:
   regtest-itest:
     name: "Integration regtest tests"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest]
     strategy:
       matrix:
@@ -180,9 +174,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Test
@@ -191,7 +183,7 @@ jobs:
   fake-mint-itest:
     name: "Integration fake mint tests"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy]
     strategy:
       matrix:
@@ -208,9 +200,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Clippy
@@ -221,7 +211,7 @@ jobs:
   pure-itest:
     name: "Integration fake wallet tests"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy]
     strategy:
       matrix:
@@ -235,9 +225,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Test fake mint
@@ -249,7 +237,7 @@ jobs:
   payment-processor-itests:
     name: "Payment processor tests"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest, regtest-itest]
     strategy:
       matrix:
@@ -263,9 +251,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Test
@@ -274,7 +260,7 @@ jobs:
   msrv-build:
     name: "MSRV build"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy, pure-itest]
     strategy:
       matrix:
@@ -301,9 +287,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Build
@@ -313,7 +297,7 @@ jobs:
   check-wasm:
     name: Check WASM
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy, pure-itest]
     strategy:
       matrix:
@@ -331,9 +315,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Build cdk and binding
@@ -343,7 +325,7 @@ jobs:
   check-wasm-msrv:
     name: Check WASM
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy, msrv-build]
     strategy:
       matrix:
@@ -361,9 +343,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Build cdk wasm
@@ -372,7 +352,7 @@ jobs:
   fake-mint-auth-itest:
     name: "Integration fake mint auth tests"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest]
     strategy:
       matrix:
@@ -385,9 +365,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Start Keycloak with Backup
@@ -407,15 +385,13 @@ jobs:
   doc-tests:
     name: "Documentation Tests"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: pre-commit-checks
     steps:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Run doc tests
@@ -424,16 +400,14 @@ jobs:
   strict-docs:
     name: "Strict Documentation Check"
     runs-on: ubuntu-latest
-    timeout-minutes: 15
+    timeout-minutes: 30
     needs: doc-tests
     steps:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Check docs with strict warnings
-        run: nix develop -i -L .#stable --command just docs-strict
+        run: nix develop -i -L .#stable --command just docs-strict

+ 85 - 0
.github/workflows/docker-publish-arm.yml

@@ -0,0 +1,85 @@
+name: Publish Docker Image ARM64
+
+on:
+  release:
+    types: [published]
+  workflow_dispatch:
+    inputs:
+      tag:
+        description: 'Tag to build and publish'
+        required: true
+        default: 'latest'
+
+env:
+  REGISTRY: docker.io
+  IMAGE_NAME: cashubtc/mintd
+
+jobs:
+  build-and-push:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      packages: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Set up Docker Buildx
+        uses: docker/setup-buildx-action@v3
+
+      - name: Login to Docker Hub
+        uses: docker/login-action@v3
+        with:
+          username: ${{ secrets.DOCKERHUB_USERNAME }}
+          password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+      - name: Extract metadata (tags, labels) for Docker
+        id: meta
+        uses: docker/metadata-action@v5
+        with:
+          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+          tags: |
+            type=raw,value=latest,enable=${{ github.event_name == 'release' }}
+            type=semver,pattern={{version}}
+            type=semver,pattern={{major}}.{{minor}}
+            type=ref,event=branch
+            type=ref,event=pr
+            type=sha
+            ${{ github.event.inputs.tag != '' && github.event.inputs.tag || '' }}
+
+      # Build and push ARM64 image with architecture suffix
+      - name: Build and push Docker image
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          push: true
+          platforms: linux/arm64
+          file: ./Dockerfile.arm
+          tags: ${{ steps.meta.outputs.tags }}-arm64
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max
+
+      # Create and push multi-arch manifest if both images exist
+      - name: Create and push multi-arch manifest
+        run: |
+          # For each tag in the metadata output
+          echo "${{ steps.meta.outputs.tags }}" | while read -r tag; do
+            # Check if AMD64 image exists
+            if docker manifest inspect $tag-amd64 >/dev/null 2>&1; then
+              # Create manifest
+              docker manifest create $tag \
+                $tag-amd64 \
+                $tag-arm64
+
+              # Annotate the manifest with architecture specific information
+              docker manifest annotate $tag $tag-amd64 --arch amd64
+              docker manifest annotate $tag $tag-arm64 --arch arm64
+
+              # Push the manifest
+              docker manifest push $tag
+            else
+              echo "AMD64 image not found for $tag, skipping manifest creation"
+            fi
+          done

+ 27 - 3
.github/workflows/docker-publish.yml

@@ -1,4 +1,4 @@
-name: Publish Docker Image
+name: Publish Docker Image AMD64
 
 on:
   release:
@@ -12,7 +12,7 @@ on:
 
 env:
   REGISTRY: docker.io
-  IMAGE_NAME: thesimplekid/cdk-mintd
+  IMAGE_NAME: cashubtc/mintd
 
 jobs:
   build-and-push:
@@ -48,13 +48,37 @@ jobs:
             type=sha
             ${{ github.event.inputs.tag != '' && github.event.inputs.tag || '' }}
 
+      # Build and push AMD64 image with architecture suffix
       - name: Build and push Docker image
         uses: docker/build-push-action@v5
         with:
           context: .
           push: true
           platforms: linux/amd64
-          tags: ${{ steps.meta.outputs.tags }}
+          tags: ${{ steps.meta.outputs.tags }}-amd64
           labels: ${{ steps.meta.outputs.labels }}
           cache-from: type=gha
           cache-to: type=gha,mode=max
+
+      # Create and push multi-arch manifest if both images exist
+      - name: Create and push multi-arch manifest
+        run: |
+          # For each tag in the metadata output
+          echo "${{ steps.meta.outputs.tags }}" | while read -r tag; do
+            # Check if ARM64 image exists
+            if docker manifest inspect $tag-arm64 >/dev/null 2>&1; then
+              # Create manifest
+              docker manifest create $tag \
+                $tag-amd64 \
+                $tag-arm64
+
+              # Annotate the manifest with architecture specific information
+              docker manifest annotate $tag $tag-amd64 --arch amd64
+              docker manifest annotate $tag $tag-arm64 --arch arm64
+
+              # Push the manifest
+              docker manifest push $tag
+            else
+              echo "ARM64 image not found for $tag, skipping manifest creation"
+            fi
+          done

+ 5 - 7
.github/workflows/nutshell_itest.yml

@@ -6,13 +6,12 @@ jobs:
   nutshell-integration-tests:
     name: Nutshell Mint Integration Tests
     runs-on: ubuntu-latest
+    timeout-minutes: 30
     steps:
       - name: checkout
         uses: actions/checkout@v4
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Test Nutshell
@@ -24,15 +23,14 @@ jobs:
   nutshell-wallet-integration-tests:
     name: Nutshell Wallet Integration Tests
     runs-on: ubuntu-latest
+    timeout-minutes: 30
     steps:
       - name: checkout
         uses: actions/checkout@v4
       - name: Pull Nutshell Docker image
         run: docker pull cashubtc/nutshell:latest
       - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v11
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@v6
+        uses: DeterminateSystems/nix-installer-action@v17
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Test Nutshell Wallet
@@ -40,4 +38,4 @@ jobs:
           nix develop -i -L .#integration --command just nutshell-wallet-itest
       - name: Show Docker logs if tests fail
         if: failure()
-        run: docker logs nutshell-wallet || true
+        run: docker logs nutshell-wallet || true

+ 57 - 0
CHANGELOG.md

@@ -4,6 +4,61 @@
 <!-- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -->
 <!-- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -->
 
+[Unreleased]
+### Fixed
+- Mintd version updated when grpc is enabled [PR](https://github.com/cashubtc/cdk/pull/803) ([thesimplekid]).
+
+## [0.10.0](https://github.com/cashubtc/cdk/releases/tag/v0.10.0)
+### Added
+- SignatoryManager service [PR](https://github.com/cashubtc/cdk/pull/509) ([crodas]).
+- 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
+- Updated dependencies [PR](https://github.com/cashubtc/cdk/pull/761) ([thesimplekid]).
+- Refactored NUT-04 and NUT-05 [PR](https://github.com/cashubtc/cdk/pull/749) ([thesimplekid]).
+- Updated Nix flake to 25.05 and removed Nix cache [PR](https://github.com/cashubtc/cdk/pull/769) ([thesimplekid]).
+
+## [0.9.3](https://github.com/cashubtc/cdk/releases/tag/v0.9.3)
+### Changed
+- Melt will perform swap before attempting to melt if exact amount is not available [PR](https://github.com/cashubtc/cdk/pull/793) ([crodas]).
+
+### Fixed
+- Handle old nut15 format to keep compatibility with older nutshell version [PR](https://github.com/cashubtc/cdk/pull/794) ([thesimplekid]).
+
+## [0.9.2](https://github.com/cashubtc/cdk/releases/tag/v0.9.2)
+### Added
+- 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]).
+
+### Fixed
+- NUT-18 payment request encoding/decoding [PR](https://github.com/cashubtc/cdk/pull/758) ([thesimplekid]).
+- Mint URL trailing slash handling [PR](https://github.com/cashubtc/cdk/pull/757) ([thesimplekid]).
+- Get spendable to return witness [PR](https://github.com/cashubtc/cdk/pull/756) ([thesimplekid]).
+- Melt start up check [PR](https://github.com/cashubtc/cdk/pull/745) ([thesimplekid]).
+- Race conditions with proof state updates ([crodas]).
+
+## [0.9.1](https://github.com/cashubtc/cdk/releases/tag/v0.9.1)
+### Fixed
+- Remove URLs in gRPC management interface ([thesimplekid]).
+- Only count signatures from unique pubkeys ([thesimplekid]).
+- Race conditions with proof state updates ([crodas]).
+- Debug print of Info struct ([thesimplekid]).
+- Correct mnemonic hashing in Debug implementation ([thesimplekid]).
+
+### Changed
+- Updated lnbits-rs to 0.5.0 ([Darrell]).
+- Update stable Rust to 1.86.0 ([thesimplekid]).
+- Added CORS headers in responses [PR](https://github.com/cashubtc/cdk/pull/719) ([lollerfirst]).
+- Mint should not enforce expiry ([thesimplekid]).
+- Ensure unique proofs when calculating token value ([thesimplekid]).
+
 ## [0.9.0](https://github.com/cashubtc/cdk/releases/tag/v0.9.0)
 ### Added
 - Amountless invoices [NUT](https://github.com/cashubtc/nuts/pull/173) [PR](https://github.com/cashubtc/cdk/pull/497) ([thesimplekid]).
@@ -320,3 +375,5 @@ Additionally, this release introduces a Mint binary cdk-mintd that uses the cdk-
 [daywalker90]: https://github.com/daywalker90
 [nodlAndHodl]: https://github.com/nodlAndHodl
 [benthecarman]: https://github.com/benthecarman
+[Darrell]: https://github.com/Darrellbor
+[asmo]: https://github.com/asmogo

+ 19 - 19
Cargo.toml

@@ -33,7 +33,7 @@ rust-version = "1.75.0"
 license = "MIT"
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-version = "0.9.0"
+version = "0.10.0"
 readme = "README.md"
 
 [workspace.dependencies]
@@ -43,26 +43,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.9.0" }
-cdk = { path = "./crates/cdk", default-features = false, version = "=0.9.0" }
-cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.9.0" }
-cdk-axum = { path = "./crates/cdk-axum", default-features = false, version = "=0.9.0" }
-cdk-cln = { path = "./crates/cdk-cln", version = "=0.9.0" }
-cdk-lnbits = { path = "./crates/cdk-lnbits", version = "=0.9.0" }
-cdk-lnd = { path = "./crates/cdk-lnd", version = "=0.9.0" }
-cdk-fake-wallet = { path = "./crates/cdk-fake-wallet", version = "=0.9.0" }
-cdk-payment-processor = { path = "./crates/cdk-payment-processor", default-features = true, version = "=0.9.0" }
-cdk-mint-rpc = { path = "./crates/cdk-mint-rpc", version = "=0.9.0" }
-cdk-redb = { path = "./crates/cdk-redb", default-features = true, version = "=0.9.0" }
-cdk-sqlite = { path = "./crates/cdk-sqlite", default-features = true, version = "=0.9.0" }
+cashu = { path = "./crates/cashu", version = "=0.10.0" }
+cdk = { path = "./crates/cdk", default-features = false, version = "=0.10.0" }
+cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.10.0" }
+cdk-axum = { path = "./crates/cdk-axum", default-features = false, version = "=0.10.0" }
+cdk-cln = { path = "./crates/cdk-cln", version = "=0.10.0" }
+cdk-lnbits = { path = "./crates/cdk-lnbits", version = "=0.10.0" }
+cdk-lnd = { path = "./crates/cdk-lnd", version = "=0.10.0" }
+cdk-fake-wallet = { path = "./crates/cdk-fake-wallet", version = "=0.10.0" }
+cdk-payment-processor = { path = "./crates/cdk-payment-processor", default-features = true, version = "=0.10.0" }
+cdk-mint-rpc = { path = "./crates/cdk-mint-rpc", version = "=0.10.0" }
+cdk-redb = { path = "./crates/cdk-redb", default-features = true, version = "=0.10.0" }
+cdk-sqlite = { path = "./crates/cdk-sqlite", default-features = true, version = "=0.10.0" }
+cdk-signatory = { path = "./crates/cdk-signatory", version = "=0.10.0", default-features = false }
 clap = { version = "4.5.31", features = ["derive"] }
 ciborium = { version = "0.2.2", default-features = false, features = ["std"] }
 cbor-diag = "0.1.12"
 futures = { version = "0.3.28", default-features = false, features = ["async-await"] }
-lightning-invoice = { version = "0.32.0", features = ["serde", "std"] }
+lightning-invoice = { version = "0.33.0", features = ["serde", "std"] }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
-thiserror = { version = "1" }
+thiserror = { version = "2" }
 tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util"] }
 tokio-util = { version = "0.7.11", default-features = false }
 tower-http = { version = "0.6.1", features = ["compression-full", "decompression-full", "cors", "trace"] }
@@ -89,16 +90,15 @@ reqwest = { version = "0.12", default-features = false, features = [
 ]}
 once_cell = "1.20.2"
 instant = { version = "0.1", default-features = false }
-rand = "0.8.5"
+rand = "0.9.1"
 regex = "1"
 home = "0.5.5"
-tonic = { version = "0.12.3", features = [
+tonic = { version = "0.13.1", features = [
     "channel",
-    "tls",
     "tls-webpki-roots",
 ] }
 prost = "0.13.1"
-tonic-build = "0.12"
+tonic-build = "0.13.1"
 strum = "0.27.1"
 strum_macros = "0.27.1"
 

+ 43 - 0
Dockerfile.arm

@@ -0,0 +1,43 @@
+# Use the official NixOS image as the base image
+FROM nixos/nix:latest AS builder
+
+# Set the working directory
+WORKDIR /usr/src/app
+
+# Copy workspace files and crates directory into the container
+COPY flake.nix ./flake.nix
+COPY Cargo.toml ./Cargo.toml
+COPY crates ./crates
+
+# Create a nix config file to disable syscall filtering
+RUN echo 'filter-syscalls = false' > /etc/nix/nix.conf
+
+# Start the Nix daemon and develop the environment
+RUN nix develop --extra-platforms aarch64-linux --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features redis
+
+# Create a runtime stage
+FROM debian:bookworm-slim
+
+# Set the working directory
+WORKDIR /usr/src/app
+
+# Install needed runtime dependencies (if any)
+RUN apt-get update && \
+    apt-get install -y --no-install-recommends patchelf && \
+    rm -rf /var/lib/apt/lists/*
+
+# Copy the built application from the build stage
+COPY --from=builder /usr/src/app/target/release/cdk-mintd /usr/local/bin/cdk-mintd
+
+# Detect the architecture and set the interpreter accordingly
+RUN ARCH=$(uname -m) && \
+    if [ "$ARCH" = "aarch64" ]; then \
+        patchelf --set-interpreter /lib/ld-linux-aarch64.so.1 /usr/local/bin/cdk-mintd; \
+    elif [ "$ARCH" = "x86_64" ]; then \
+        patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 /usr/local/bin/cdk-mintd; \
+    else \
+        echo "Unsupported architecture: $ARCH"; exit 1; \
+    fi
+
+# Set the entry point for the container
+CMD ["cdk-mintd"]

+ 0 - 16
LICENSES/BDK-LICENSE-MIT

@@ -1,16 +0,0 @@
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

+ 0 - 29
LICENSES/CASHU-CRAB-BSD-3

@@ -1,29 +0,0 @@
-BSD 3-Clause License
-
-Copyright (c) 2023, thesimplekid
-All rights reserved.
-
-Redistribution and use in source and binary forms, with or without
-modification, are permitted provided that the following conditions are met:
-
-1. Redistributions of source code must retain the above copyright notice, this
-   list of conditions and the following disclaimer.
-
-2. Redistributions in binary form must reproduce the above copyright notice,
-   this list of conditions and the following disclaimer in the documentation
-   and/or other materials provided with the distribution.
-
-3. Neither the name of the copyright holder nor the names of its
-   contributors may be used to endorse or promote products derived from
-   this software without specific prior written permission.
-
-THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
-DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
-FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
-DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
-SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
-CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
-OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
-OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

+ 0 - 21
LICENSES/CASHU-RS-MIT

@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2023 Clark Moody
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.

+ 0 - 21
LICENSES/MOKSHA-MIT

@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2023 Steffen
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.

+ 0 - 21
LICENSES/NOSTR-MIT

@@ -1,21 +0,0 @@
-MIT License
-
-Copyright (c) 2022-2023 Yuki Kishimoto
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.

+ 4 - 22
README.md

@@ -35,22 +35,6 @@ The project is split up into several crates in the `crates/` directory:
 
 For a guide to settings up a development environment see [DEVELOPMENT.md](./DEVELOPMENT.md)
 
-### Code Style Guidelines
-
-- **Large Enum Variants**: When an enum variant contains a large type (>100 bytes), box it using `Box<T>` to reduce the overall enum size. This improves memory efficiency, especially for error types.
-
-  ```rust
-  // Instead of this:
-  enum Error {
-      SomeLargeError(LargeType),  // LargeType is >100 bytes
-  }
-  
-  // Do this:
-  enum Error {
-      SomeLargeError(Box<LargeType>),
-  }
-  ```
-
 ## Implemented [NUTs](https://github.com/cashubtc/nuts/):
 
 ### Mandatory
@@ -85,12 +69,9 @@ For a guide to settings up a development environment see [DEVELOPMENT.md](./DEVE
 | [20][20] | Signature on Mint Quote  | :heavy_check_mark: |
 | [21][21] | Clear Authentication | :heavy_check_mark: |
 | [22][22] | Blind Authentication  | :heavy_check_mark: |
+| [23][23] | Payment Method: BOLT11 | :heavy_check_mark: |
 
 
-## Bindings
-
-Experimental JS bindings can be found in the [bindings repository](https://github.com/thesimplekid/cdk-js).
-
 ## License
 
 Code is under the [MIT License](LICENSE)
@@ -125,5 +106,6 @@ Please see the [development guide](DEVELOPMENT.md).
 [18]: https://github.com/cashubtc/nuts/blob/main/18.md
 [19]: https://github.com/cashubtc/nuts/blob/main/19.md
 [20]: https://github.com/cashubtc/nuts/blob/main/20.md
-[20]: https://github.com/cashubtc/nuts/blob/main/21.md
-[20]: https://github.com/cashubtc/nuts/blob/main/22.md
+[21]: https://github.com/cashubtc/nuts/blob/main/21.md
+[22]: https://github.com/cashubtc/nuts/blob/main/22.md
+[23]: https://github.com/cashubtc/nuts/blob/main/23.md

+ 70 - 7
crates/cashu/src/mint_url.rs

@@ -24,9 +24,30 @@ pub enum Error {
 }
 
 /// MintUrl Url
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
 pub struct MintUrl(String);
 
+impl Serialize for MintUrl {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        // Use the to_string implementation to get the correctly formatted URL
+        serializer.serialize_str(&self.to_string())
+    }
+}
+
+impl<'de> Deserialize<'de> for MintUrl {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        // Deserialize as a string and then use from_str to parse it correctly
+        let s = String::deserialize(deserializer)?;
+        MintUrl::from_str(&s).map_err(serde::de::Error::custom)
+    }
+}
+
 impl MintUrl {
     fn format_url(url: &str) -> Result<String, Error> {
         ensure_cdk!(!url.is_empty(), Error::InvalidUrl);
@@ -54,18 +75,31 @@ impl MintUrl {
             .skip(1)
             .collect::<Vec<&str>>()
             .join("/");
-        let mut formatted_url = format!("{}://{}", protocol, host);
+        let mut formatted_url = format!("{protocol}://{host}");
         if !path.is_empty() {
-            formatted_url.push_str(&format!("/{}/", path));
+            formatted_url.push_str(&format!("/{path}"));
         }
         Ok(formatted_url)
     }
 
     /// Join onto url
     pub fn join(&self, path: &str) -> Result<Url, Error> {
-        Url::parse(&self.0)
-            .and_then(|url| url.join(path))
-            .map_err(Into::into)
+        let url = Url::parse(&self.0)?;
+
+        // Get the current path segments
+        let base_path = url.path();
+
+        // Check if the path has a trailing slash to avoid double slashes
+        let normalized_path = if base_path.ends_with('/') {
+            format!("{base_path}{path}")
+        } else {
+            format!("{base_path}/{path}")
+        };
+
+        // Create a new URL with the combined path
+        let mut result = url.clone();
+        result.set_path(&normalized_path);
+        Ok(result)
     }
 
     /// Append path elements onto the URL
@@ -96,6 +130,7 @@ impl fmt::Display for MintUrl {
 mod tests {
 
     use super::*;
+    use crate::Token;
 
     #[test]
     fn test_trim_trailing_slashes() {
@@ -121,7 +156,7 @@ mod tests {
         assert_eq!(correct_cased_url, cased_url_formatted.to_string());
 
         let wrong_cased_url_with_path = "http://URL-to-check.com/PATH/to/check";
-        let correct_cased_url_with_path = "http://url-to-check.com/PATH/to/check/";
+        let correct_cased_url_with_path = "http://url-to-check.com/PATH/to/check";
 
         let cased_url_with_path_formatted = MintUrl::from_str(wrong_cased_url_with_path).unwrap();
         assert_eq!(
@@ -164,4 +199,32 @@ mod tests {
             url.join_paths(&["hello", "world"]).unwrap().to_string()
         );
     }
+
+    #[test]
+    fn test_mint_url_slash_eqality() {
+        let mint_url_with_slash_str = "https://mint.minibits.cash/Bitcoin/";
+        let mint_url_with_slash = MintUrl::from_str(mint_url_with_slash_str).unwrap();
+
+        let mint_url_without_slash_str = "https://mint.minibits.cash/Bitcoin";
+        let mint_url_without_slash = MintUrl::from_str(mint_url_without_slash_str).unwrap();
+
+        assert_eq!(mint_url_with_slash, mint_url_without_slash);
+        assert_eq!(
+            mint_url_with_slash.to_string(),
+            mint_url_without_slash_str.to_string()
+        );
+    }
+
+    #[test]
+    fn test_token_equality_trailing_slash() {
+        let token_with_slash = Token::from_str("cashuBo2FteCNodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luL2F1Y3NhdGF0gaJhaUgAUAVQ8ElBRmFwgqRhYQhhc3hAYzg2NTZhZDg4MzVmOWVmMzVkYWQ1MTZjNGU5ZTU5ZjA3YzFmODg0NTc2NWY3M2FhNWMyMjVhOGI4MGM0ZGM0ZmFjWCECNpnvLdFcsaVbCPUlOzr78XtBoD3mm3jQcldsQ6iKUBFhZKNhZVggrER4tfjjiH0e-lf9H---us1yjQQi__ZCFB9yFwH4jDphc1ggZfP2KcQOWA110vLz11caZF1PuXN606caPO2ZCAhfdvphclggadgz0psQELNif3xJ5J2d_TJWtRKfDFSj7h2ZD4WSFeykYWECYXN4QGZlNjAzNjA1NWM1MzVlZTBlYjI3MjQ1NmUzNjJlNmNkOWViNDNkMWQxODg0M2MzMDQ4MGU0YzE2YjI0MDY5MDZhY1ghAilA3g2_NriE94uTPISd2CM-90x53mK5QNM2iyTFDlnTYWSjYWVYIExR7bUzqM6-lRU7PbbEfnPW1vnSzCEN4SArmJZqp_7bYXNYIJMKRTSlXumUjPWXX5V8-hGPSZ-OXZJiEWm6_IB93OUDYXJYIB8YsigK7dMX59Oiy4Rh05xU0n0rVAPV7g_YFx564ZVa").unwrap();
+
+        let token_without_slash = Token::from_str("cashuBo2FteCJodHRwczovL21pbnQubWluaWJpdHMuY2FzaC9CaXRjb2luYXVjc2F0YXSBomFpSABQBVDwSUFGYXCCpGFhCGFzeEBjODY1NmFkODgzNWY5ZWYzNWRhZDUxNmM0ZTllNTlmMDdjMWY4ODQ1NzY1ZjczYWE1YzIyNWE4YjgwYzRkYzRmYWNYIQI2me8t0VyxpVsI9SU7Ovvxe0GgPeabeNByV2xDqIpQEWFko2FlWCCsRHi1-OOIfR76V_0f7766zXKNBCL_9kIUH3IXAfiMOmFzWCBl8_YpxA5YDXXS8vPXVxpkXU-5c3rTpxo87ZkICF92-mFyWCBp2DPSmxAQs2J_fEnknZ39Mla1Ep8MVKPuHZkPhZIV7KRhYQJhc3hAZmU2MDM2MDU1YzUzNWVlMGViMjcyNDU2ZTM2MmU2Y2Q5ZWI0M2QxZDE4ODQzYzMwNDgwZTRjMTZiMjQwNjkwNmFjWCECKUDeDb82uIT3i5M8hJ3YIz73THneYrlA0zaLJMUOWdNhZKNhZVggTFHttTOozr6VFTs9tsR-c9bW-dLMIQ3hICuYlmqn_tthc1ggkwpFNKVe6ZSM9ZdflXz6EY9Jn45dkmIRabr8gH3c5QNhclggHxiyKArt0xfn06LLhGHTnFTSfStUA9XuD9gXHnrhlVo").unwrap();
+
+        let url_with_slash = token_with_slash.mint_url().unwrap();
+        let url_without_slash = token_without_slash.mint_url().unwrap();
+
+        assert_eq!(url_without_slash.to_string(), url_with_slash.to_string());
+        assert_eq!(url_without_slash, url_with_slash);
+    }
 }

+ 1 - 1
crates/cashu/src/nuts/auth/nut21.rs

@@ -169,7 +169,7 @@ impl std::fmt::Display for RoutePath {
         };
         // Remove the quotes from the JSON string
         let path = json_str.trim_matches('"');
-        write!(f, "{}", path)
+        write!(f, "{path}")
     }
 }
 

+ 1 - 1
crates/cashu/src/nuts/auth/nut22.rs

@@ -231,7 +231,7 @@ impl fmt::Display for BlindAuthToken {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         let json_string = serde_json::to_string(&self.auth_proof).map_err(|_| fmt::Error)?;
         let encoded = general_purpose::URL_SAFE.encode(json_string);
-        write!(f, "authA{}", encoded)
+        write!(f, "authA{encoded}")
     }
 }
 

+ 7 - 6
crates/cashu/src/nuts/mod.rs

@@ -23,6 +23,7 @@ pub mod nut17;
 pub mod nut18;
 pub mod nut19;
 pub mod nut20;
+pub mod nut23;
 
 #[cfg(feature = "auth")]
 mod auth;
@@ -45,13 +46,9 @@ pub use nut02::{Id, KeySet, KeySetInfo, KeysetResponse};
 #[cfg(feature = "wallet")]
 pub use nut03::PreSwap;
 pub use nut03::{SwapRequest, SwapResponse};
-pub use nut04::{
-    MintBolt11Request, MintBolt11Response, MintMethodSettings, MintQuoteBolt11Request,
-    MintQuoteBolt11Response, QuoteState as MintQuoteState, Settings as NUT04Settings,
-};
+pub use nut04::{MintMethodSettings, MintRequest, MintResponse, Settings as NUT04Settings};
 pub use nut05::{
-    MeltBolt11Request, MeltMethodSettings, MeltOptions, MeltQuoteBolt11Request,
-    MeltQuoteBolt11Response, QuoteState as MeltQuoteState, Settings as NUT05Settings,
+    MeltMethodSettings, MeltRequest, QuoteState as MeltQuoteState, Settings as NUT05Settings,
 };
 pub use nut06::{ContactInfo, MintInfo, MintVersion, Nuts};
 pub use nut07::{CheckStateRequest, CheckStateResponse, ProofState, State};
@@ -66,3 +63,7 @@ pub use nut18::{
     PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, Transport, TransportBuilder,
     TransportType,
 };
+pub use nut23::{
+    MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request,
+    MintQuoteBolt11Response, QuoteState as MintQuoteState,
+};

+ 13 - 1
crates/cashu/src/nuts/nut00/mod.rs

@@ -283,6 +283,18 @@ pub enum Witness {
     HTLCWitness(HTLCWitness),
 }
 
+impl From<P2PKWitness> for Witness {
+    fn from(witness: P2PKWitness) -> Self {
+        Self::P2PKWitness(witness)
+    }
+}
+
+impl From<HTLCWitness> for Witness {
+    fn from(witness: HTLCWitness) -> Self {
+        Self::HTLCWitness(witness)
+    }
+}
+
 impl Witness {
     /// Add signatures to [`Witness`]
     pub fn add_signatures(&mut self, signatues: Vec<String>) {
@@ -570,7 +582,7 @@ impl fmt::Display for PaymentMethod {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             PaymentMethod::Bolt11 => write!(f, "bolt11"),
-            PaymentMethod::Custom(p) => write!(f, "{}", p),
+            PaymentMethod::Custom(p) => write!(f, "{p}"),
         }
     }
 }

+ 3 - 3
crates/cashu/src/nuts/nut00/token.rs

@@ -33,7 +33,7 @@ impl fmt::Display for Token {
             Self::TokenV4(token) => token.to_string(),
         };
 
-        write!(f, "{}", token)
+        write!(f, "{token}")
     }
 }
 
@@ -300,7 +300,7 @@ impl fmt::Display for TokenV3 {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         let json_string = serde_json::to_string(self).map_err(|_| fmt::Error)?;
         let encoded = general_purpose::URL_SAFE.encode(json_string);
-        write!(f, "cashuA{}", encoded)
+        write!(f, "cashuA{encoded}")
     }
 }
 
@@ -387,7 +387,7 @@ impl fmt::Display for TokenV4 {
         let mut data = Vec::new();
         ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?;
         let encoded = general_purpose::URL_SAFE.encode(data);
-        write!(f, "cashuB{}", encoded)
+        write!(f, "cashuB{encoded}")
     }
 }
 

+ 8 - 0
crates/cashu/src/nuts/nut01/mod.rs

@@ -91,6 +91,14 @@ impl<'de> Deserialize<'de> for Keys {
     }
 }
 
+impl Deref for Keys {
+    type Target = BTreeMap<Amount, PublicKey>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
 impl From<MintKeys> for Keys {
     fn from(keys: MintKeys) -> Self {
         Self(

+ 238 - 126
crates/cashu/src/nuts/nut04.rs

@@ -3,16 +3,17 @@
 //! <https://github.com/cashubtc/nuts/blob/main/04.md>
 
 use std::fmt;
+#[cfg(feature = "mint")]
 use std::str::FromStr;
 
-use serde::de::DeserializeOwned;
+use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, Visitor};
+use serde::ser::{SerializeStruct, Serializer};
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
 #[cfg(feature = "mint")]
 use uuid::Uuid;
 
 use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod};
-use super::{MintQuoteState, PublicKey};
 use crate::Amount;
 
 /// NUT04 Error
@@ -26,124 +27,11 @@ pub enum Error {
     AmountOverflow,
 }
 
-/// Mint quote request [NUT-04]
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
-pub struct MintQuoteBolt11Request {
-    /// Amount
-    pub amount: Amount,
-    /// Unit wallet would like to pay with
-    pub unit: CurrencyUnit,
-    /// Memo to create the invoice with
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub description: Option<String>,
-    /// NUT-19 Pubkey
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub pubkey: Option<PublicKey>,
-}
-
-/// Possible states of a quote
-#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
-#[serde(rename_all = "UPPERCASE")]
-#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = MintQuoteState))]
-pub enum QuoteState {
-    /// Quote has not been paid
-    #[default]
-    Unpaid,
-    /// Quote has been paid and wallet can mint
-    Paid,
-    /// Minting is in progress
-    /// **Note:** This state is to be used internally but is not part of the
-    /// nut.
-    Pending,
-    /// ecash issued for quote
-    Issued,
-}
-
-impl fmt::Display for QuoteState {
-    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
-        match self {
-            Self::Unpaid => write!(f, "UNPAID"),
-            Self::Paid => write!(f, "PAID"),
-            Self::Pending => write!(f, "PENDING"),
-            Self::Issued => write!(f, "ISSUED"),
-        }
-    }
-}
-
-impl FromStr for QuoteState {
-    type Err = Error;
-
-    fn from_str(state: &str) -> Result<Self, Self::Err> {
-        match state {
-            "PENDING" => Ok(Self::Pending),
-            "PAID" => Ok(Self::Paid),
-            "UNPAID" => Ok(Self::Unpaid),
-            "ISSUED" => Ok(Self::Issued),
-            _ => Err(Error::UnknownState),
-        }
-    }
-}
-
-/// Mint quote response [NUT-04]
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
-#[serde(bound = "Q: Serialize + DeserializeOwned")]
-pub struct MintQuoteBolt11Response<Q> {
-    /// Quote Id
-    pub quote: Q,
-    /// Payment request to fulfil
-    pub request: String,
-    /// Amount
-    // REVIEW: This is now required in the spec, we should remove the option once all mints update
-    pub amount: Option<Amount>,
-    /// Unit
-    // REVIEW: This is now required in the spec, we should remove the option once all mints update
-    pub unit: Option<CurrencyUnit>,
-    /// Quote State
-    pub state: MintQuoteState,
-    /// Unix timestamp until the quote is valid
-    pub expiry: Option<u64>,
-    /// NUT-19 Pubkey
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub pubkey: Option<PublicKey>,
-}
-
-impl<Q: ToString> MintQuoteBolt11Response<Q> {
-    /// Convert the MintQuote with a quote type Q to a String
-    pub fn to_string_id(&self) -> MintQuoteBolt11Response<String> {
-        MintQuoteBolt11Response {
-            quote: self.quote.to_string(),
-            request: self.request.clone(),
-            state: self.state,
-            expiry: self.expiry,
-            pubkey: self.pubkey,
-            amount: self.amount,
-            unit: self.unit.clone(),
-        }
-    }
-}
-
-#[cfg(feature = "mint")]
-impl From<MintQuoteBolt11Response<Uuid>> for MintQuoteBolt11Response<String> {
-    fn from(value: MintQuoteBolt11Response<Uuid>) -> Self {
-        Self {
-            quote: value.quote.to_string(),
-            request: value.request,
-            state: value.state,
-            expiry: value.expiry,
-            pubkey: value.pubkey,
-            amount: value.amount,
-            unit: value.unit.clone(),
-        }
-    }
-}
-
 /// Mint request [NUT-04]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 #[serde(bound = "Q: Serialize + DeserializeOwned")]
-pub struct MintBolt11Request<Q> {
+pub struct MintRequest<Q> {
     /// Quote id
     #[cfg_attr(feature = "swagger", schema(max_length = 1_000))]
     pub quote: Q,
@@ -156,10 +44,10 @@ pub struct MintBolt11Request<Q> {
 }
 
 #[cfg(feature = "mint")]
-impl TryFrom<MintBolt11Request<String>> for MintBolt11Request<Uuid> {
+impl TryFrom<MintRequest<String>> for MintRequest<Uuid> {
     type Error = uuid::Error;
 
-    fn try_from(value: MintBolt11Request<String>) -> Result<Self, Self::Error> {
+    fn try_from(value: MintRequest<String>) -> Result<Self, Self::Error> {
         Ok(Self {
             quote: Uuid::from_str(&value.quote)?,
             outputs: value.outputs,
@@ -168,7 +56,7 @@ impl TryFrom<MintBolt11Request<String>> for MintBolt11Request<Uuid> {
     }
 }
 
-impl<Q> MintBolt11Request<Q> {
+impl<Q> MintRequest<Q> {
     /// Total [`Amount`] of outputs
     pub fn total_amount(&self) -> Result<Amount, Error> {
         Amount::try_sum(
@@ -183,13 +71,13 @@ impl<Q> MintBolt11Request<Q> {
 /// Mint response [NUT-04]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
-pub struct MintBolt11Response {
+pub struct MintResponse {
     /// Blinded Signatures
     pub signatures: Vec<BlindSignature>,
 }
 
 /// Mint Method Settings
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct MintMethodSettings {
     /// Payment Method e.g. bolt11
@@ -197,14 +85,168 @@ pub struct MintMethodSettings {
     /// Currency Unit e.g. sat
     pub unit: CurrencyUnit,
     /// Min Amount
-    #[serde(skip_serializing_if = "Option::is_none")]
     pub min_amount: Option<Amount>,
     /// Max Amount
-    #[serde(skip_serializing_if = "Option::is_none")]
     pub max_amount: Option<Amount>,
-    /// Quote Description
-    #[serde(default)]
-    pub description: bool,
+    /// Options
+    pub options: Option<MintMethodOptions>,
+}
+
+impl Serialize for MintMethodSettings {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        let mut num_fields = 3; // method and unit are always present
+        if self.min_amount.is_some() {
+            num_fields += 1;
+        }
+        if self.max_amount.is_some() {
+            num_fields += 1;
+        }
+
+        let mut description_in_top_level = false;
+        if let Some(MintMethodOptions::Bolt11 { description }) = &self.options {
+            if *description {
+                num_fields += 1;
+                description_in_top_level = true;
+            }
+        }
+
+        let mut state = serializer.serialize_struct("MintMethodSettings", num_fields)?;
+
+        state.serialize_field("method", &self.method)?;
+        state.serialize_field("unit", &self.unit)?;
+
+        if let Some(min_amount) = &self.min_amount {
+            state.serialize_field("min_amount", min_amount)?;
+        }
+
+        if let Some(max_amount) = &self.max_amount {
+            state.serialize_field("max_amount", max_amount)?;
+        }
+
+        // If there's a description flag in Bolt11 options, add it at the top level
+        if description_in_top_level {
+            state.serialize_field("description", &true)?;
+        }
+
+        state.end()
+    }
+}
+
+struct MintMethodSettingsVisitor;
+
+impl<'de> Visitor<'de> for MintMethodSettingsVisitor {
+    type Value = MintMethodSettings;
+
+    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+        formatter.write_str("a MintMethodSettings structure")
+    }
+
+    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
+    where
+        M: MapAccess<'de>,
+    {
+        let mut method: Option<PaymentMethod> = None;
+        let mut unit: Option<CurrencyUnit> = None;
+        let mut min_amount: Option<Amount> = None;
+        let mut max_amount: Option<Amount> = None;
+        let mut description: Option<bool> = None;
+
+        while let Some(key) = map.next_key::<String>()? {
+            match key.as_str() {
+                "method" => {
+                    if method.is_some() {
+                        return Err(de::Error::duplicate_field("method"));
+                    }
+                    method = Some(map.next_value()?);
+                }
+                "unit" => {
+                    if unit.is_some() {
+                        return Err(de::Error::duplicate_field("unit"));
+                    }
+                    unit = Some(map.next_value()?);
+                }
+                "min_amount" => {
+                    if min_amount.is_some() {
+                        return Err(de::Error::duplicate_field("min_amount"));
+                    }
+                    min_amount = Some(map.next_value()?);
+                }
+                "max_amount" => {
+                    if max_amount.is_some() {
+                        return Err(de::Error::duplicate_field("max_amount"));
+                    }
+                    max_amount = Some(map.next_value()?);
+                }
+                "description" => {
+                    if description.is_some() {
+                        return Err(de::Error::duplicate_field("description"));
+                    }
+                    description = Some(map.next_value()?);
+                }
+                "options" => {
+                    // If there are explicit options, they take precedence, except the description
+                    // field which we will handle specially
+                    let options: Option<MintMethodOptions> = map.next_value()?;
+
+                    if let Some(MintMethodOptions::Bolt11 {
+                        description: desc_from_options,
+                    }) = options
+                    {
+                        // If we already found a top-level description, use that instead
+                        if description.is_none() {
+                            description = Some(desc_from_options);
+                        }
+                    }
+                }
+                _ => {
+                    // Skip unknown fields
+                    let _: serde::de::IgnoredAny = map.next_value()?;
+                }
+            }
+        }
+
+        let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
+        let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
+
+        // Create options based on the method and the description flag
+        let options = if method == PaymentMethod::Bolt11 {
+            description.map(|description| MintMethodOptions::Bolt11 { description })
+        } else {
+            None
+        };
+
+        Ok(MintMethodSettings {
+            method,
+            unit,
+            min_amount,
+            max_amount,
+            options,
+        })
+    }
+}
+
+impl<'de> Deserialize<'de> for MintMethodSettings {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        deserializer.deserialize_map(MintMethodSettingsVisitor)
+    }
+}
+
+/// Mint Method settings options
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+#[serde(untagged)]
+pub enum MintMethodOptions {
+    /// Bolt11 Options
+    Bolt11 {
+        /// Mint supports setting bolt11 description
+        description: bool,
+    },
 }
 
 /// Mint Settings
@@ -250,3 +292,73 @@ impl Settings {
             .map(|index| self.methods.remove(index))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use serde_json::{from_str, json, to_string};
+
+    use super::*;
+
+    #[test]
+    fn test_mint_method_settings_top_level_description() {
+        // Create JSON with top-level description
+        let json_str = r#"{
+            "method": "bolt11",
+            "unit": "sat",
+            "min_amount": 0,
+            "max_amount": 10000,
+            "description": true
+        }"#;
+
+        // Deserialize it
+        let settings: MintMethodSettings = from_str(json_str).unwrap();
+
+        // Check that description was correctly moved to options
+        assert_eq!(settings.method, PaymentMethod::Bolt11);
+        assert_eq!(settings.unit, CurrencyUnit::Sat);
+        assert_eq!(settings.min_amount, Some(Amount::from(0)));
+        assert_eq!(settings.max_amount, Some(Amount::from(10000)));
+
+        match settings.options {
+            Some(MintMethodOptions::Bolt11 { description }) => {
+                assert_eq!(description, true);
+            }
+            _ => panic!("Expected Bolt11 options with description = true"),
+        }
+
+        // Serialize it back
+        let serialized = to_string(&settings).unwrap();
+        let parsed: serde_json::Value = from_str(&serialized).unwrap();
+
+        // Verify the description is at the top level
+        assert_eq!(parsed["description"], json!(true));
+    }
+
+    #[test]
+    fn test_both_description_locations() {
+        // Create JSON with description in both places (top level and in options)
+        let json_str = r#"{
+            "method": "bolt11",
+            "unit": "sat",
+            "min_amount": 0,
+            "max_amount": 10000,
+            "description": true,
+            "options": {
+                "description": false
+            }
+        }"#;
+
+        // Deserialize it - top level should take precedence
+        let settings: MintMethodSettings = from_str(json_str).unwrap();
+
+        match settings.options {
+            Some(MintMethodOptions::Bolt11 { description }) => {
+                assert_eq!(
+                    description, true,
+                    "Top-level description should take precedence"
+                );
+            }
+            _ => panic!("Expected Bolt11 options with description = true"),
+        }
+    }
+}

+ 241 - 292
crates/cashu/src/nuts/nut05.rs

@@ -5,18 +5,16 @@
 use std::fmt;
 use std::str::FromStr;
 
-use serde::de::DeserializeOwned;
-use serde::{Deserialize, Deserializer, Serialize};
-use serde_json::Value;
+use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, Visitor};
+use serde::ser::{SerializeStruct, Serializer};
+use serde::{Deserialize, Serialize};
 use thiserror::Error;
 #[cfg(feature = "mint")]
 use uuid::Uuid;
 
-use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proofs};
-use super::nut15::Mpp;
+use super::nut00::{BlindedMessage, CurrencyUnit, PaymentMethod, Proofs};
 use super::ProofsMethods;
-use crate::nuts::MeltQuoteState;
-use crate::{Amount, Bolt11Invoice};
+use crate::Amount;
 
 /// NUT05 Error
 #[derive(Debug, Error)]
@@ -27,118 +25,11 @@ pub enum Error {
     /// Amount overflow
     #[error("Amount Overflow")]
     AmountOverflow,
-    /// Invalid Amount
-    #[error("Invalid Request")]
-    InvalidAmountRequest,
     /// Unsupported unit
     #[error("Unsupported unit")]
     UnsupportedUnit,
 }
 
-/// Melt quote request [NUT-05]
-#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
-#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
-pub struct MeltQuoteBolt11Request {
-    /// Bolt11 invoice to be paid
-    #[cfg_attr(feature = "swagger", schema(value_type = String))]
-    pub request: Bolt11Invoice,
-    /// Unit wallet would like to pay with
-    pub unit: CurrencyUnit,
-    /// Payment Options
-    pub options: Option<MeltOptions>,
-}
-
-/// Melt Options
-#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
-#[serde(untagged)]
-#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
-pub enum MeltOptions {
-    /// Mpp Options
-    Mpp {
-        /// MPP
-        mpp: Mpp,
-    },
-    /// Amountless options
-    Amountless {
-        /// Amountless
-        amountless: Amountless,
-    },
-}
-
-impl MeltOptions {
-    /// Create new [`MeltOptions::Mpp`]
-    pub fn new_mpp<A>(amount: A) -> Self
-    where
-        A: Into<Amount>,
-    {
-        Self::Mpp {
-            mpp: Mpp {
-                amount: amount.into(),
-            },
-        }
-    }
-
-    /// Create new [`MeltOptions::Amountless`]
-    pub fn new_amountless<A>(amount_msat: A) -> Self
-    where
-        A: Into<Amount>,
-    {
-        Self::Amountless {
-            amountless: Amountless {
-                amount_msat: amount_msat.into(),
-            },
-        }
-    }
-
-    /// Payment amount
-    pub fn amount_msat(&self) -> Amount {
-        match self {
-            Self::Mpp { mpp } => mpp.amount,
-            Self::Amountless { amountless } => amountless.amount_msat,
-        }
-    }
-}
-
-/// Amountless payment
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
-#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
-pub struct Amountless {
-    /// Amount to pay in msat
-    pub amount_msat: Amount,
-}
-
-impl MeltQuoteBolt11Request {
-    /// Amount from [`MeltQuoteBolt11Request`]
-    ///
-    /// Amount can either be defined in the bolt11 invoice,
-    /// in the request for an amountless bolt11 or in MPP option.
-    pub fn amount_msat(&self) -> Result<Amount, Error> {
-        let MeltQuoteBolt11Request {
-            request,
-            unit: _,
-            options,
-            ..
-        } = self;
-
-        match options {
-            None => Ok(request
-                .amount_milli_satoshis()
-                .ok_or(Error::InvalidAmountRequest)?
-                .into()),
-            Some(MeltOptions::Mpp { mpp }) => Ok(mpp.amount),
-            Some(MeltOptions::Amountless { amountless }) => {
-                let amount = amountless.amount_msat;
-                if let Some(amount_msat) = request.amount_milli_satoshis() {
-                    if amount != amount_msat.into() {
-                        return Err(Error::InvalidAmountRequest);
-                    }
-                }
-                Ok(amount)
-            }
-        }
-    }
-}
-
 /// Possible states of a quote
 #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
 #[serde(rename_all = "UPPERCASE")]
@@ -184,177 +75,11 @@ impl FromStr for QuoteState {
     }
 }
 
-/// Melt quote response [NUT-05]
-#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
-#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
-#[serde(bound = "Q: Serialize")]
-pub struct MeltQuoteBolt11Response<Q> {
-    /// Quote Id
-    pub quote: Q,
-    /// The amount that needs to be provided
-    pub amount: Amount,
-    /// The fee reserve that is required
-    pub fee_reserve: Amount,
-    /// Whether the request haas be paid
-    // TODO: To be deprecated
-    /// Deprecated
-    pub paid: Option<bool>,
-    /// Quote State
-    pub state: MeltQuoteState,
-    /// Unix timestamp until the quote is valid
-    pub expiry: u64,
-    /// Payment preimage
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub payment_preimage: Option<String>,
-    /// Change
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub change: Option<Vec<BlindSignature>>,
-    /// Payment request to fulfill
-    // REVIEW: This is now required in the spec, we should remove the option once all mints update
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub request: Option<String>,
-    /// Unit
-    // REVIEW: This is now required in the spec, we should remove the option once all mints update
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub unit: Option<CurrencyUnit>,
-}
-
-impl<Q: ToString> MeltQuoteBolt11Response<Q> {
-    /// Convert a `MeltQuoteBolt11Response` with type Q (generic/unknown) to a
-    /// `MeltQuoteBolt11Response` with `String`
-    pub fn to_string_id(self) -> MeltQuoteBolt11Response<String> {
-        MeltQuoteBolt11Response {
-            quote: self.quote.to_string(),
-            amount: self.amount,
-            fee_reserve: self.fee_reserve,
-            paid: self.paid,
-            state: self.state,
-            expiry: self.expiry,
-            payment_preimage: self.payment_preimage,
-            change: self.change,
-            request: self.request,
-            unit: self.unit,
-        }
-    }
-}
-
-#[cfg(feature = "mint")]
-impl From<MeltQuoteBolt11Response<Uuid>> for MeltQuoteBolt11Response<String> {
-    fn from(value: MeltQuoteBolt11Response<Uuid>) -> Self {
-        Self {
-            quote: value.quote.to_string(),
-            amount: value.amount,
-            fee_reserve: value.fee_reserve,
-            paid: value.paid,
-            state: value.state,
-            expiry: value.expiry,
-            payment_preimage: value.payment_preimage,
-            change: value.change,
-            request: value.request,
-            unit: value.unit,
-        }
-    }
-}
-
-// A custom deserializer is needed until all mints
-// update some will return without the required state.
-impl<'de, Q: DeserializeOwned> Deserialize<'de> for MeltQuoteBolt11Response<Q> {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let value = Value::deserialize(deserializer)?;
-
-        let quote: Q = serde_json::from_value(
-            value
-                .get("quote")
-                .ok_or(serde::de::Error::missing_field("quote"))?
-                .clone(),
-        )
-        .map_err(|_| serde::de::Error::custom("Invalid quote if string"))?;
-
-        let amount = value
-            .get("amount")
-            .ok_or(serde::de::Error::missing_field("amount"))?
-            .as_u64()
-            .ok_or(serde::de::Error::missing_field("amount"))?;
-        let amount = Amount::from(amount);
-
-        let fee_reserve = value
-            .get("fee_reserve")
-            .ok_or(serde::de::Error::missing_field("fee_reserve"))?
-            .as_u64()
-            .ok_or(serde::de::Error::missing_field("fee_reserve"))?;
-
-        let fee_reserve = Amount::from(fee_reserve);
-
-        let paid: Option<bool> = value.get("paid").and_then(|p| p.as_bool());
-
-        let state: Option<String> = value
-            .get("state")
-            .and_then(|s| serde_json::from_value(s.clone()).ok());
-
-        let (state, paid) = match (state, paid) {
-            (None, None) => return Err(serde::de::Error::custom("State or paid must be defined")),
-            (Some(state), _) => {
-                let state: QuoteState = QuoteState::from_str(&state)
-                    .map_err(|_| serde::de::Error::custom("Unknown state"))?;
-                let paid = state == QuoteState::Paid;
-
-                (state, paid)
-            }
-            (None, Some(paid)) => {
-                let state = if paid {
-                    QuoteState::Paid
-                } else {
-                    QuoteState::Unpaid
-                };
-                (state, paid)
-            }
-        };
-
-        let expiry = value
-            .get("expiry")
-            .ok_or(serde::de::Error::missing_field("expiry"))?
-            .as_u64()
-            .ok_or(serde::de::Error::missing_field("expiry"))?;
-
-        let payment_preimage: Option<String> = value
-            .get("payment_preimage")
-            .and_then(|p| serde_json::from_value(p.clone()).ok());
-
-        let change: Option<Vec<BlindSignature>> = value
-            .get("change")
-            .and_then(|b| serde_json::from_value(b.clone()).ok());
-
-        let request: Option<String> = value
-            .get("request")
-            .and_then(|r| serde_json::from_value(r.clone()).ok());
-
-        let unit: Option<CurrencyUnit> = value
-            .get("unit")
-            .and_then(|u| serde_json::from_value(u.clone()).ok());
-
-        Ok(Self {
-            quote,
-            amount,
-            fee_reserve,
-            paid: Some(paid),
-            state,
-            expiry,
-            payment_preimage,
-            change,
-            request,
-            unit,
-        })
-    }
-}
-
 /// Melt Bolt11 Request [NUT-05]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 #[serde(bound = "Q: Serialize + DeserializeOwned")]
-pub struct MeltBolt11Request<Q> {
+pub struct MeltRequest<Q> {
     /// Quote ID
     quote: Q,
     /// Proofs
@@ -366,10 +91,10 @@ pub struct MeltBolt11Request<Q> {
 }
 
 #[cfg(feature = "mint")]
-impl TryFrom<MeltBolt11Request<String>> for MeltBolt11Request<Uuid> {
+impl TryFrom<MeltRequest<String>> for MeltRequest<Uuid> {
     type Error = uuid::Error;
 
-    fn try_from(value: MeltBolt11Request<String>) -> Result<Self, Self::Error> {
+    fn try_from(value: MeltRequest<String>) -> Result<Self, Self::Error> {
         Ok(Self {
             quote: Uuid::from_str(&value.quote)?,
             inputs: value.inputs,
@@ -379,7 +104,7 @@ impl TryFrom<MeltBolt11Request<String>> for MeltBolt11Request<Uuid> {
 }
 
 // Basic implementation without trait bounds
-impl<Q> MeltBolt11Request<Q> {
+impl<Q> MeltRequest<Q> {
     /// Get inputs (proofs)
     pub fn inputs(&self) -> &Proofs {
         &self.inputs
@@ -391,8 +116,8 @@ impl<Q> MeltBolt11Request<Q> {
     }
 }
 
-impl<Q: Serialize + DeserializeOwned> MeltBolt11Request<Q> {
-    /// Create new [`MeltBolt11Request`]
+impl<Q: Serialize + DeserializeOwned> MeltRequest<Q> {
+    /// Create new [`MeltRequest`]
     pub fn new(quote: Q, inputs: Proofs, outputs: Option<Vec<BlindedMessage>>) -> Self {
         Self {
             quote,
@@ -414,7 +139,7 @@ impl<Q: Serialize + DeserializeOwned> MeltBolt11Request<Q> {
 }
 
 /// Melt Method Settings
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct MeltMethodSettings {
     /// Payment Method e.g. bolt11
@@ -422,14 +147,168 @@ pub struct MeltMethodSettings {
     /// Currency Unit e.g. sat
     pub unit: CurrencyUnit,
     /// Min Amount
-    #[serde(skip_serializing_if = "Option::is_none")]
     pub min_amount: Option<Amount>,
     /// Max Amount
-    #[serde(skip_serializing_if = "Option::is_none")]
     pub max_amount: Option<Amount>,
-    /// Amountless
-    #[serde(default)]
-    pub amountless: bool,
+    /// Options
+    pub options: Option<MeltMethodOptions>,
+}
+
+impl Serialize for MeltMethodSettings {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        let mut num_fields = 3; // method and unit are always present
+        if self.min_amount.is_some() {
+            num_fields += 1;
+        }
+        if self.max_amount.is_some() {
+            num_fields += 1;
+        }
+
+        let mut amountless_in_top_level = false;
+        if let Some(MeltMethodOptions::Bolt11 { amountless }) = &self.options {
+            if *amountless {
+                num_fields += 1;
+                amountless_in_top_level = true;
+            }
+        }
+
+        let mut state = serializer.serialize_struct("MeltMethodSettings", num_fields)?;
+
+        state.serialize_field("method", &self.method)?;
+        state.serialize_field("unit", &self.unit)?;
+
+        if let Some(min_amount) = &self.min_amount {
+            state.serialize_field("min_amount", min_amount)?;
+        }
+
+        if let Some(max_amount) = &self.max_amount {
+            state.serialize_field("max_amount", max_amount)?;
+        }
+
+        // If there's an amountless flag in Bolt11 options, add it at the top level
+        if amountless_in_top_level {
+            state.serialize_field("amountless", &true)?;
+        }
+
+        state.end()
+    }
+}
+
+struct MeltMethodSettingsVisitor;
+
+impl<'de> Visitor<'de> for MeltMethodSettingsVisitor {
+    type Value = MeltMethodSettings;
+
+    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+        formatter.write_str("a MeltMethodSettings structure")
+    }
+
+    fn visit_map<M>(self, mut map: M) -> Result<Self::Value, M::Error>
+    where
+        M: MapAccess<'de>,
+    {
+        let mut method: Option<PaymentMethod> = None;
+        let mut unit: Option<CurrencyUnit> = None;
+        let mut min_amount: Option<Amount> = None;
+        let mut max_amount: Option<Amount> = None;
+        let mut amountless: Option<bool> = None;
+
+        while let Some(key) = map.next_key::<String>()? {
+            match key.as_str() {
+                "method" => {
+                    if method.is_some() {
+                        return Err(de::Error::duplicate_field("method"));
+                    }
+                    method = Some(map.next_value()?);
+                }
+                "unit" => {
+                    if unit.is_some() {
+                        return Err(de::Error::duplicate_field("unit"));
+                    }
+                    unit = Some(map.next_value()?);
+                }
+                "min_amount" => {
+                    if min_amount.is_some() {
+                        return Err(de::Error::duplicate_field("min_amount"));
+                    }
+                    min_amount = Some(map.next_value()?);
+                }
+                "max_amount" => {
+                    if max_amount.is_some() {
+                        return Err(de::Error::duplicate_field("max_amount"));
+                    }
+                    max_amount = Some(map.next_value()?);
+                }
+                "amountless" => {
+                    if amountless.is_some() {
+                        return Err(de::Error::duplicate_field("amountless"));
+                    }
+                    amountless = Some(map.next_value()?);
+                }
+                "options" => {
+                    // If there are explicit options, they take precedence, except the amountless
+                    // field which we will handle specially
+                    let options: Option<MeltMethodOptions> = map.next_value()?;
+
+                    if let Some(MeltMethodOptions::Bolt11 {
+                        amountless: amountless_from_options,
+                    }) = options
+                    {
+                        // If we already found a top-level amountless, use that instead
+                        if amountless.is_none() {
+                            amountless = Some(amountless_from_options);
+                        }
+                    }
+                }
+                _ => {
+                    // Skip unknown fields
+                    let _: serde::de::IgnoredAny = map.next_value()?;
+                }
+            }
+        }
+
+        let method = method.ok_or_else(|| de::Error::missing_field("method"))?;
+        let unit = unit.ok_or_else(|| de::Error::missing_field("unit"))?;
+
+        // Create options based on the method and the amountless flag
+        let options = if method == PaymentMethod::Bolt11 && amountless.is_some() {
+            amountless.map(|amountless| MeltMethodOptions::Bolt11 { amountless })
+        } else {
+            None
+        };
+
+        Ok(MeltMethodSettings {
+            method,
+            unit,
+            min_amount,
+            max_amount,
+            options,
+        })
+    }
+}
+
+impl<'de> Deserialize<'de> for MeltMethodSettings {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        deserializer.deserialize_map(MeltMethodSettingsVisitor)
+    }
+}
+
+/// Mint Method settings options
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+#[serde(untagged)]
+pub enum MeltMethodOptions {
+    /// Bolt11 Options
+    Bolt11 {
+        /// Mint supports paying bolt11 amountless
+        amountless: bool,
+    },
 }
 
 impl Settings {
@@ -475,3 +354,73 @@ pub struct Settings {
     /// Minting disabled
     pub disabled: bool,
 }
+
+#[cfg(test)]
+mod tests {
+    use serde_json::{from_str, json, to_string};
+
+    use super::*;
+
+    #[test]
+    fn test_melt_method_settings_top_level_amountless() {
+        // Create JSON with top-level amountless
+        let json_str = r#"{
+            "method": "bolt11",
+            "unit": "sat",
+            "min_amount": 0,
+            "max_amount": 10000,
+            "amountless": true
+        }"#;
+
+        // Deserialize it
+        let settings: MeltMethodSettings = from_str(json_str).unwrap();
+
+        // Check that amountless was correctly moved to options
+        assert_eq!(settings.method, PaymentMethod::Bolt11);
+        assert_eq!(settings.unit, CurrencyUnit::Sat);
+        assert_eq!(settings.min_amount, Some(Amount::from(0)));
+        assert_eq!(settings.max_amount, Some(Amount::from(10000)));
+
+        match settings.options {
+            Some(MeltMethodOptions::Bolt11 { amountless }) => {
+                assert_eq!(amountless, true);
+            }
+            _ => panic!("Expected Bolt11 options with amountless = true"),
+        }
+
+        // Serialize it back
+        let serialized = to_string(&settings).unwrap();
+        let parsed: serde_json::Value = from_str(&serialized).unwrap();
+
+        // Verify the amountless is at the top level
+        assert_eq!(parsed["amountless"], json!(true));
+    }
+
+    #[test]
+    fn test_both_amountless_locations() {
+        // Create JSON with amountless in both places (top level and in options)
+        let json_str = r#"{
+            "method": "bolt11",
+            "unit": "sat",
+            "min_amount": 0,
+            "max_amount": 10000,
+            "amountless": true,
+            "options": {
+                "amountless": false
+            }
+        }"#;
+
+        // Deserialize it - top level should take precedence
+        let settings: MeltMethodSettings = from_str(json_str).unwrap();
+
+        match settings.options {
+            Some(MeltMethodOptions::Bolt11 { amountless }) => {
+                assert_eq!(
+                    amountless, true,
+                    "Top-level amountless should take precedence"
+                );
+            }
+            _ => panic!("Expected Bolt11 options with amountless = true"),
+        }
+    }
+}

+ 19 - 3
crates/cashu/src/nuts/nut06.rs

@@ -446,7 +446,8 @@ impl Nuts {
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash, Serialize, Deserialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct SupportedSettings {
-    supported: bool,
+    /// Setting supported
+    pub supported: bool,
 }
 
 /// Contact Info
@@ -470,6 +471,7 @@ impl ContactInfo {
 mod tests {
 
     use super::*;
+    use crate::nut04::MintMethodOptions;
 
     #[test]
     fn test_des_mint_into() {
@@ -552,7 +554,9 @@ mod tests {
         "unit": "sat",
         "min_amount": 0,
         "max_amount": 10000,
-        "description": true
+        "options": {
+            "description": true
+            }
         }
       ],
       "disabled": false
@@ -598,7 +602,9 @@ mod tests {
                 "unit": "sat",
                 "min_amount": 0,
                 "max_amount": 10000,
-                "description": true
+                "options": {
+                     "description": true
+                 }
                 }
             ],
             "disabled": false
@@ -624,6 +630,16 @@ mod tests {
 }"#;
         let mint_info: MintInfo = serde_json::from_str(mint_info_str).unwrap();
 
+        let t = mint_info
+            .nuts
+            .nut04
+            .get_settings(&crate::CurrencyUnit::Sat, &crate::PaymentMethod::Bolt11)
+            .unwrap();
+
+        let t = t.options.unwrap();
+
+        matches!(t, MintMethodOptions::Bolt11 { description: true });
+
         assert_eq!(info, mint_info);
     }
 }

+ 3 - 2
crates/cashu/src/nuts/nut07.rs

@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
 use super::nut01::PublicKey;
+use super::Witness;
 
 /// NUT07 Error
 #[derive(Debug, Error, PartialEq, Eq)]
@@ -49,7 +50,7 @@ impl fmt::Display for State {
             Self::PendingSpent => "PENDING_SPENT",
         };
 
-        write!(f, "{}", s)
+        write!(f, "{s}")
     }
 }
 
@@ -89,7 +90,7 @@ pub struct ProofState {
     /// State of proof
     pub state: State,
     /// Witness data if it is supplied
-    pub witness: Option<String>,
+    pub witness: Option<Witness>,
 }
 
 impl From<(PublicKey, State)> for ProofState {

+ 3 - 2
crates/cashu/src/nuts/nut08.rs

@@ -2,10 +2,11 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/08.md>
 
-use super::nut05::{MeltBolt11Request, MeltQuoteBolt11Response};
+use super::nut05::MeltRequest;
+use super::nut23::MeltQuoteBolt11Response;
 use crate::Amount;
 
-impl<Q> MeltBolt11Request<Q> {
+impl<Q> MeltRequest<Q> {
     /// Total output [`Amount`]
     pub fn output_amount(&self) -> Option<Amount> {
         self.outputs()

+ 158 - 13
crates/cashu/src/nuts/nut10.rs

@@ -2,8 +2,10 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/10.md>
 
+use std::fmt;
 use std::str::FromStr;
 
+use serde::de::{self, Deserializer, SeqAccess, Visitor};
 use serde::ser::SerializeTuple;
 use serde::{Deserialize, Serialize, Serializer};
 use thiserror::Error;
@@ -32,21 +34,53 @@ pub enum Kind {
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 pub struct SecretData {
     /// Unique random string
-    pub nonce: String,
+    nonce: String,
     /// Expresses the spending condition specific to each kind
-    pub data: String,
+    data: String,
     /// Additional data committed to and can be used for feature extensions
     #[serde(skip_serializing_if = "Option::is_none")]
-    pub tags: Option<Vec<Vec<String>>>,
+    tags: Option<Vec<Vec<String>>>,
+}
+
+impl SecretData {
+    /// Create new [`SecretData`]
+    pub fn new<S, V>(data: S, tags: Option<V>) -> Self
+    where
+        S: Into<String>,
+        V: Into<Vec<Vec<String>>>,
+    {
+        let nonce = crate::secret::Secret::generate().to_string();
+
+        Self {
+            nonce,
+            data: data.into(),
+            tags: tags.map(|v| v.into()),
+        }
+    }
+
+    /// Get the nonce
+    pub fn nonce(&self) -> &str {
+        &self.nonce
+    }
+
+    /// Get the data
+    pub fn data(&self) -> &str {
+        &self.data
+    }
+
+    /// Get the tags
+    pub fn tags(&self) -> Option<&Vec<Vec<String>>> {
+        self.tags.as_ref()
+    }
 }
 
 /// NUT10 Secret
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
 pub struct Secret {
     ///  Kind of the spending condition
-    pub kind: Kind,
+    kind: Kind,
     /// Secret Data
-    pub secret_data: SecretData,
+    secret_data: SecretData,
 }
 
 impl Secret {
@@ -56,15 +90,18 @@ impl Secret {
         S: Into<String>,
         V: Into<Vec<Vec<String>>>,
     {
-        let nonce = crate::secret::Secret::generate().to_string();
+        let secret_data = SecretData::new(data, tags);
+        Self { kind, secret_data }
+    }
 
-        let secret_data = SecretData {
-            nonce,
-            data: data.into(),
-            tags: tags.map(|v| v.into()),
-        };
+    /// Get the kind
+    pub fn kind(&self) -> Kind {
+        self.kind
+    }
 
-        Self { kind, secret_data }
+    /// Get the secret data
+    pub fn secret_data(&self) -> &SecretData {
+        &self.secret_data
     }
 }
 
@@ -94,9 +131,52 @@ impl TryFrom<Secret> for crate::secret::Secret {
     }
 }
 
+// Custom visitor for deserializing Secret
+struct SecretVisitor;
+
+impl<'de> Visitor<'de> for SecretVisitor {
+    type Value = Secret;
+
+    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+        formatter.write_str("a tuple with two elements: [Kind, SecretData]")
+    }
+
+    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+    where
+        A: SeqAccess<'de>,
+    {
+        // Deserialize the kind (first element)
+        let kind = seq
+            .next_element()?
+            .ok_or_else(|| de::Error::invalid_length(0, &self))?;
+
+        // Deserialize the secret_data (second element)
+        let secret_data = seq
+            .next_element()?
+            .ok_or_else(|| de::Error::invalid_length(1, &self))?;
+
+        // Make sure there are no additional elements
+        if seq.next_element::<serde::de::IgnoredAny>()?.is_some() {
+            return Err(de::Error::invalid_length(3, &self));
+        }
+
+        Ok(Secret { kind, secret_data })
+    }
+}
+
+impl<'de> Deserialize<'de> for Secret {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        deserializer.deserialize_seq(SecretVisitor)
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use std::assert_eq;
+    use std::str::FromStr;
 
     use super::*;
 
@@ -120,4 +200,69 @@ mod tests {
 
         assert_eq!(serde_json::to_string(&secret).unwrap(), secret_str);
     }
+
+    #[test]
+    fn test_secret_round_trip_serialization() {
+        // Create a Secret instance
+        let original_secret = Secret {
+            kind: Kind::P2PK,
+            secret_data: SecretData {
+                nonce: "5d11913ee0f92fefdc82a6764fd2457a".to_string(),
+                data: "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198"
+                    .to_string(),
+                tags: None,
+            },
+        };
+
+        // Serialize the Secret to JSON string
+        let serialized = serde_json::to_string(&original_secret).unwrap();
+
+        // Deserialize directly back to Secret using serde
+        let deserialized_secret: Secret = serde_json::from_str(&serialized).unwrap();
+
+        // Verify the direct serde serialization/deserialization round trip works
+        assert_eq!(original_secret, deserialized_secret);
+
+        // Also verify that the conversion to crate::secret::Secret works
+        let cashu_secret = crate::secret::Secret::from_str(&serialized).unwrap();
+        let deserialized_from_cashu: Secret = TryFrom::try_from(&cashu_secret).unwrap();
+        assert_eq!(original_secret, deserialized_from_cashu);
+    }
+
+    #[test]
+    fn test_htlc_secret_round_trip() {
+        // The reference BOLT11 invoice is:
+        // lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9
+
+        // Payment hash (typical 32 byte hash in hex format)
+        let payment_hash = "5c23fc3aec9d985bd5fc88ca8bceaccc52cf892715dd94b42b84f1b43350751e";
+
+        // Create a Secret instance with HTLC kind
+        let original_secret = Secret {
+            kind: Kind::HTLC,
+            secret_data: SecretData {
+                nonce: "7a9128b3f9612549f9278958337a5d7f".to_string(),
+                data: payment_hash.to_string(),
+                tags: None,
+            },
+        };
+
+        // Serialize the Secret to JSON string
+        let serialized = serde_json::to_string(&original_secret).unwrap();
+
+        // Validate serialized format
+        let expected_json = format!(
+            r#"["HTLC",{{"nonce":"7a9128b3f9612549f9278958337a5d7f","data":"{}"}}]"#,
+            payment_hash
+        );
+        assert_eq!(serialized, expected_json);
+
+        // Deserialize directly back to Secret using serde
+        let deserialized_secret: Secret = serde_json::from_str(&serialized).unwrap();
+
+        // Verify the direct serde serialization/deserialization round trip works
+        assert_eq!(original_secret, deserialized_secret);
+        assert_eq!(deserialized_secret.kind, Kind::HTLC);
+        assert_eq!(deserialized_secret.secret_data.data, payment_hash);
+    }
 }

+ 34 - 14
crates/cashu/src/nuts/nut11/mod.rs

@@ -127,8 +127,12 @@ impl Proof {
     /// Verify P2PK signature on [Proof]
     pub fn verify_p2pk(&self) -> Result<(), Error> {
         let secret: Nut10Secret = self.secret.clone().try_into()?;
-        let spending_conditions: Conditions =
-            secret.secret_data.tags.unwrap_or_default().try_into()?;
+        let spending_conditions: Conditions = secret
+            .secret_data()
+            .tags()
+            .cloned()
+            .unwrap_or_default()
+            .try_into()?;
         let msg: &[u8] = self.secret.as_bytes();
 
         let mut verified_pubkeys = HashSet::new();
@@ -142,8 +146,8 @@ impl Proof {
 
         let mut pubkeys = spending_conditions.pubkeys.clone().unwrap_or_default();
 
-        if secret.kind.eq(&Kind::P2PK) {
-            pubkeys.push(PublicKey::from_str(&secret.secret_data.data)?);
+        if secret.kind().eq(&Kind::P2PK) {
+            pubkeys.push(PublicKey::from_str(secret.secret_data().data())?);
         }
 
         for signature in witness_signatures.iter() {
@@ -312,6 +316,16 @@ impl SpendingConditions {
         })
     }
 
+    /// New HTLC [SpendingConditions] from a hash directly instead of preimage
+    pub fn new_htlc_hash(hash: &str, conditions: Option<Conditions>) -> Result<Self, Error> {
+        let hash = Sha256Hash::from_str(hash).map_err(|_| Error::InvalidHash)?;
+
+        Ok(Self::HTLCConditions {
+            data: hash,
+            conditions,
+        })
+    }
+
     /// New P2PK [SpendingConditions]
     pub fn new_p2pk(pubkey: PublicKey, conditions: Option<Conditions>) -> Self {
         Self::P2PKConditions {
@@ -384,15 +398,21 @@ impl TryFrom<&Secret> for SpendingConditions {
 impl TryFrom<Nut10Secret> for SpendingConditions {
     type Error = Error;
     fn try_from(secret: Nut10Secret) -> Result<SpendingConditions, Error> {
-        match secret.kind {
+        match secret.kind() {
             Kind::P2PK => Ok(SpendingConditions::P2PKConditions {
-                data: PublicKey::from_str(&secret.secret_data.data)?,
-                conditions: secret.secret_data.tags.and_then(|t| t.try_into().ok()),
+                data: PublicKey::from_str(secret.secret_data().data())?,
+                conditions: secret
+                    .secret_data()
+                    .tags()
+                    .and_then(|t| t.clone().try_into().ok()),
             }),
             Kind::HTLC => Ok(Self::HTLCConditions {
-                data: Sha256Hash::from_str(&secret.secret_data.data)
+                data: Sha256Hash::from_str(secret.secret_data().data())
                     .map_err(|_| Error::InvalidHash)?,
-                conditions: secret.secret_data.tags.and_then(|t| t.try_into().ok()),
+                conditions: secret
+                    .secret_data()
+                    .tags()
+                    .and_then(|t| t.clone().try_into().ok()),
             }),
         }
     }
@@ -575,7 +595,7 @@ impl fmt::Display for TagKind {
             Self::Locktime => write!(f, "locktime"),
             Self::Refund => write!(f, "refund"),
             Self::Pubkeys => write!(f, "pubkeys"),
-            Self::Custom(kind) => write!(f, "{}", kind),
+            Self::Custom(kind) => write!(f, "{kind}"),
         }
     }
 }
@@ -640,14 +660,14 @@ pub fn enforce_sig_flag(proofs: Proofs) -> EnforceSigFlag {
     let mut sigs_required = 1;
     for proof in proofs {
         if let Ok(secret) = Nut10Secret::try_from(proof.secret) {
-            if secret.kind.eq(&Kind::P2PK) {
-                if let Ok(verifying_key) = PublicKey::from_str(&secret.secret_data.data) {
+            if secret.kind().eq(&Kind::P2PK) {
+                if let Ok(verifying_key) = PublicKey::from_str(secret.secret_data().data()) {
                     pubkeys.insert(verifying_key);
                 }
             }
 
-            if let Some(tags) = secret.secret_data.tags {
-                if let Ok(conditions) = Conditions::try_from(tags) {
+            if let Some(tags) = secret.secret_data().tags() {
+                if let Ok(conditions) = Conditions::try_from(tags.clone()) {
                     if conditions.sig_flag.eq(&SigFlag::SigAll) {
                         sig_flag = SigFlag::SigAll;
                     }

+ 6 - 4
crates/cashu/src/nuts/nut14/mod.rs

@@ -66,8 +66,10 @@ impl Proof {
     /// Verify HTLC
     pub fn verify_htlc(&self) -> Result<(), Error> {
         let secret: Secret = self.secret.clone().try_into()?;
-        let conditions: Option<Conditions> =
-            secret.secret_data.tags.and_then(|c| c.try_into().ok());
+        let conditions: Option<Conditions> = secret
+            .secret_data()
+            .tags()
+            .and_then(|c| c.clone().try_into().ok());
 
         let htlc_witness = match &self.witness {
             Some(Witness::HTLCWitness(witness)) => witness,
@@ -118,12 +120,12 @@ impl Proof {
             }
         }
 
-        if secret.kind.ne(&super::Kind::HTLC) {
+        if secret.kind().ne(&super::Kind::HTLC) {
             return Err(Error::IncorrectSecretKind);
         }
 
         let hash_lock =
-            Sha256Hash::from_str(&secret.secret_data.data).map_err(|_| Error::InvalidHash)?;
+            Sha256Hash::from_str(secret.secret_data().data()).map_err(|_| Error::InvalidHash)?;
 
         let preimage_hash = Sha256Hash::hash(htlc_witness.preimage.as_bytes());
 

+ 59 - 2
crates/cashu/src/nuts/nut15.rs

@@ -2,7 +2,7 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/15.md>
 
-use serde::{Deserialize, Serialize};
+use serde::{Deserialize, Deserializer, Serialize};
 
 use super::{CurrencyUnit, PaymentMethod};
 use crate::Amount;
@@ -27,9 +27,66 @@ pub struct MppMethodSettings {
 }
 
 /// Mpp Settings
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = nut15::Settings))]
 pub struct Settings {
     /// Method settings
     pub methods: Vec<MppMethodSettings>,
 }
+
+// Custom deserialization to handle both array and object formats
+impl<'de> Deserialize<'de> for Settings {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        #[derive(Deserialize)]
+        #[serde(untagged)]
+        enum SettingsFormat {
+            Array(Vec<MppMethodSettings>),
+            Object { methods: Vec<MppMethodSettings> },
+        }
+
+        let format = SettingsFormat::deserialize(deserializer)?;
+        match format {
+            SettingsFormat::Array(methods) => Ok(Settings { methods }),
+            SettingsFormat::Object { methods } => Ok(Settings { methods }),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::PaymentMethod;
+
+    #[test]
+    fn test_nut15_settings_deserialization() {
+        // Test array format
+        let array_json = r#"[{"method":"bolt11","unit":"sat"}]"#;
+        let settings: Settings = serde_json::from_str(array_json).unwrap();
+        assert_eq!(settings.methods.len(), 1);
+        assert_eq!(settings.methods[0].method, PaymentMethod::Bolt11);
+        assert_eq!(settings.methods[0].unit, CurrencyUnit::Sat);
+
+        // Test object format
+        let object_json = r#"{"methods":[{"method":"bolt11","unit":"sat"}]}"#;
+        let settings: Settings = serde_json::from_str(object_json).unwrap();
+        assert_eq!(settings.methods.len(), 1);
+        assert_eq!(settings.methods[0].method, PaymentMethod::Bolt11);
+        assert_eq!(settings.methods[0].unit, CurrencyUnit::Sat);
+    }
+
+    #[test]
+    fn test_nut15_settings_serialization() {
+        let settings = Settings {
+            methods: vec![MppMethodSettings {
+                method: PaymentMethod::Bolt11,
+                unit: CurrencyUnit::Sat,
+            }],
+        };
+
+        let json = serde_json::to_string(&settings).unwrap();
+        assert_eq!(json, r#"{"methods":[{"method":"bolt11","unit":"sat"}]}"#);
+    }
+}

+ 30 - 13
crates/cashu/src/nuts/nut17/mod.rs

@@ -42,34 +42,51 @@ pub struct SupportedMethods {
     /// Unit
     pub unit: CurrencyUnit,
     /// Command
-    pub commands: Vec<String>,
+    pub commands: Vec<WsCommand>,
 }
 
 impl SupportedMethods {
     /// Create [`SupportedMethods`]
-    pub fn new(method: PaymentMethod, unit: CurrencyUnit) -> Self {
+    pub fn new(method: PaymentMethod, unit: CurrencyUnit, commands: Vec<WsCommand>) -> Self {
         Self {
             method,
             unit,
-            commands: Vec::new(),
+            commands,
         }
     }
-}
 
-impl Default for SupportedMethods {
-    fn default() -> Self {
-        SupportedMethods {
+    /// Create [`SupportedMethods`] for Bolt11 with all supported commands
+    pub fn default_bolt11(unit: CurrencyUnit) -> Self {
+        let commands = vec![
+            WsCommand::Bolt11MintQuote,
+            WsCommand::Bolt11MeltQuote,
+            WsCommand::ProofState,
+        ];
+
+        Self {
             method: PaymentMethod::Bolt11,
-            unit: CurrencyUnit::Sat,
-            commands: vec![
-                "bolt11_mint_quote".to_owned(),
-                "bolt11_melt_quote".to_owned(),
-                "proof_state".to_owned(),
-            ],
+            unit,
+            commands,
         }
     }
 }
 
+/// WebSocket commands supported by the Cashu mint
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+#[serde(rename_all = "snake_case")]
+pub enum WsCommand {
+    /// Command to request a Lightning invoice for minting tokens
+    #[serde(rename = "bolt11_mint_quote")]
+    Bolt11MintQuote,
+    /// Command to request a Lightning payment for melting tokens
+    #[serde(rename = "bolt11_melt_quote")]
+    Bolt11MeltQuote,
+    /// Command to check the state of a proof
+    #[serde(rename = "proof_state")]
+    ProofState,
+}
+
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(bound = "T: Serialize + DeserializeOwned")]
 #[serde(untagged)]

+ 17 - 0
crates/cashu/src/nuts/nut18/error.rs

@@ -0,0 +1,17 @@
+//! Error types for NUT-18: Payment Requests
+
+use thiserror::Error;
+
+/// NUT18 Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Invalid Prefix
+    #[error("Invalid Prefix")]
+    InvalidPrefix,
+    /// Ciborium error
+    #[error(transparent)]
+    CiboriumError(#[from] ciborium::de::Error<std::io::Error>),
+    /// Base64 error
+    #[error(transparent)]
+    Base64Error(#[from] bitcoin::base64::DecodeError),
+}

+ 11 - 0
crates/cashu/src/nuts/nut18/mod.rs

@@ -0,0 +1,11 @@
+//! NUT-18 module imports
+
+pub mod error;
+pub mod payment_request;
+pub mod secret;
+pub mod transport;
+
+pub use error::Error;
+pub use payment_request::{PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload};
+pub use secret::{Nut10SecretRequest, SecretDataRequest};
+pub use transport::{Transport, TransportBuilder, TransportType};

+ 200 - 171
crates/cashu/src/nuts/nut18.rs → crates/cashu/src/nuts/nut18/payment_request.rs

@@ -3,149 +3,20 @@
 //! <https://github.com/cashubtc/nuts/blob/main/18.md>
 
 use std::fmt;
+use std::ops::Not;
 use std::str::FromStr;
 
 use bitcoin::base64::engine::{general_purpose, GeneralPurpose};
 use bitcoin::base64::{alphabet, Engine};
 use serde::{Deserialize, Serialize};
-use thiserror::Error;
 
-use super::{CurrencyUnit, Proofs};
+use super::{Error, Nut10SecretRequest, Transport};
 use crate::mint_url::MintUrl;
+use crate::nuts::{CurrencyUnit, Proofs};
 use crate::Amount;
 
 const PAYMENT_REQUEST_PREFIX: &str = "creqA";
 
-/// NUT18 Error
-#[derive(Debug, Error)]
-pub enum Error {
-    /// Invalid Prefix
-    #[error("Invalid Prefix")]
-    InvalidPrefix,
-    /// Ciborium error
-    #[error(transparent)]
-    CiboriumError(#[from] ciborium::de::Error<std::io::Error>),
-    /// Base64 error
-    #[error(transparent)]
-    Base64Error(#[from] bitcoin::base64::DecodeError),
-}
-
-/// Transport Type
-#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
-pub enum TransportType {
-    /// Nostr
-    #[serde(rename = "nostr")]
-    Nostr,
-    /// Http post
-    #[serde(rename = "post")]
-    HttpPost,
-}
-
-impl fmt::Display for TransportType {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        use serde::ser::Error;
-        let t = serde_json::to_string(self).map_err(|e| fmt::Error::custom(e.to_string()))?;
-        write!(f, "{}", t)
-    }
-}
-
-impl FromStr for TransportType {
-    type Err = Error;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        match s.to_lowercase().as_str() {
-            "nostr" => Ok(Self::Nostr),
-            "post" => Ok(Self::HttpPost),
-            _ => Err(Error::InvalidPrefix),
-        }
-    }
-}
-
-impl FromStr for Transport {
-    type Err = Error;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        let decode_config = general_purpose::GeneralPurposeConfig::new()
-            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
-        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
-
-        Ok(ciborium::from_reader(&decoded[..])?)
-    }
-}
-
-/// Transport
-#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Transport {
-    /// Type
-    #[serde(rename = "t")]
-    pub _type: TransportType,
-    /// Target
-    #[serde(rename = "a")]
-    pub target: String,
-    /// Tags
-    #[serde(rename = "g")]
-    pub tags: Option<Vec<Vec<String>>>,
-}
-
-impl Transport {
-    /// Create a new TransportBuilder
-    pub fn builder() -> TransportBuilder {
-        TransportBuilder::default()
-    }
-}
-
-/// Builder for Transport
-#[derive(Debug, Default, Clone)]
-pub struct TransportBuilder {
-    _type: Option<TransportType>,
-    target: Option<String>,
-    tags: Option<Vec<Vec<String>>>,
-}
-
-impl TransportBuilder {
-    /// Set transport type
-    pub fn transport_type(mut self, transport_type: TransportType) -> Self {
-        self._type = Some(transport_type);
-        self
-    }
-
-    /// Set target
-    pub fn target<S: Into<String>>(mut self, target: S) -> Self {
-        self.target = Some(target.into());
-        self
-    }
-
-    /// Add a tag
-    pub fn add_tag(mut self, tag: Vec<String>) -> Self {
-        self.tags.get_or_insert_with(Vec::new).push(tag);
-        self
-    }
-
-    /// Set tags
-    pub fn tags(mut self, tags: Vec<Vec<String>>) -> Self {
-        self.tags = Some(tags);
-        self
-    }
-
-    /// Build the Transport
-    pub fn build(self) -> Result<Transport, &'static str> {
-        let _type = self._type.ok_or("Transport type is required")?;
-        let target = self.target.ok_or("Target is required")?;
-
-        Ok(Transport {
-            _type,
-            target,
-            tags: self.tags,
-        })
-    }
-}
-
-impl AsRef<String> for Transport {
-    fn as_ref(&self) -> &String {
-        &self.target
-    }
-}
-
 /// Payment Request
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
 pub struct PaymentRequest {
@@ -169,7 +40,10 @@ pub struct PaymentRequest {
     pub description: Option<String>,
     /// Transport
     #[serde(rename = "t")]
-    pub transports: Vec<Transport>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub transports: Option<Vec<Transport>>,
+    /// Nut10
+    pub nut10: Option<Nut10SecretRequest>,
 }
 
 impl PaymentRequest {
@@ -179,6 +53,38 @@ impl PaymentRequest {
     }
 }
 
+impl AsRef<Option<String>> for PaymentRequest {
+    fn as_ref(&self) -> &Option<String> {
+        &self.payment_id
+    }
+}
+
+impl fmt::Display for PaymentRequest {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        use serde::ser::Error;
+        let mut data = Vec::new();
+        ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?;
+        let encoded = general_purpose::URL_SAFE.encode(data);
+        write!(f, "{PAYMENT_REQUEST_PREFIX}{encoded}")
+    }
+}
+
+impl FromStr for PaymentRequest {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let s = s
+            .strip_prefix(PAYMENT_REQUEST_PREFIX)
+            .ok_or(Error::InvalidPrefix)?;
+
+        let decode_config = general_purpose::GeneralPurposeConfig::new()
+            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
+        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
+
+        Ok(ciborium::from_reader(&decoded[..])?)
+    }
+}
+
 /// Builder for PaymentRequest
 #[derive(Debug, Default, Clone)]
 pub struct PaymentRequestBuilder {
@@ -189,6 +95,7 @@ pub struct PaymentRequestBuilder {
     mints: Option<Vec<MintUrl>>,
     description: Option<String>,
     transports: Vec<Transport>,
+    nut10: Option<Nut10SecretRequest>,
 }
 
 impl PaymentRequestBuilder {
@@ -252,8 +159,16 @@ impl PaymentRequestBuilder {
         self
     }
 
+    /// Set Nut10 secret
+    pub fn nut10(mut self, nut10: Nut10SecretRequest) -> Self {
+        self.nut10 = Some(nut10);
+        self
+    }
+
     /// Build the PaymentRequest
     pub fn build(self) -> PaymentRequest {
+        let transports = self.transports.is_empty().not().then_some(self.transports);
+
         PaymentRequest {
             payment_id: self.payment_id,
             amount: self.amount,
@@ -261,43 +176,12 @@ impl PaymentRequestBuilder {
             single_use: self.single_use,
             mints: self.mints,
             description: self.description,
-            transports: self.transports,
+            transports,
+            nut10: self.nut10,
         }
     }
 }
 
-impl AsRef<Option<String>> for PaymentRequest {
-    fn as_ref(&self) -> &Option<String> {
-        &self.payment_id
-    }
-}
-
-impl fmt::Display for PaymentRequest {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        use serde::ser::Error;
-        let mut data = Vec::new();
-        ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?;
-        let encoded = general_purpose::URL_SAFE.encode(data);
-        write!(f, "{}{}", PAYMENT_REQUEST_PREFIX, encoded)
-    }
-}
-
-impl FromStr for PaymentRequest {
-    type Err = Error;
-
-    fn from_str(s: &str) -> Result<Self, Self::Err> {
-        let s = s
-            .strip_prefix(PAYMENT_REQUEST_PREFIX)
-            .ok_or(Error::InvalidPrefix)?;
-
-        let decode_config = general_purpose::GeneralPurposeConfig::new()
-            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
-        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
-
-        Ok(ciborium::from_reader(&decoded[..])?)
-    }
-}
-
 /// Payment Request
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
 pub struct PaymentRequestPayload {
@@ -317,7 +201,12 @@ pub struct PaymentRequestPayload {
 mod tests {
     use std::str::FromStr;
 
+    use lightning_invoice::Bolt11Invoice;
+
     use super::*;
+    use crate::nuts::nut10::Kind;
+    use crate::nuts::SpendingConditions;
+    use crate::TransportType;
 
     const PAYMENT_REQUEST: &str = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U=";
 
@@ -334,7 +223,8 @@ mod tests {
         );
         assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
 
-        let transport = req.transports.first().unwrap();
+        let transport = req.transports.unwrap();
+        let transport = transport.first().unwrap();
 
         let expected_transport = Transport {_type: TransportType::Nostr, target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])};
 
@@ -354,7 +244,8 @@ mod tests {
                 .parse()
                 .expect("valid mint url")]),
             description: None,
-            transports: vec![transport.clone()],
+            transports: Some(vec![transport.clone()]),
+            nut10: None,
         };
 
         let request_str = request.to_string();
@@ -370,7 +261,8 @@ mod tests {
         );
         assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
 
-        let t = req.transports.first().unwrap();
+        let t = req.transports.unwrap();
+        let t = t.first().unwrap();
         assert_eq!(&transport, t);
     }
 
@@ -400,7 +292,8 @@ mod tests {
         assert_eq!(request.unit.clone().unwrap(), CurrencyUnit::Sat);
         assert_eq!(request.mints.clone().unwrap(), vec![mint_url]);
 
-        let t = request.transports.first().unwrap();
+        let t = request.transports.clone().unwrap();
+        let t = t.first().unwrap();
         assert_eq!(&transport, t);
 
         // Test serialization and deserialization
@@ -431,7 +324,143 @@ mod tests {
         );
 
         // Test error case - missing required fields
-        let result = TransportBuilder::default().build();
+        let result = crate::nuts::nut18::transport::TransportBuilder::default().build();
         assert!(result.is_err());
     }
+
+    #[test]
+    fn test_nut10_secret_request() {
+        use crate::nuts::nut10::Kind;
+
+        // Create a Nut10SecretRequest
+        let secret_request = Nut10SecretRequest::new(
+            Kind::P2PK,
+            "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198",
+            Some(vec![vec!["key".to_string(), "value".to_string()]]),
+        );
+
+        // Convert to a full Nut10Secret
+        let full_secret: crate::nuts::Nut10Secret = secret_request.clone().into();
+
+        // Check conversion
+        assert_eq!(full_secret.kind(), Kind::P2PK);
+        assert_eq!(
+            full_secret.secret_data().data(),
+            "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198"
+        );
+        assert_eq!(
+            full_secret.secret_data().tags().clone(),
+            Some(vec![vec!["key".to_string(), "value".to_string()]]).as_ref()
+        );
+
+        // Convert back to Nut10SecretRequest
+        let converted_back = Nut10SecretRequest::from(full_secret);
+
+        // Check round-trip conversion
+        assert_eq!(converted_back.kind, secret_request.kind);
+        assert_eq!(
+            converted_back.secret_data.data,
+            secret_request.secret_data.data
+        );
+        assert_eq!(
+            converted_back.secret_data.tags,
+            secret_request.secret_data.tags
+        );
+
+        // Test in PaymentRequest builder
+        let payment_request = PaymentRequest::builder()
+            .payment_id("test123")
+            .amount(Amount::from(100))
+            .nut10(secret_request.clone())
+            .build();
+
+        assert_eq!(payment_request.nut10, Some(secret_request));
+    }
+
+    #[test]
+    fn test_nut10_secret_request_multiple_mints() {
+        let mint_urls = [
+            "https://8333.space:3338",
+            "https://mint.minibits.cash/Bitcoin",
+            "https://antifiat.cash",
+            "https://mint.macadamia.cash",
+        ]
+        .iter()
+        .map(|m| MintUrl::from_str(m).unwrap())
+        .collect();
+
+        let payment_request = PaymentRequestBuilder::default()
+            .unit(CurrencyUnit::Sat)
+            .amount(10)
+            .mints(mint_urls)
+            .build();
+
+        let payment_request_str = payment_request.to_string();
+
+        let r = PaymentRequest::from_str(&payment_request_str).unwrap();
+
+        assert_eq!(payment_request, r);
+    }
+
+    #[test]
+    fn test_nut10_secret_request_htlc() {
+        let bolt11 = "lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9";
+
+        let bolt11 = Bolt11Invoice::from_str(bolt11).unwrap();
+
+        let nut10 = SpendingConditions::HTLCConditions {
+            data: bolt11.payment_hash().clone(),
+            conditions: None,
+        };
+
+        let payment_request = PaymentRequestBuilder::default()
+            .unit(CurrencyUnit::Sat)
+            .amount(10)
+            .nut10(nut10.into())
+            .build();
+
+        let payment_request_str = payment_request.to_string();
+
+        let r = PaymentRequest::from_str(&payment_request_str).unwrap();
+
+        assert_eq!(payment_request, r);
+    }
+
+    #[test]
+    fn test_nut10_secret_request_p2pk() {
+        // Use a public key for P2PK condition
+        let pubkey_hex = "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198";
+
+        // Create P2PK spending conditions
+        let nut10 = SpendingConditions::P2PKConditions {
+            data: crate::nuts::PublicKey::from_str(pubkey_hex).unwrap(),
+            conditions: None,
+        };
+
+        // Build payment request with P2PK condition
+        let payment_request = PaymentRequestBuilder::default()
+            .unit(CurrencyUnit::Sat)
+            .amount(10)
+            .payment_id("test-p2pk-id")
+            .description("P2PK locked payment")
+            .nut10(nut10.into())
+            .build();
+
+        // Convert to string representation
+        let payment_request_str = payment_request.to_string();
+
+        // Parse back from string
+        let decoded_request = PaymentRequest::from_str(&payment_request_str).unwrap();
+
+        // Verify round-trip serialization
+        assert_eq!(payment_request, decoded_request);
+
+        // Verify the P2PK data was preserved correctly
+        if let Some(nut10_secret) = decoded_request.nut10 {
+            assert_eq!(nut10_secret.kind, Kind::P2PK);
+            assert_eq!(nut10_secret.secret_data.data, pubkey_hex);
+        } else {
+            panic!("NUT10 secret data missing in decoded payment request");
+        }
+    }
 }

+ 137 - 0
crates/cashu/src/nuts/nut18/secret.rs

@@ -0,0 +1,137 @@
+//! Secret types for NUT-18: Payment Requests
+
+use std::fmt;
+
+use serde::de::{self, Deserializer, SeqAccess, Visitor};
+use serde::ser::{SerializeTuple, Serializer};
+use serde::{Deserialize, Serialize};
+
+use crate::nuts::nut10::Kind;
+use crate::nuts::{Nut10Secret, SpendingConditions};
+
+/// Secret Data without nonce for payment requests
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct SecretDataRequest {
+    /// Expresses the spending condition specific to each kind
+    pub data: String,
+    /// Additional data committed to and can be used for feature extensions
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub tags: Option<Vec<Vec<String>>>,
+}
+
+/// Nut10Secret without nonce for payment requests
+#[derive(Debug, Clone, Hash, PartialEq, Eq)]
+pub struct Nut10SecretRequest {
+    /// Kind of the spending condition
+    pub kind: Kind,
+    /// Secret Data without nonce
+    pub secret_data: SecretDataRequest,
+}
+
+impl Nut10SecretRequest {
+    /// Create a new Nut10SecretRequest
+    pub fn new<S, V>(kind: Kind, data: S, tags: Option<V>) -> Self
+    where
+        S: Into<String>,
+        V: Into<Vec<Vec<String>>>,
+    {
+        let secret_data = SecretDataRequest {
+            data: data.into(),
+            tags: tags.map(|v| v.into()),
+        };
+
+        Self { kind, secret_data }
+    }
+}
+
+impl From<Nut10Secret> for Nut10SecretRequest {
+    fn from(secret: Nut10Secret) -> Self {
+        let secret_data = SecretDataRequest {
+            data: secret.secret_data().data().to_string(),
+            tags: secret.secret_data().tags().cloned(),
+        };
+
+        Self {
+            kind: secret.kind(),
+            secret_data,
+        }
+    }
+}
+
+impl From<Nut10SecretRequest> for Nut10Secret {
+    fn from(value: Nut10SecretRequest) -> Self {
+        Self::new(value.kind, value.secret_data.data, value.secret_data.tags)
+    }
+}
+
+impl From<SpendingConditions> for Nut10SecretRequest {
+    fn from(conditions: SpendingConditions) -> Self {
+        match conditions {
+            SpendingConditions::P2PKConditions { data, conditions } => {
+                Self::new(Kind::P2PK, data.to_hex(), conditions)
+            }
+            SpendingConditions::HTLCConditions { data, conditions } => {
+                Self::new(Kind::HTLC, data.to_string(), conditions)
+            }
+        }
+    }
+}
+
+impl Serialize for Nut10SecretRequest {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        // Create a tuple representing the struct fields
+        let secret_tuple = (&self.kind, &self.secret_data);
+
+        // Serialize the tuple as a JSON array
+        let mut s = serializer.serialize_tuple(2)?;
+
+        s.serialize_element(&secret_tuple.0)?;
+        s.serialize_element(&secret_tuple.1)?;
+        s.end()
+    }
+}
+
+// Custom visitor for deserializing Secret
+struct SecretVisitor;
+
+impl<'de> Visitor<'de> for SecretVisitor {
+    type Value = Nut10SecretRequest;
+
+    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+        formatter.write_str("a tuple with two elements: [Kind, SecretData]")
+    }
+
+    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
+    where
+        A: SeqAccess<'de>,
+    {
+        // Deserialize the kind (first element)
+        let kind = seq
+            .next_element()?
+            .ok_or_else(|| de::Error::invalid_length(0, &self))?;
+
+        // Deserialize the secret_data (second element)
+        let secret_data = seq
+            .next_element()?
+            .ok_or_else(|| de::Error::invalid_length(1, &self))?;
+
+        // Make sure there are no additional elements
+        if seq.next_element::<serde::de::IgnoredAny>()?.is_some() {
+            return Err(de::Error::invalid_length(3, &self));
+        }
+
+        Ok(Nut10SecretRequest { kind, secret_data })
+    }
+}
+
+impl<'de> Deserialize<'de> for Nut10SecretRequest {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        deserializer.deserialize_seq(SecretVisitor)
+    }
+}

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

@@ -0,0 +1,126 @@
+//! Transport types for NUT-18: Payment Requests
+
+use std::fmt;
+use std::str::FromStr;
+
+use bitcoin::base64::engine::{general_purpose, GeneralPurpose};
+use bitcoin::base64::{alphabet, Engine};
+use serde::{Deserialize, Serialize};
+
+use crate::nuts::nut18::error::Error;
+
+/// Transport Type
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub enum TransportType {
+    /// Nostr
+    #[serde(rename = "nostr")]
+    Nostr,
+    /// Http post
+    #[serde(rename = "post")]
+    HttpPost,
+}
+
+impl fmt::Display for TransportType {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        use serde::ser::Error;
+        let t = serde_json::to_string(self).map_err(|e| fmt::Error::custom(e.to_string()))?;
+        write!(f, "{t}")
+    }
+}
+
+impl FromStr for TransportType {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        match s.to_lowercase().as_str() {
+            "nostr" => Ok(Self::Nostr),
+            "post" => Ok(Self::HttpPost),
+            _ => Err(Error::InvalidPrefix),
+        }
+    }
+}
+
+/// Transport
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Transport {
+    /// Type
+    #[serde(rename = "t")]
+    pub _type: TransportType,
+    /// Target
+    #[serde(rename = "a")]
+    pub target: String,
+    /// Tags
+    #[serde(rename = "g")]
+    pub tags: Option<Vec<Vec<String>>>,
+}
+
+impl Transport {
+    /// Create a new TransportBuilder
+    pub fn builder() -> TransportBuilder {
+        TransportBuilder::default()
+    }
+}
+
+impl FromStr for Transport {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let decode_config = general_purpose::GeneralPurposeConfig::new()
+            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
+        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
+
+        Ok(ciborium::from_reader(&decoded[..])?)
+    }
+}
+
+/// Builder for Transport
+#[derive(Debug, Default, Clone)]
+pub struct TransportBuilder {
+    _type: Option<TransportType>,
+    target: Option<String>,
+    tags: Option<Vec<Vec<String>>>,
+}
+
+impl TransportBuilder {
+    /// Set transport type
+    pub fn transport_type(mut self, transport_type: TransportType) -> Self {
+        self._type = Some(transport_type);
+        self
+    }
+
+    /// Set target
+    pub fn target<S: Into<String>>(mut self, target: S) -> Self {
+        self.target = Some(target.into());
+        self
+    }
+
+    /// Add a tag
+    pub fn add_tag(mut self, tag: Vec<String>) -> Self {
+        self.tags.get_or_insert_with(Vec::new).push(tag);
+        self
+    }
+
+    /// Set tags
+    pub fn tags(mut self, tags: Vec<Vec<String>>) -> Self {
+        self.tags = Some(tags);
+        self
+    }
+
+    /// Build the Transport
+    pub fn build(self) -> Result<Transport, &'static str> {
+        let _type = self._type.ok_or("Transport type is required")?;
+        let target = self.target.ok_or("Target is required")?;
+
+        Ok(Transport {
+            _type,
+            target,
+            tags: self.tags,
+        })
+    }
+}
+
+impl AsRef<String> for Transport {
+    fn as_ref(&self) -> &String {
+        &self.target
+    }
+}

+ 8 - 8
crates/cashu/src/nuts/nut20.rs

@@ -5,7 +5,7 @@ use std::str::FromStr;
 use bitcoin::secp256k1::schnorr::Signature;
 use thiserror::Error;
 
-use super::{MintBolt11Request, PublicKey, SecretKey};
+use super::{MintRequest, PublicKey, SecretKey};
 
 /// Nut19 Error
 #[derive(Debug, Error)]
@@ -21,7 +21,7 @@ pub enum Error {
     NUT01(#[from] crate::nuts::nut01::Error),
 }
 
-impl<Q> MintBolt11Request<Q>
+impl<Q> MintRequest<Q>
 where
     Q: ToString,
 {
@@ -46,7 +46,7 @@ where
         msg
     }
 
-    /// Sign [`MintBolt11Request`]
+    /// Sign [`MintRequest`]
     pub fn sign(&mut self, secret_key: SecretKey) -> Result<(), Error> {
         let msg = self.msg_to_sign();
 
@@ -57,7 +57,7 @@ where
         Ok(())
     }
 
-    /// Verify signature on [`MintBolt11Request`]
+    /// Verify signature on [`MintRequest`]
     pub fn verify_signature(&self, pubkey: PublicKey) -> Result<(), Error> {
         let signature = self.signature.as_ref().ok_or(Error::SignatureMissing)?;
 
@@ -80,7 +80,7 @@ mod tests {
 
     #[test]
     fn test_msg_to_sign() {
-        let request: MintBolt11Request<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}],"signature":"cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3"}"#).unwrap();
+        let request: MintRequest<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}],"signature":"cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3"}"#).unwrap();
 
         // let expected_msg_to_sign = "9d745270-1405-46de-b5c5-e2762b4f5e000342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c31102be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b5302209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79";
 
@@ -118,14 +118,14 @@ mod tests {
         )
         .unwrap();
 
-        let request: MintBolt11Request<Uuid> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}], "signature": "d4b386f21f7aa7172f0994ee6e4dd966539484247ea71c99b81b8e09b1bb2acbc0026a43c221fd773471dc30d6a32b04692e6837ddaccf0830a63128308e4ee0"}"#).unwrap();
+        let request: MintRequest<Uuid> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}], "signature": "d4b386f21f7aa7172f0994ee6e4dd966539484247ea71c99b81b8e09b1bb2acbc0026a43c221fd773471dc30d6a32b04692e6837ddaccf0830a63128308e4ee0"}"#).unwrap();
 
         assert!(request.verify_signature(pubkey).is_ok());
     }
 
     #[test]
     fn test_mint_request_signature() {
-        let mut request: MintBolt11Request<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}]}"#).unwrap();
+        let mut request: MintRequest<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}]}"#).unwrap();
 
         let secret =
             SecretKey::from_hex("50d7fd7aa2b2fe4607f41f4ce6f8794fc184dd47b8cdfbe4b3d1249aa02d35aa")
@@ -143,7 +143,7 @@ mod tests {
         )
         .unwrap();
 
-        let request: MintBolt11Request<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}],"signature":"cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3"}"#).unwrap();
+        let request: MintRequest<String> = serde_json::from_str(r#"{"quote":"9d745270-1405-46de-b5c5-e2762b4f5e00","outputs":[{"amount":1,"id":"00456a94ab4e1c46","B_":"0342e5bcc77f5b2a3c2afb40bb591a1e27da83cddc968abdc0ec4904201a201834"},{"amount":1,"id":"00456a94ab4e1c46","B_":"032fd3c4dc49a2844a89998d5e9d5b0f0b00dde9310063acb8a92e2fdafa4126d4"},{"amount":1,"id":"00456a94ab4e1c46","B_":"033b6fde50b6a0dfe61ad148fff167ad9cf8308ded5f6f6b2fe000a036c464c311"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02be5a55f03e5c0aaea77595d574bce92c6d57a2a0fb2b5955c0b87e4520e06b53"},{"amount":1,"id":"00456a94ab4e1c46","B_":"02209fc2873f28521cbdde7f7b3bb1521002463f5979686fd156f23fe6a8aa2b79"}],"signature":"cb2b8e7ea69362dfe2a07093f2bbc319226db33db2ef686c940b5ec976bcbfc78df0cd35b3e998adf437b09ee2c950bd66dfe9eb64abd706e43ebc7c669c36c3"}"#).unwrap();
 
         // Signature is on a different quote id verification should fail
         assert!(request.verify_signature(pubkey).is_err());

+ 411 - 0
crates/cashu/src/nuts/nut23.rs

@@ -0,0 +1,411 @@
+//! Bolt11
+
+use std::fmt;
+use std::str::FromStr;
+
+use lightning_invoice::Bolt11Invoice;
+use serde::de::DeserializeOwned;
+use serde::{Deserialize, Deserializer, Serialize};
+use serde_json::Value;
+use thiserror::Error;
+#[cfg(feature = "mint")]
+use uuid::Uuid;
+
+use super::{BlindSignature, CurrencyUnit, MeltQuoteState, Mpp, PublicKey};
+use crate::Amount;
+
+/// NUT023 Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Unknown Quote State
+    #[error("Unknown Quote State")]
+    UnknownState,
+    /// Amount overflow
+    #[error("Amount overflow")]
+    AmountOverflow,
+    /// Invalid Amount
+    #[error("Invalid Request")]
+    InvalidAmountRequest,
+}
+
+/// Mint quote request [NUT-04]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct MintQuoteBolt11Request {
+    /// Amount
+    pub amount: Amount,
+    /// Unit wallet would like to pay with
+    pub unit: CurrencyUnit,
+    /// Memo to create the invoice with
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub description: Option<String>,
+    /// NUT-19 Pubkey
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub pubkey: Option<PublicKey>,
+}
+
+/// Possible states of a quote
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "UPPERCASE")]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema), schema(as = MintQuoteState))]
+pub enum QuoteState {
+    /// Quote has not been paid
+    #[default]
+    Unpaid,
+    /// Quote has been paid and wallet can mint
+    Paid,
+    /// Minting is in progress
+    /// **Note:** This state is to be used internally but is not part of the
+    /// nut.
+    Pending,
+    /// ecash issued for quote
+    Issued,
+}
+
+impl fmt::Display for QuoteState {
+    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+        match self {
+            Self::Unpaid => write!(f, "UNPAID"),
+            Self::Paid => write!(f, "PAID"),
+            Self::Pending => write!(f, "PENDING"),
+            Self::Issued => write!(f, "ISSUED"),
+        }
+    }
+}
+
+impl FromStr for QuoteState {
+    type Err = Error;
+
+    fn from_str(state: &str) -> Result<Self, Self::Err> {
+        match state {
+            "PENDING" => Ok(Self::Pending),
+            "PAID" => Ok(Self::Paid),
+            "UNPAID" => Ok(Self::Unpaid),
+            "ISSUED" => Ok(Self::Issued),
+            _ => Err(Error::UnknownState),
+        }
+    }
+}
+
+/// Mint quote response [NUT-04]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+#[serde(bound = "Q: Serialize + DeserializeOwned")]
+pub struct MintQuoteBolt11Response<Q> {
+    /// Quote Id
+    pub quote: Q,
+    /// Payment request to fulfil
+    pub request: String,
+    /// Amount
+    // REVIEW: This is now required in the spec, we should remove the option once all mints update
+    pub amount: Option<Amount>,
+    /// Unit
+    // REVIEW: This is now required in the spec, we should remove the option once all mints update
+    pub unit: Option<CurrencyUnit>,
+    /// Quote State
+    pub state: QuoteState,
+    /// Unix timestamp until the quote is valid
+    pub expiry: Option<u64>,
+    /// NUT-19 Pubkey
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub pubkey: Option<PublicKey>,
+}
+impl<Q: ToString> MintQuoteBolt11Response<Q> {
+    /// Convert the MintQuote with a quote type Q to a String
+    pub fn to_string_id(&self) -> MintQuoteBolt11Response<String> {
+        MintQuoteBolt11Response {
+            quote: self.quote.to_string(),
+            request: self.request.clone(),
+            state: self.state,
+            expiry: self.expiry,
+            pubkey: self.pubkey,
+            amount: self.amount,
+            unit: self.unit.clone(),
+        }
+    }
+}
+
+#[cfg(feature = "mint")]
+impl From<MintQuoteBolt11Response<Uuid>> for MintQuoteBolt11Response<String> {
+    fn from(value: MintQuoteBolt11Response<Uuid>) -> Self {
+        Self {
+            quote: value.quote.to_string(),
+            request: value.request,
+            state: value.state,
+            expiry: value.expiry,
+            pubkey: value.pubkey,
+            amount: value.amount,
+            unit: value.unit.clone(),
+        }
+    }
+}
+
+/// BOLT11 melt quote request [NUT-23]
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct MeltQuoteBolt11Request {
+    /// Bolt11 invoice to be paid
+    #[cfg_attr(feature = "swagger", schema(value_type = String))]
+    pub request: Bolt11Invoice,
+    /// Unit wallet would like to pay with
+    pub unit: CurrencyUnit,
+    /// Payment Options
+    pub options: Option<MeltOptions>,
+}
+
+/// Melt Options
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(untagged)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub enum MeltOptions {
+    /// Mpp Options
+    Mpp {
+        /// MPP
+        mpp: Mpp,
+    },
+    /// Amountless options
+    Amountless {
+        /// Amountless
+        amountless: Amountless,
+    },
+}
+
+impl MeltOptions {
+    /// Create new [`MeltOptions::Mpp`]
+    pub fn new_mpp<A>(amount: A) -> Self
+    where
+        A: Into<Amount>,
+    {
+        Self::Mpp {
+            mpp: Mpp {
+                amount: amount.into(),
+            },
+        }
+    }
+
+    /// Create new [`MeltOptions::Amountless`]
+    pub fn new_amountless<A>(amount_msat: A) -> Self
+    where
+        A: Into<Amount>,
+    {
+        Self::Amountless {
+            amountless: Amountless {
+                amount_msat: amount_msat.into(),
+            },
+        }
+    }
+
+    /// Payment amount
+    pub fn amount_msat(&self) -> Amount {
+        match self {
+            Self::Mpp { mpp } => mpp.amount,
+            Self::Amountless { amountless } => amountless.amount_msat,
+        }
+    }
+}
+
+/// Amountless payment
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct Amountless {
+    /// Amount to pay in msat
+    pub amount_msat: Amount,
+}
+
+impl MeltQuoteBolt11Request {
+    /// Amount from [`MeltQuoteBolt11Request`]
+    ///
+    /// Amount can either be defined in the bolt11 invoice,
+    /// in the request for an amountless bolt11 or in MPP option.
+    pub fn amount_msat(&self) -> Result<Amount, Error> {
+        let MeltQuoteBolt11Request {
+            request,
+            unit: _,
+            options,
+            ..
+        } = self;
+
+        match options {
+            None => Ok(request
+                .amount_milli_satoshis()
+                .ok_or(Error::InvalidAmountRequest)?
+                .into()),
+            Some(MeltOptions::Mpp { mpp }) => Ok(mpp.amount),
+            Some(MeltOptions::Amountless { amountless }) => {
+                let amount = amountless.amount_msat;
+                if let Some(amount_msat) = request.amount_milli_satoshis() {
+                    if amount != amount_msat.into() {
+                        return Err(Error::InvalidAmountRequest);
+                    }
+                }
+                Ok(amount)
+            }
+        }
+    }
+}
+
+/// Melt quote response [NUT-05]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+#[serde(bound = "Q: Serialize")]
+pub struct MeltQuoteBolt11Response<Q> {
+    /// Quote Id
+    pub quote: Q,
+    /// The amount that needs to be provided
+    pub amount: Amount,
+    /// The fee reserve that is required
+    pub fee_reserve: Amount,
+    /// Whether the request haas be paid
+    // TODO: To be deprecated
+    /// Deprecated
+    pub paid: Option<bool>,
+    /// Quote State
+    pub state: MeltQuoteState,
+    /// Unix timestamp until the quote is valid
+    pub expiry: u64,
+    /// Payment preimage
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub payment_preimage: Option<String>,
+    /// Change
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub change: Option<Vec<BlindSignature>>,
+    /// Payment request to fulfill
+    // REVIEW: This is now required in the spec, we should remove the option once all mints update
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub request: Option<String>,
+    /// Unit
+    // REVIEW: This is now required in the spec, we should remove the option once all mints update
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub unit: Option<CurrencyUnit>,
+}
+
+impl<Q: ToString> MeltQuoteBolt11Response<Q> {
+    /// Convert a `MeltQuoteBolt11Response` with type Q (generic/unknown) to a
+    /// `MeltQuoteBolt11Response` with `String`
+    pub fn to_string_id(self) -> MeltQuoteBolt11Response<String> {
+        MeltQuoteBolt11Response {
+            quote: self.quote.to_string(),
+            amount: self.amount,
+            fee_reserve: self.fee_reserve,
+            paid: self.paid,
+            state: self.state,
+            expiry: self.expiry,
+            payment_preimage: self.payment_preimage,
+            change: self.change,
+            request: self.request,
+            unit: self.unit,
+        }
+    }
+}
+
+#[cfg(feature = "mint")]
+impl From<MeltQuoteBolt11Response<Uuid>> for MeltQuoteBolt11Response<String> {
+    fn from(value: MeltQuoteBolt11Response<Uuid>) -> Self {
+        Self {
+            quote: value.quote.to_string(),
+            amount: value.amount,
+            fee_reserve: value.fee_reserve,
+            paid: value.paid,
+            state: value.state,
+            expiry: value.expiry,
+            payment_preimage: value.payment_preimage,
+            change: value.change,
+            request: value.request,
+            unit: value.unit,
+        }
+    }
+}
+
+// A custom deserializer is needed until all mints
+// update some will return without the required state.
+impl<'de, Q: DeserializeOwned> Deserialize<'de> for MeltQuoteBolt11Response<Q> {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        let value = Value::deserialize(deserializer)?;
+
+        let quote: Q = serde_json::from_value(
+            value
+                .get("quote")
+                .ok_or(serde::de::Error::missing_field("quote"))?
+                .clone(),
+        )
+        .map_err(|_| serde::de::Error::custom("Invalid quote if string"))?;
+
+        let amount = value
+            .get("amount")
+            .ok_or(serde::de::Error::missing_field("amount"))?
+            .as_u64()
+            .ok_or(serde::de::Error::missing_field("amount"))?;
+        let amount = Amount::from(amount);
+
+        let fee_reserve = value
+            .get("fee_reserve")
+            .ok_or(serde::de::Error::missing_field("fee_reserve"))?
+            .as_u64()
+            .ok_or(serde::de::Error::missing_field("fee_reserve"))?;
+
+        let fee_reserve = Amount::from(fee_reserve);
+
+        let paid: Option<bool> = value.get("paid").and_then(|p| p.as_bool());
+
+        let state: Option<String> = value
+            .get("state")
+            .and_then(|s| serde_json::from_value(s.clone()).ok());
+
+        let (state, paid) = match (state, paid) {
+            (None, None) => return Err(serde::de::Error::custom("State or paid must be defined")),
+            (Some(state), _) => {
+                let state: MeltQuoteState = MeltQuoteState::from_str(&state)
+                    .map_err(|_| serde::de::Error::custom("Unknown state"))?;
+                let paid = state == MeltQuoteState::Paid;
+
+                (state, paid)
+            }
+            (None, Some(paid)) => {
+                let state = if paid {
+                    MeltQuoteState::Paid
+                } else {
+                    MeltQuoteState::Unpaid
+                };
+                (state, paid)
+            }
+        };
+
+        let expiry = value
+            .get("expiry")
+            .ok_or(serde::de::Error::missing_field("expiry"))?
+            .as_u64()
+            .ok_or(serde::de::Error::missing_field("expiry"))?;
+
+        let payment_preimage: Option<String> = value
+            .get("payment_preimage")
+            .and_then(|p| serde_json::from_value(p.clone()).ok());
+
+        let change: Option<Vec<BlindSignature>> = value
+            .get("change")
+            .and_then(|b| serde_json::from_value(b.clone()).ok());
+
+        let request: Option<String> = value
+            .get("request")
+            .and_then(|r| serde_json::from_value(r.clone()).ok());
+
+        let unit: Option<CurrencyUnit> = value
+            .get("unit")
+            .and_then(|u| serde_json::from_value(u.clone()).ok());
+
+        Ok(Self {
+            quote,
+            amount,
+            fee_reserve,
+            paid: Some(paid),
+            state,
+            expiry,
+            payment_preimage,
+            change,
+            request,
+            unit,
+        })
+    }
+}

+ 1 - 1
crates/cashu/src/secret.rs

@@ -78,7 +78,7 @@ impl Secret {
             serde_json::from_str(&self.0);
 
         if let Ok(secret) = secret {
-            if secret.kind.eq(&Kind::P2PK) {
+            if secret.kind().eq(&Kind::P2PK) {
                 return true;
             }
         }

+ 1 - 1
crates/cashu/src/util/hex.rs

@@ -28,7 +28,7 @@ impl fmt::Display for Error {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         match self {
             Self::InvalidHexCharacter { c, index } => {
-                write!(f, "Invalid character {} at position {}", c, index)
+                write!(f, "Invalid character {c} at position {index}")
             }
             Self::OddLength => write!(f, "Odd number of digits"),
         }

+ 2 - 8
crates/cdk-axum/Cargo.toml

@@ -27,18 +27,12 @@ tokio.workspace = true
 tracing.workspace = true
 utoipa = { workspace = true, optional = true }
 futures.workspace = true
-moka = { version = "0.11.1", features = ["future"] }
+moka = { version = "0.12.10", features = ["future"] }
 serde_json.workspace = true
 paste = "1.0.15"
 serde.workspace = true
 uuid.workspace = true
 sha2 = "0.10.8"
-redis = { version = "0.23.3", features = [
+redis = { version = "0.31.0", features = [
     "tokio-rustls-comp",
 ], optional = true }
-
-
-[build-dependencies]
-# Dep of utopia 2.5.0 breaks so keeping here for now
-time = "=0.3.39"
-

+ 7 - 16
crates/cdk-axum/README.md

@@ -1,23 +1,14 @@
 # CDK Axum
 
-[![crates.io](https://img.shields.io/crates/v/cdk-axum.svg)](https://crates.io/crates/cdk-axum) [![Documentation](https://docs.rs/cdk-axum/badge.svg)](https://docs.rs/cdk-axum)
+[![crates.io](https://img.shields.io/crates/v/cdk-axum.svg)](https://crates.io/crates/cdk-axum)
+[![Documentation](https://docs.rs/cdk-axum/badge.svg)](https://docs.rs/cdk-axum)
+[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE)
 
-The CDK Axum crate is a component of the [Cashu Development Kit](https://github.com/cashubtc/cdk) that provides a web server implementation for Cashu mints using the [Axum](https://github.com/tokio-rs/axum) web framework.
+**ALPHA** This library is in early development, the API will change and should be used with caution.
 
-## Overview
+Axum web server implementation for the Cashu Development Kit (CDK). This provides the HTTP API for Cashu mints.
 
-This crate implements the HTTP API for Cashu mints, providing endpoints for all the Cashu NUTs (Notation, Usage, and Terminology) specifications. It handles routing, request validation, response formatting, and includes features like WebSocket support and HTTP caching.
-
-## Features
-
-- Complete implementation of Cashu mint HTTP API
-- WebSocket support for real-time notifications (NUT-17)
-- HTTP response caching for improved performance (NUT-19)
-- CORS support for browser-based clients
-- Compression and decompression of HTTP payloads
-- Configurable logging and tracing
-
-## Usage
+## Installation
 
 Add this to your `Cargo.toml`:
 
@@ -28,4 +19,4 @@ cdk-axum = "*"
 
 ## License
 
-This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/LICENSE).
+This project is licensed under the [MIT License](../../LICENSE).

+ 5 - 10
crates/cdk-axum/src/auth.rs

@@ -9,7 +9,7 @@ use axum::{Json, Router};
 #[cfg(feature = "swagger")]
 use cdk::error::ErrorResponse;
 use cdk::nuts::{
-    AuthToken, BlindAuthToken, KeysResponse, KeysetResponse, MintAuthRequest, MintBolt11Response,
+    AuthToken, BlindAuthToken, KeysResponse, KeysetResponse, MintAuthRequest, MintResponse,
 };
 use serde::{Deserialize, Serialize};
 
@@ -103,12 +103,7 @@ where
 pub async fn get_auth_keysets(
     State(state): State<MintState>,
 ) -> Result<Json<KeysetResponse>, Response> {
-    let keysets = state.mint.auth_keysets().await.map_err(|err| {
-        tracing::error!("Could not get keysets: {}", err);
-        into_response(err)
-    })?;
-
-    Ok(Json(keysets))
+    Ok(Json(state.mint.auth_keysets()))
 }
 
 #[cfg_attr(feature = "swagger", utoipa::path(
@@ -125,7 +120,7 @@ pub async fn get_auth_keysets(
 pub async fn get_blind_auth_keys(
     State(state): State<MintState>,
 ) -> Result<Json<KeysResponse>, Response> {
-    let pubkeys = state.mint.auth_pubkeys().await.map_err(|err| {
+    let pubkeys = state.mint.auth_pubkeys().map_err(|err| {
         tracing::error!("Could not get keys: {}", err);
         into_response(err)
     })?;
@@ -144,7 +139,7 @@ pub async fn get_blind_auth_keys(
     path = "/blind/mint",
     request_body(content = MintAuthRequest, description = "Request params", content_type = "application/json"),
     responses(
-        (status = 200, description = "Successful response", body = MintBolt11Response, content_type = "application/json"),
+        (status = 200, description = "Successful response", body = MintResponse, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
     )
 ))]
@@ -152,7 +147,7 @@ pub async fn post_mint_auth(
     auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<MintAuthRequest>,
-) -> Result<Json<MintBolt11Response>, Response> {
+) -> Result<Json<MintResponse>, Response> {
     let auth_token = match auth {
         AuthHeader::Clear(cat) => {
             if cat.is_empty() {

+ 1 - 1
crates/cdk-axum/src/cache/backend/memory.rs

@@ -36,7 +36,7 @@ impl HttpCacheStorage for InMemoryHttpCache {
     }
 
     async fn get(&self, key: &HttpCacheKey) -> Option<Vec<u8>> {
-        self.0.get(key)
+        self.0.get(key).await
     }
 
     async fn set(&self, key: HttpCacheKey, value: Vec<u8>) {

+ 1 - 1
crates/cdk-axum/src/cache/backend/redis.rs

@@ -86,7 +86,7 @@ impl HttpCacheStorage for HttpCacheRedis {
         };
 
         let _: Result<(), _> = conn
-            .set_ex(db_key, value, self.cache_ttl.as_secs() as usize)
+            .set_ex(db_key, value, self.cache_ttl.as_secs())
             .await
             .map_err(|err| {
                 tracing::error!("Failed to set value in redis: {:?}", err);

+ 9 - 10
crates/cdk-axum/src/lib.rs

@@ -33,13 +33,8 @@ mod swagger_imports {
     pub use cdk::nuts::nut01::{Keys, KeysResponse, PublicKey, SecretKey};
     pub use cdk::nuts::nut02::{KeySet, KeySetInfo, KeysetResponse};
     pub use cdk::nuts::nut03::{SwapRequest, SwapResponse};
-    pub use cdk::nuts::nut04::{
-        MintBolt11Request, MintBolt11Response, MintMethodSettings, MintQuoteBolt11Request,
-        MintQuoteBolt11Response,
-    };
-    pub use cdk::nuts::nut05::{
-        MeltBolt11Request, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteBolt11Response,
-    };
+    pub use cdk::nuts::nut04::{MintMethodSettings, MintRequest, MintResponse};
+    pub use cdk::nuts::nut05::{MeltMethodSettings, MeltRequest};
     pub use cdk::nuts::nut06::{ContactInfo, MintInfo, MintVersion, Nuts, SupportedSettings};
     pub use cdk::nuts::nut07::{CheckStateRequest, CheckStateResponse, ProofState, State};
     pub use cdk::nuts::nut09::{RestoreRequest, RestoreResponse};
@@ -47,6 +42,10 @@ mod swagger_imports {
     pub use cdk::nuts::nut12::{BlindSignatureDleq, ProofDleq};
     pub use cdk::nuts::nut14::HTLCWitness;
     pub use cdk::nuts::nut15::{Mpp, MppMethodSettings};
+    pub use cdk::nuts::nut23::{
+        MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request,
+        MintQuoteBolt11Response,
+    };
     pub use cdk::nuts::{nut04, nut05, nut15, MeltQuoteState, MintQuoteState};
 }
 
@@ -80,13 +79,13 @@ pub struct MintState {
         KeysetResponse,
         KeySet,
         KeySetInfo,
-        MeltBolt11Request<String>,
+        MeltRequest<String>,
         MeltQuoteBolt11Request,
         MeltQuoteBolt11Response<String>,
         MeltQuoteState,
         MeltMethodSettings,
-        MintBolt11Request<String>,
-        MintBolt11Response,
+        MintRequest<String>,
+        MintResponse,
         MintInfo,
         MintQuoteBolt11Request,
         MintQuoteBolt11Response<String>,

+ 14 - 28
crates/cdk-axum/src/router_handlers.rs

@@ -7,9 +7,9 @@ use cdk::error::ErrorResponse;
 #[cfg(feature = "auth")]
 use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath};
 use cdk::nuts::{
-    CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MeltBolt11Request,
-    MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response,
-    MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, RestoreResponse,
+    CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse,
+    MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, MintInfo, MintQuoteBolt11Request,
+    MintQuoteBolt11Response, MintRequest, MintResponse, RestoreRequest, RestoreResponse,
     SwapRequest, SwapResponse,
 };
 use cdk::util::unix_time;
@@ -60,14 +60,10 @@ macro_rules! post_cache_wrapper {
 }
 
 post_cache_wrapper!(post_swap, SwapRequest, SwapResponse);
-post_cache_wrapper!(
-    post_mint_bolt11,
-    MintBolt11Request<Uuid>,
-    MintBolt11Response
-);
+post_cache_wrapper!(post_mint_bolt11, MintRequest<Uuid>, MintResponse);
 post_cache_wrapper!(
     post_melt_bolt11,
-    MeltBolt11Request<Uuid>,
+    MeltRequest<Uuid>,
     MeltQuoteBolt11Response<Uuid>
 );
 
@@ -86,12 +82,7 @@ post_cache_wrapper!(
 pub(crate) async fn get_keys(
     State(state): State<MintState>,
 ) -> Result<Json<KeysResponse>, Response> {
-    let pubkeys = state.mint.pubkeys().await.map_err(|err| {
-        tracing::error!("Could not get keys: {}", err);
-        into_response(err)
-    })?;
-
-    Ok(Json(pubkeys))
+    Ok(Json(state.mint.pubkeys()))
 }
 
 #[cfg_attr(feature = "swagger", utoipa::path(
@@ -114,7 +105,7 @@ pub(crate) async fn get_keyset_pubkeys(
     State(state): State<MintState>,
     Path(keyset_id): Path<Id>,
 ) -> Result<Json<KeysResponse>, Response> {
-    let pubkeys = state.mint.keyset_pubkeys(&keyset_id).await.map_err(|err| {
+    let pubkeys = state.mint.keyset_pubkeys(&keyset_id).map_err(|err| {
         tracing::error!("Could not get keyset pubkeys: {}", err);
         into_response(err)
     })?;
@@ -138,12 +129,7 @@ pub(crate) async fn get_keyset_pubkeys(
 pub(crate) async fn get_keysets(
     State(state): State<MintState>,
 ) -> Result<Json<KeysetResponse>, Response> {
-    let keysets = state.mint.keysets().await.map_err(|err| {
-        tracing::error!("Could not get keysets: {}", err);
-        into_response(err)
-    })?;
-
-    Ok(Json(keysets))
+    Ok(Json(state.mint.keysets()))
 }
 
 #[cfg_attr(feature = "swagger", utoipa::path(
@@ -246,9 +232,9 @@ pub(crate) async fn ws_handler(
     post,
     context_path = "/v1",
     path = "/mint/bolt11",
-    request_body(content = MintBolt11Request<String>, description = "Request params", content_type = "application/json"),
+    request_body(content = MintRequest<String>, description = "Request params", content_type = "application/json"),
     responses(
-        (status = 200, description = "Successful response", body = MintBolt11Response, content_type = "application/json"),
+        (status = 200, description = "Successful response", body = MintResponse, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
     )
 ))]
@@ -256,8 +242,8 @@ pub(crate) async fn ws_handler(
 pub(crate) async fn post_mint_bolt11(
     #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
-    Json(payload): Json<MintBolt11Request<Uuid>>,
-) -> Result<Json<MintBolt11Response>, Response> {
+    Json(payload): Json<MintRequest<Uuid>>,
+) -> Result<Json<MintResponse>, Response> {
     #[cfg(feature = "auth")]
     {
         state
@@ -369,7 +355,7 @@ pub(crate) async fn get_check_melt_bolt11_quote(
     post,
     context_path = "/v1",
     path = "/melt/bolt11",
-    request_body(content = MeltBolt11Request<String>, description = "Melt params", content_type = "application/json"),
+    request_body(content = MeltRequest<String>, description = "Melt params", content_type = "application/json"),
     responses(
         (status = 200, description = "Successful response", body = MeltQuoteBolt11Response<String>, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
@@ -382,7 +368,7 @@ pub(crate) async fn get_check_melt_bolt11_quote(
 pub(crate) async fn post_melt_bolt11(
     #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
-    Json(payload): Json<MeltBolt11Request<Uuid>>,
+    Json(payload): Json<MeltRequest<Uuid>>,
 ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> {
     #[cfg(feature = "auth")]
     {

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

@@ -18,6 +18,7 @@ redb = ["dep:cdk-redb"]
 [dependencies]
 anyhow.workspace = true
 bip39.workspace = true
+bitcoin.workspace = true
 cdk = { workspace = true, default-features = false, features = ["wallet", "auth"]}
 cdk-redb = { workspace = true, features = ["wallet"], optional = true }
 cdk-sqlite = { workspace = true, features = ["wallet"] }
@@ -28,7 +29,7 @@ tokio.workspace = true
 tracing.workspace = true
 tracing-subscriber.workspace = true
 home.workspace = true
-nostr-sdk = { version = "0.35.0", default-features = false, features = [
+nostr-sdk = { version = "0.41.0", default-features = false, features = [
     "nip04",
     "nip44",
     "nip59"
@@ -36,4 +37,3 @@ nostr-sdk = { version = "0.35.0", default-features = false, features = [
 reqwest.workspace = true
 url.workspace = true
 serde_with.workspace = true
-

+ 17 - 6
crates/cdk-cli/src/main.rs

@@ -20,6 +20,7 @@ use url::Url;
 mod nostr_storage;
 mod sub_commands;
 mod token_storage;
+mod utils;
 
 const DEFAULT_WORK_DIR: &str = ".cdk-cli";
 
@@ -64,8 +65,8 @@ enum Commands {
     Receive(sub_commands::receive::ReceiveSubCommand),
     /// Send
     Send(sub_commands::send::SendSubCommand),
-    /// Check if wallet balance is spendable
-    CheckSpendable,
+    /// Reclaim pending proofs that are no longer pending
+    CheckPending,
     /// View mint info
     MintInfo(sub_commands::mint_info::MintInfoSubcommand),
     /// Mint proofs via bolt11
@@ -99,7 +100,7 @@ async fn main() -> Result<()> {
 
     let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn";
 
-    let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter));
+    let env_filter = EnvFilter::new(format!("{default_filter},{sqlx_filter}"));
 
     // Parse input
     tracing_subscriber::fmt().with_env_filter(env_filter).init();
@@ -183,7 +184,17 @@ async fn main() -> Result<()> {
 
         let wallet = builder.build()?;
 
-        wallet.get_mint_info().await?;
+        let wallet_clone = wallet.clone();
+
+        tokio::spawn(async move {
+            if let Err(err) = wallet_clone.get_mint_info().await {
+                tracing::error!(
+                    "Could not get mint quote for {}, {}",
+                    wallet_clone.mint_url,
+                    err
+                );
+            }
+        });
 
         wallets.push(wallet);
     }
@@ -204,8 +215,8 @@ async fn main() -> Result<()> {
         Commands::Send(sub_command_args) => {
             sub_commands::send::send(&multi_mint_wallet, sub_command_args).await
         }
-        Commands::CheckSpendable => {
-            sub_commands::check_spent::check_spent(&multi_mint_wallet).await
+        Commands::CheckPending => {
+            sub_commands::check_pending::check_pending(&multi_mint_wallet).await
         }
         Commands::MintInfo(sub_command_args) => {
             sub_commands::mint_info::mint_info(args.proxy, sub_command_args).await

+ 2 - 2
crates/cdk-cli/src/nostr_storage.rs

@@ -12,7 +12,7 @@ pub async fn store_nostr_last_checked(
     last_checked: u32,
 ) -> Result<()> {
     let key_hex = hex::encode(verifying_key.to_bytes());
-    let file_path = work_dir.join(format!("nostr_last_checked_{}", key_hex));
+    let file_path = work_dir.join(format!("nostr_last_checked_{key_hex}"));
 
     fs::write(file_path, last_checked.to_string())?;
 
@@ -25,7 +25,7 @@ pub async fn get_nostr_last_checked(
     verifying_key: &PublicKey,
 ) -> Result<Option<u32>> {
     let key_hex = hex::encode(verifying_key.to_bytes());
-    let file_path = work_dir.join(format!("nostr_last_checked_{}", key_hex));
+    let file_path = work_dir.join(format!("nostr_last_checked_{key_hex}"));
 
     match fs::read_to_string(file_path) {
         Ok(content) => {

+ 5 - 1
crates/cdk-cli/src/sub_commands/balance.rs

@@ -19,7 +19,11 @@ pub async fn mint_balances(
 
     let mut wallets_vec = Vec::with_capacity(wallets.len());
 
-    for (i, (mint_url, amount)) in wallets.iter().enumerate() {
+    for (i, (mint_url, amount)) in wallets
+        .iter()
+        .filter(|(_, a)| a > &&Amount::ZERO)
+        .enumerate()
+    {
         let mint_url = mint_url.clone();
         println!("{i}: {mint_url} {amount} {unit}");
         wallets_vec.push((mint_url, *amount))

+ 7 - 10
crates/cdk-cli/src/sub_commands/cat_device_login.rs

@@ -60,7 +60,7 @@ pub async fn cat_device_login(
     if let Err(e) =
         token_storage::save_tokens(work_dir, &mint_url, &access_token, &refresh_token).await
     {
-        println!("Warning: Failed to save tokens to file: {}", e);
+        println!("Warning: Failed to save tokens to file: {e}");
     } else {
         println!("Tokens saved to work directory");
     }
@@ -68,8 +68,8 @@ pub async fn cat_device_login(
     // Print a cute ASCII cat
     println!("\nAuthentication successful! 🎉\n");
     println!("\nYour tokens:");
-    println!("access_token: {}", access_token);
-    println!("refresh_token: {}", refresh_token);
+    println!("access_token: {access_token}");
+    println!("refresh_token: {refresh_token}");
 
     Ok(())
 }
@@ -125,14 +125,11 @@ async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String
 
     let interval = device_code_data["interval"].as_u64().unwrap_or(5);
 
-    println!("\nTo login, visit: {}", verification_uri);
-    println!("And enter code: {}\n", user_code);
+    println!("\nTo login, visit: {verification_uri}");
+    println!("And enter code: {user_code}\n");
 
     if verification_uri_complete != verification_uri {
-        println!(
-            "Or visit this URL directly: {}\n",
-            verification_uri_complete
-        );
+        println!("Or visit this URL directly: {verification_uri_complete}\n");
     }
 
     // Poll for the token
@@ -187,7 +184,7 @@ async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String
                 continue;
             } else {
                 // For other errors, exit with an error message
-                panic!("Authentication failed: {}", error);
+                panic!("Authentication failed: {error}");
             }
         }
     }

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

@@ -67,15 +67,15 @@ pub async fn cat_login(
     if let Err(e) =
         token_storage::save_tokens(work_dir, &mint_url, &access_token, &refresh_token).await
     {
-        println!("Warning: Failed to save tokens to file: {}", e);
+        println!("Warning: Failed to save tokens to file: {e}");
     } else {
         println!("Tokens saved to work directory");
     }
 
     println!("\nAuthentication successful! 🎉\n");
     println!("\nYour tokens:");
-    println!("access_token: {}", access_token);
-    println!("refresh_token: {}", refresh_token);
+    println!("access_token: {access_token}");
+    println!("refresh_token: {refresh_token}");
 
     Ok(())
 }

+ 33 - 0
crates/cdk-cli/src/sub_commands/check_pending.rs

@@ -0,0 +1,33 @@
+use anyhow::Result;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+
+pub async fn check_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
+    let wallets = multi_mint_wallet.get_wallets().await;
+
+    for (i, wallet) in wallets.iter().enumerate() {
+        let mint_url = wallet.mint_url.clone();
+        println!("{i}: {mint_url}");
+
+        // Get all pending proofs
+        let pending_proofs = wallet.get_pending_proofs().await?;
+        if pending_proofs.is_empty() {
+            println!("No pending proofs found");
+            continue;
+        }
+
+        println!(
+            "Found {} pending proofs with {} {}",
+            pending_proofs.len(),
+            pending_proofs.total_amount()?,
+            wallet.unit
+        );
+
+        // Try to reclaim any proofs that are no longer pending
+        match wallet.reclaim_unspent(pending_proofs).await {
+            Ok(()) => println!("Successfully reclaimed pending proofs"),
+            Err(e) => println!("Error reclaimed pending proofs: {}", e),
+        }
+    }
+    Ok(())
+}

+ 0 - 12
crates/cdk-cli/src/sub_commands/check_spent.rs

@@ -1,12 +0,0 @@
-use anyhow::Result;
-use cdk::wallet::MultiMintWallet;
-
-pub async fn check_spent(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
-    for wallet in multi_mint_wallet.get_wallets().await {
-        let amount = wallet.check_all_pending_proofs().await?;
-
-        println!("Amount marked as spent: {}", amount);
-    }
-
-    Ok(())
-}

+ 246 - 50
crates/cdk-cli/src/sub_commands/create_request.rs

@@ -1,5 +1,10 @@
-use anyhow::Result;
-use cdk::nuts::nut18::TransportType;
+use std::str::FromStr;
+
+use anyhow::{bail, Result};
+use bitcoin::hashes::sha256::Hash as Sha256Hash;
+use cdk::nuts::nut01::PublicKey;
+use cdk::nuts::nut11::{Conditions, SigFlag, SpendingConditions};
+use cdk::nuts::nut18::{Nut10SecretRequest, TransportType};
 use cdk::nuts::{CurrencyUnit, PaymentRequest, PaymentRequestPayload, Token, Transport};
 use cdk::wallet::{MultiMintWallet, ReceiveOptions};
 use clap::Args;
@@ -16,23 +21,41 @@ pub struct CreateRequestSubCommand {
     unit: String,
     /// Quote description
     description: Option<String>,
+    /// P2PK: Public key(s) for which the token can be spent with valid signature(s)
+    /// Can be specified multiple times for multiple pubkeys
+    #[arg(long, action = clap::ArgAction::Append)]
+    pubkey: Option<Vec<String>>,
+    /// Number of required signatures (for multiple pubkeys)
+    /// Defaults to 1 if not specified
+    #[arg(long, default_value = "1")]
+    num_sigs: u64,
+    /// HTLC: Hash for hash time locked contract
+    #[arg(long, conflicts_with = "preimage")]
+    hash: Option<String>,
+    /// HTLC: Preimage of the hash (to be used instead of hash)
+    #[arg(long, conflicts_with = "hash")]
+    preimage: Option<String>,
+    /// Transport type to use (nostr, http, or none)
+    /// - nostr: Use Nostr transport and listen for payment
+    /// - http: Use HTTP transport but only print the request
+    /// - none: Don't use any transport, just print the request
+    #[arg(long, default_value = "nostr")]
+    transport: String,
+    /// URL for HTTP transport (only used when transport=http)
+    #[arg(long)]
+    http_url: Option<String>,
+    /// Nostr relays to use (only used when transport=nostr)
+    /// Can be specified multiple times for multiple relays
+    /// If not provided, defaults to standard relays
+    #[arg(long, action = clap::ArgAction::Append)]
+    nostr_relay: Option<Vec<String>>,
 }
 
 pub async fn create_request(
     multi_mint_wallet: &MultiMintWallet,
     sub_command_args: &CreateRequestSubCommand,
 ) -> Result<()> {
-    let keys = Keys::generate();
-    let relays = vec!["wss://relay.nos.social", "wss://relay.damus.io"];
-
-    let nprofile = Nip19Profile::new(keys.public_key, relays.clone())?;
-
-    let nostr_transport = Transport {
-        _type: TransportType::Nostr,
-        target: nprofile.to_bech32()?,
-        tags: Some(vec![vec!["n".to_string(), "17".to_string()]]),
-    };
-
+    // Get available mints from the wallet
     let mints: Vec<cdk::mint_url::MintUrl> = multi_mint_wallet
         .get_balances(&CurrencyUnit::Sat)
         .await?
@@ -40,58 +63,231 @@ pub async fn create_request(
         .cloned()
         .collect();
 
+    // Process transport based on command line args
+    let transport_type = sub_command_args.transport.to_lowercase();
+    let transports = match transport_type.as_str() {
+        "nostr" => {
+            let keys = Keys::generate();
+
+            // Use custom relays if provided, otherwise use defaults
+            let relays = if let Some(custom_relays) = &sub_command_args.nostr_relay {
+                if !custom_relays.is_empty() {
+                    println!("Using custom Nostr relays: {custom_relays:?}");
+                    custom_relays.clone()
+                } else {
+                    // Empty vector provided, fall back to defaults
+                    vec![
+                        "wss://relay.nos.social".to_string(),
+                        "wss://relay.damus.io".to_string(),
+                    ]
+                }
+            } else {
+                // No relays provided, use defaults
+                vec![
+                    "wss://relay.nos.social".to_string(),
+                    "wss://relay.damus.io".to_string(),
+                ]
+            };
+
+            let nprofile = Nip19Profile::new(keys.public_key, relays.clone())?;
+
+            let nostr_transport = Transport {
+                _type: TransportType::Nostr,
+                target: nprofile.to_bech32()?,
+                tags: Some(vec![vec!["n".to_string(), "17".to_string()]]),
+            };
+
+            // We'll need the Nostr keys and relays later for listening
+            let transport_info = Some((keys, relays, nprofile.public_key));
+
+            (Some(vec![nostr_transport]), transport_info)
+        }
+        "http" => {
+            if let Some(url) = &sub_command_args.http_url {
+                let http_transport = Transport {
+                    _type: TransportType::HttpPost,
+                    target: url.clone(),
+                    tags: None,
+                };
+
+                (Some(vec![http_transport]), None)
+            } else {
+                println!(
+                    "Warning: HTTP transport selected but no URL provided, skipping transport"
+                );
+                (None, None)
+            }
+        }
+        "none" => (None, None),
+        _ => {
+            println!("Warning: Unknown transport type '{transport_type}', defaulting to none");
+            (None, None)
+        }
+    };
+
+    // Create spending conditions based on provided arguments
+    // Handle the following cases:
+    // 1. Only P2PK condition
+    // 2. Only HTLC condition with hash
+    // 3. Only HTLC condition with preimage
+    // 4. Both P2PK and HTLC conditions
+
+    let spending_conditions = if let Some(pubkey_strings) = &sub_command_args.pubkey {
+        // Parse all pubkeys
+        let mut parsed_pubkeys = Vec::new();
+        for pubkey_str in pubkey_strings {
+            match PublicKey::from_str(pubkey_str) {
+                Ok(pubkey) => parsed_pubkeys.push(pubkey),
+                Err(err) => {
+                    println!("Error parsing pubkey {pubkey_str}: {err}");
+                    // Continue with other pubkeys
+                }
+            }
+        }
+
+        if parsed_pubkeys.is_empty() {
+            println!("No valid pubkeys provided");
+            None
+        } else {
+            // We have pubkeys for P2PK condition
+            let num_sigs = sub_command_args.num_sigs.min(parsed_pubkeys.len() as u64);
+
+            // Check if we also have an HTLC condition
+            if let Some(hash_str) = &sub_command_args.hash {
+                // Create conditions with the pubkeys
+                let conditions = Conditions {
+                    locktime: None,
+                    pubkeys: Some(parsed_pubkeys),
+                    refund_keys: None,
+                    num_sigs: Some(num_sigs),
+                    sig_flag: SigFlag::SigInputs,
+                };
+
+                // Try to parse the hash
+                match Sha256Hash::from_str(hash_str) {
+                    Ok(hash) => {
+                        // Create HTLC condition with P2PK in the conditions
+                        Some(SpendingConditions::HTLCConditions {
+                            data: hash,
+                            conditions: Some(conditions),
+                        })
+                    }
+                    Err(err) => {
+                        println!("Error parsing hash: {err}");
+                        // Fallback to just P2PK with multiple pubkeys
+                        bail!("Error parsing hash");
+                    }
+                }
+            } else if let Some(preimage) = &sub_command_args.preimage {
+                // Create conditions with the pubkeys
+                let conditions = Conditions {
+                    locktime: None,
+                    pubkeys: Some(parsed_pubkeys),
+                    refund_keys: None,
+                    num_sigs: Some(num_sigs),
+                    sig_flag: SigFlag::SigInputs,
+                };
+
+                // Create HTLC conditions with the hash and pubkeys in conditions
+                Some(SpendingConditions::new_htlc(
+                    preimage.to_string(),
+                    Some(conditions),
+                )?)
+            } else {
+                // Only P2PK condition with multiple pubkeys
+                Some(SpendingConditions::new_p2pk(
+                    *parsed_pubkeys.first().unwrap(),
+                    Some(Conditions {
+                        locktime: None,
+                        pubkeys: Some(parsed_pubkeys[1..].to_vec()),
+                        refund_keys: None,
+                        num_sigs: Some(num_sigs),
+                        sig_flag: SigFlag::SigInputs,
+                    }),
+                ))
+            }
+        }
+    } else if let Some(hash_str) = &sub_command_args.hash {
+        // Only HTLC condition with provided hash
+        match Sha256Hash::from_str(hash_str) {
+            Ok(hash) => Some(SpendingConditions::HTLCConditions {
+                data: hash,
+                conditions: None,
+            }),
+            Err(err) => {
+                println!("Error parsing hash: {err}");
+                None
+            }
+        }
+    } else if let Some(preimage) = &sub_command_args.preimage {
+        // Only HTLC condition with provided preimage
+        // For HTLC, create the hash from the preimage and use it directly
+        Some(SpendingConditions::new_htlc(preimage.to_string(), None)?)
+    } else {
+        None
+    };
+
+    // Convert SpendingConditions to Nut10SecretRequest
+    let nut10 = spending_conditions.map(Nut10SecretRequest::from);
+
+    // Extract the transports option from our match result
+    let (transports_option, nostr_info) = transports;
+
     let req = PaymentRequest {
         payment_id: None,
         amount: sub_command_args.amount.map(|a| a.into()),
-        unit: None,
+        unit: Some(CurrencyUnit::from_str(&sub_command_args.unit)?),
         single_use: Some(true),
         mints: Some(mints),
         description: sub_command_args.description.clone(),
-        transports: vec![nostr_transport],
+        transports: transports_option,
+        nut10,
     };
 
-    println!("{}", req);
-
-    let client = NostrClient::new(keys);
-
-    let filter = Filter::new().pubkey(nprofile.public_key);
-
-    for relay in relays {
-        client.add_read_relay(relay).await?;
-    }
-
-    client.connect().await;
+    // Always print the request
+    println!("{req}");
 
-    client.subscribe(vec![filter], None).await?;
+    // Only listen for Nostr payment if Nostr transport was selected
+    if let Some((keys, relays, pubkey)) = nostr_info {
+        println!("Listening for payment via Nostr...");
 
-    // Handle subscription notifications with `handle_notifications` method
-    client
-        .handle_notifications(|notification| async {
-            let mut exit = false;
-            if let RelayPoolNotification::Event {
-                subscription_id: _,
-                event,
-                ..
-            } = notification
-            {
-                let unwrapped = client.unwrap_gift_wrap(&event).await?;
+        let client = NostrClient::new(keys);
+        let filter = Filter::new().pubkey(pubkey);
 
-                let rumor = unwrapped.rumor;
+        for relay in relays {
+            client.add_read_relay(relay).await?;
+        }
 
-                let payload: PaymentRequestPayload = serde_json::from_str(&rumor.content)?;
+        client.connect().await;
+        client.subscribe(filter, None).await?;
 
-                let token = Token::new(payload.mint, payload.proofs, payload.memo, payload.unit);
+        // Handle subscription notifications with `handle_notifications` method
+        client
+            .handle_notifications(|notification| async {
+                let mut exit = false;
+                if let RelayPoolNotification::Event {
+                    subscription_id: _,
+                    event,
+                    ..
+                } = notification
+                {
+                    let unwrapped = client.unwrap_gift_wrap(&event).await?;
+                    let rumor = unwrapped.rumor;
+                    let payload: PaymentRequestPayload = serde_json::from_str(&rumor.content)?;
+                    let token =
+                        Token::new(payload.mint, payload.proofs, payload.memo, payload.unit);
 
-                let amount = multi_mint_wallet
-                    .receive(&token.to_string(), ReceiveOptions::default())
-                    .await?;
+                    let amount = multi_mint_wallet
+                        .receive(&token.to_string(), ReceiveOptions::default())
+                        .await?;
 
-                println!("Received {}", amount);
-                exit = true;
-            }
-            Ok(exit) // Set to true to exit from the loop
-        })
-        .await?;
+                    println!("Received {amount}");
+                    exit = true;
+                }
+                Ok(exit) // Set to true to exit from the loop
+            })
+            .await?;
+    }
 
     Ok(())
 }

+ 41 - 13
crates/cdk-cli/src/sub_commands/list_mint_proofs.rs

@@ -1,5 +1,3 @@
-use std::collections::BTreeMap;
-
 use anyhow::Result;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::{CurrencyUnit, Proof};
@@ -13,27 +11,57 @@ pub async fn proofs(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
 async fn list_proofs(
     multi_mint_wallet: &MultiMintWallet,
 ) -> Result<Vec<(MintUrl, (Vec<Proof>, CurrencyUnit))>> {
-    let wallets_proofs: BTreeMap<MintUrl, (Vec<Proof>, CurrencyUnit)> =
-        multi_mint_wallet.list_proofs().await?;
+    let mut proofs_vec = Vec::new();
 
-    let mut proofs_vec = Vec::with_capacity(wallets_proofs.len());
+    let wallets = multi_mint_wallet.get_wallets().await;
 
-    for (i, (mint_url, proofs)) in wallets_proofs.iter().enumerate() {
-        let mint_url = mint_url.clone();
+    for (i, wallet) in wallets.iter().enumerate() {
+        let mint_url = wallet.mint_url.clone();
         println!("{i}: {mint_url}");
-        println!("|   Amount | Unit | Secret                                                           | DLEQ proof included");
-        println!("|----------|------|------------------------------------------------------------------|--------------------");
-        for proof in &proofs.0 {
+        println!("|   Amount | Unit | State    | Secret                                                           | DLEQ proof included");
+        println!("|----------|------|----------|------------------------------------------------------------------|--------------------");
+
+        // Unspent proofs
+        let unspent_proofs = wallet.get_unspent_proofs().await?;
+        for proof in unspent_proofs.iter() {
+            println!(
+                "| {:8} | {:4} | {:8} | {:64} | {}",
+                proof.amount,
+                wallet.unit,
+                "unspent",
+                proof.secret,
+                proof.dleq.is_some()
+            );
+        }
+
+        // Pending proofs
+        let pending_proofs = wallet.get_pending_proofs().await?;
+        for proof in pending_proofs {
             println!(
-                "| {:8} | {:4} | {:64} | {}",
+                "| {:8} | {:4} | {:8} | {:64} | {}",
                 proof.amount,
-                proofs.1,
+                wallet.unit,
+                "pending",
                 proof.secret,
                 proof.dleq.is_some()
             );
         }
+
+        // Reserved proofs
+        let reserved_proofs = wallet.get_reserved_proofs().await?;
+        for proof in reserved_proofs {
+            println!(
+                "| {:8} | {:4} | {:8} | {:64} | {}",
+                proof.amount,
+                wallet.unit,
+                "reserved",
+                proof.secret,
+                proof.dleq.is_some()
+            );
+        }
+
         println!();
-        proofs_vec.push((mint_url, proofs.clone()))
+        proofs_vec.push((mint_url, (unspent_proofs, wallet.unit.clone())));
     }
     Ok(proofs_vec)
 }

+ 168 - 59
crates/cdk-cli/src/sub_commands/melt.rs

@@ -1,5 +1,3 @@
-use std::io;
-use std::io::Write;
 use std::str::FromStr;
 
 use anyhow::{bail, Result};
@@ -9,8 +7,13 @@ use cdk::wallet::multi_mint_wallet::MultiMintWallet;
 use cdk::wallet::types::WalletKey;
 use cdk::Bolt11Invoice;
 use clap::Args;
+use tokio::task::JoinSet;
 
 use crate::sub_commands::balance::mint_balances;
+use crate::utils::{
+    get_number_input, get_user_input, get_wallet_by_index, get_wallet_by_mint_url,
+    validate_mint_number,
+};
 
 #[derive(Args)]
 pub struct MeltSubCommand {
@@ -18,8 +21,11 @@ pub struct MeltSubCommand {
     #[arg(default_value = "sat")]
     unit: String,
     /// Mpp
-    #[arg(short, long)]
+    #[arg(short, long, conflicts_with = "mint_url")]
     mpp: bool,
+    /// Mint URL to use for melting
+    #[arg(long, conflicts_with = "mpp")]
+    mint_url: Option<String>,
 }
 
 pub async fn pay(
@@ -29,82 +35,185 @@ pub async fn pay(
     let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
     let mints_amounts = mint_balances(multi_mint_wallet, &unit).await?;
 
-    println!("Enter mint number to melt from");
+    let mut mints = vec![];
+    let mut mint_amounts = vec![];
+    if sub_command_args.mpp {
+        // MPP functionality expects multiple mints, so mint_url flag doesn't fully apply here,
+        // but we can offer to use the specified mint as the first one if provided
+        if let Some(mint_url) = &sub_command_args.mint_url {
+            println!("Using mint URL {mint_url} as the first mint for MPP payment.");
+
+            // Check if the mint exists
+            if let Ok(_wallet) =
+                get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit.clone()).await
+            {
+                // Find the index of this mint in the mints_amounts list
+                if let Some(mint_index) = mints_amounts
+                    .iter()
+                    .position(|(url, _)| url.to_string() == *mint_url)
+                {
+                    mints.push(mint_index);
+                    let melt_amount: u64 =
+                        get_number_input("Enter amount to mint from this mint in sats.")?;
+                    mint_amounts.push(melt_amount);
+                } else {
+                    println!("Warning: Mint URL exists but no balance found. Continuing with manual selection.");
+                }
+            } else {
+                println!("Warning: Could not find wallet for the specified mint URL. Continuing with manual selection.");
+            }
+        }
+        loop {
+            let mint_number: String =
+                get_user_input("Enter mint number to melt from and -1 when done.")?;
 
-    let mut user_input = String::new();
-    let stdin = io::stdin();
-    io::stdout().flush().unwrap();
-    stdin.read_line(&mut user_input)?;
+            if mint_number == "-1" || mint_number.is_empty() {
+                break;
+            }
 
-    let mint_number: usize = user_input.trim().parse()?;
+            let mint_number: usize = mint_number.parse()?;
+            validate_mint_number(mint_number, mints_amounts.len())?;
 
-    if mint_number.gt(&(mints_amounts.len() - 1)) {
-        bail!("Invalid mint number");
-    }
+            mints.push(mint_number);
+            let melt_amount: u64 =
+                get_number_input("Enter amount to mint from this mint in sats.")?;
+            mint_amounts.push(melt_amount);
+        }
 
-    let wallet = mints_amounts[mint_number].0.clone();
+        let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice request")?)?;
 
-    let wallet = multi_mint_wallet
-        .get_wallet(&WalletKey::new(wallet, unit))
-        .await
-        .expect("Known wallet");
+        let mut quotes = JoinSet::new();
 
-    println!("Enter bolt11 invoice request");
+        for (mint, amount) in mints.iter().zip(mint_amounts) {
+            let wallet = mints_amounts[*mint].0.clone();
 
-    let mut user_input = String::new();
-    let stdin = io::stdin();
-    io::stdout().flush().unwrap();
-    stdin.read_line(&mut user_input)?;
-    let bolt11 = Bolt11Invoice::from_str(user_input.trim())?;
+            let wallet = multi_mint_wallet
+                .get_wallet(&WalletKey::new(wallet, unit.clone()))
+                .await
+                .expect("Known wallet");
+            let options = MeltOptions::new_mpp(amount * 1000);
 
-    let available_funds =
-        <cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT;
+            let bolt11_clone = bolt11.clone();
 
-    // Determine payment amount and options
-    let options = if sub_command_args.mpp || bolt11.amount_milli_satoshis().is_none() {
-        // Get user input for amount
-        println!(
-            "Enter the amount you would like to pay in sats for a {} payment.",
-            if sub_command_args.mpp {
-                "MPP"
+            quotes.spawn(async move {
+                let quote = wallet
+                    .melt_quote(bolt11_clone.to_string(), Some(options))
+                    .await;
+
+                (wallet, quote)
+            });
+        }
+
+        let quotes = quotes.join_all().await;
+
+        for (wallet, quote) in quotes.iter() {
+            if let Err(quote) = quote {
+                tracing::error!("Could not get quote for {}: {:?}", wallet.mint_url, quote);
+                bail!("Could not get melt quote for {}", wallet.mint_url);
             } else {
-                "amountless invoice"
+                let quote = quote.as_ref().unwrap();
+                println!(
+                    "Melt quote {} for mint {} of amount {} with fee {}.",
+                    quote.id, wallet.mint_url, quote.amount, quote.fee_reserve
+                );
             }
-        );
+        }
 
-        let mut user_input = String::new();
-        io::stdout().flush()?;
-        io::stdin().read_line(&mut user_input)?;
+        let mut melts = JoinSet::new();
 
-        let user_amount = user_input.trim_end().parse::<u64>()? * MSAT_IN_SAT;
+        for (wallet, quote) in quotes {
+            let quote = quote.expect("Errors checked above");
 
-        if user_amount > available_funds {
-            bail!("Not enough funds");
+            melts.spawn(async move {
+                let melt = wallet.melt(&quote.id).await;
+                (wallet, melt)
+            });
         }
 
-        Some(if sub_command_args.mpp {
-            MeltOptions::new_mpp(user_amount)
-        } else {
-            MeltOptions::new_amountless(user_amount)
-        })
-    } else {
-        // Check if invoice amount exceeds available funds
-        let invoice_amount = bolt11.amount_milli_satoshis().unwrap();
-        if invoice_amount > available_funds {
-            bail!("Not enough funds");
+        let melts = melts.join_all().await;
+
+        let mut error = false;
+
+        for (wallet, melt) in melts {
+            match melt {
+                Ok(melt) => {
+                    println!(
+                        "Melt for {} paid {} with fee of {} ",
+                        wallet.mint_url, melt.amount, melt.fee_paid
+                    );
+                }
+                Err(err) => {
+                    println!("Melt for {} failed with {}", wallet.mint_url, err);
+                    error = true;
+                }
+            }
         }
-        None
-    };
 
-    // Process payment
-    let quote = wallet.melt_quote(bolt11.to_string(), options).await?;
-    println!("{:?}", quote);
+        if error {
+            bail!("Could not complete all melts");
+        }
+    } else {
+        // Get wallet either by mint URL or by index
+        let wallet = if let Some(mint_url) = &sub_command_args.mint_url {
+            // Use the provided mint URL
+            get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit.clone()).await?
+        } else {
+            // Fallback to the index-based selection
+            let mint_number: usize = get_number_input("Enter mint number to melt from")?;
+            get_wallet_by_index(multi_mint_wallet, &mints_amounts, mint_number, unit.clone())
+                .await?
+        };
+
+        // Find the mint amount for the selected wallet to check available funds
+        let mint_url = &wallet.mint_url;
+        let mint_amount = mints_amounts
+            .iter()
+            .find(|(url, _)| url == mint_url)
+            .map(|(_, amount)| *amount)
+            .ok_or_else(|| anyhow::anyhow!("Could not find balance for mint: {}", mint_url))?;
+
+        let available_funds = <cdk::Amount as Into<u64>>::into(mint_amount) * MSAT_IN_SAT;
+
+        let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice request")?)?;
+
+        // Determine payment amount and options
+        let options = if bolt11.amount_milli_satoshis().is_none() {
+            // Get user input for amount
+            let prompt = format!(
+                "Enter the amount you would like to pay in sats for a {} payment.",
+                if sub_command_args.mpp {
+                    "MPP"
+                } else {
+                    "amountless invoice"
+                }
+            );
+
+            let user_amount = get_number_input::<u64>(&prompt)? * MSAT_IN_SAT;
+
+            if user_amount > available_funds {
+                bail!("Not enough funds");
+            }
 
-    let melt = wallet.melt(&quote.id).await?;
-    println!("Paid invoice: {}", melt.state);
+            Some(MeltOptions::new_amountless(user_amount))
+        } else {
+            // Check if invoice amount exceeds available funds
+            let invoice_amount = bolt11.amount_milli_satoshis().unwrap();
+            if invoice_amount > available_funds {
+                bail!("Not enough funds");
+            }
+            None
+        };
+
+        // Process payment
+        let quote = wallet.melt_quote(bolt11.to_string(), options).await?;
+        println!("{quote:?}");
 
-    if let Some(preimage) = melt.preimage {
-        println!("Payment preimage: {}", preimage);
+        let melt = wallet.melt(&quote.id).await?;
+        println!("Paid invoice: {}", melt.state);
+
+        if let Some(preimage) = melt.preimage {
+            println!("Payment preimage: {preimage}");
+        }
     }
 
     Ok(())

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

@@ -5,12 +5,13 @@ use cdk::amount::SplitTarget;
 use cdk::mint_url::MintUrl;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload};
-use cdk::wallet::types::WalletKey;
 use cdk::wallet::{MultiMintWallet, WalletSubscription};
 use cdk::Amount;
 use clap::Args;
 use serde::{Deserialize, Serialize};
 
+use crate::utils::get_or_create_wallet;
+
 #[derive(Args, Serialize, Deserialize)]
 pub struct MintSubCommand {
     /// Mint url
@@ -36,18 +37,7 @@ pub async fn mint(
     let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
     let description: Option<String> = sub_command_args.description.clone();
 
-    let wallet = match multi_mint_wallet
-        .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
-        .await
-    {
-        Some(wallet) => wallet.clone(),
-        None => {
-            tracing::debug!("Wallet does not exist creating..");
-            multi_mint_wallet
-                .create_and_add_wallet(&mint_url.to_string(), unit, None)
-                .await?
-        }
-    };
+    let wallet = get_or_create_wallet(multi_mint_wallet, &mint_url, unit).await?;
 
     let quote_id = match &sub_command_args.quote_id {
         None => {
@@ -56,7 +46,7 @@ pub async fn mint(
                 .ok_or(anyhow!("Amount must be defined"))?;
             let quote = wallet.mint_quote(Amount::from(amount), description).await?;
 
-            println!("Quote: {:#?}", quote);
+            println!("Quote: {quote:#?}");
 
             println!("Please pay: {}", quote.request);
 

+ 1 - 1
crates/cdk-cli/src/sub_commands/mint_blind_auth.rs

@@ -97,7 +97,7 @@ pub async fn mint_blind_auth(
                         )
                         .await
                         {
-                            println!("Warning: Failed to save refreshed tokens: {}", e);
+                            println!("Warning: Failed to save refreshed tokens: {e}");
                         }
 
                         // Try setting the new access token

+ 1 - 1
crates/cdk-cli/src/sub_commands/mint_info.rs

@@ -19,7 +19,7 @@ pub async fn mint_info(proxy: Option<Url>, sub_command_args: &MintInfoSubcommand
 
     let info = client.get_mint_info().await?;
 
-    println!("{:#?}", info);
+    println!("{info:#?}");
 
     Ok(())
 }

+ 1 - 1
crates/cdk-cli/src/sub_commands/mod.rs

@@ -2,7 +2,7 @@ pub mod balance;
 pub mod burn;
 pub mod cat_device_login;
 pub mod cat_login;
-pub mod check_spent;
+pub mod check_pending;
 pub mod create_request;
 pub mod decode_request;
 pub mod decode_token;

+ 78 - 66
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::nut18::TransportType;
-use cdk::nuts::{PaymentRequest, PaymentRequestPayload};
+use cdk::nuts::{PaymentRequest, PaymentRequestPayload, Token};
 use cdk::wallet::{MultiMintWallet, SendOptions};
 use clap::Args;
 use nostr_sdk::nips::nip19::Nip19Profile;
@@ -67,18 +67,20 @@ pub async fn pay_request(
 
     let matching_wallet = matching_wallets.first().unwrap();
 
-    // We prefer nostr transport if it is available to hide ip.
-    let transport = payment_request
+    let transports = payment_request
         .transports
+        .clone()
+        .ok_or(anyhow!("Cannot pay request without transport"))?;
+
+    // We prefer nostr transport if it is available to hide ip.
+    let transport = transports
         .iter()
         .find(|t| t._type == TransportType::Nostr)
         .or_else(|| {
-            payment_request
-                .transports
+            transports
                 .iter()
                 .find(|t| t._type == TransportType::HttpPost)
-        })
-        .ok_or(anyhow!("No supported transport method found"))?;
+        });
 
     let prepared_send = matching_wallet
         .prepare_send(
@@ -91,81 +93,91 @@ pub async fn pay_request(
         .await?;
     let proofs = matching_wallet.send(prepared_send, None).await?.proofs();
 
-    let payload = PaymentRequestPayload {
-        id: payment_request.payment_id.clone(),
-        memo: None,
-        mint: matching_wallet.mint_url.clone(),
-        unit: matching_wallet.unit.clone(),
-        proofs,
-    };
+    if let Some(transport) = transport {
+        let payload = PaymentRequestPayload {
+            id: payment_request.payment_id.clone(),
+            memo: None,
+            mint: matching_wallet.mint_url.clone(),
+            unit: matching_wallet.unit.clone(),
+            proofs,
+        };
 
-    match transport._type {
-        TransportType::Nostr => {
-            let keys = Keys::generate();
-            let client = NostrClient::new(keys);
-            let nprofile = Nip19Profile::from_bech32(&transport.target)?;
+        match transport._type {
+            TransportType::Nostr => {
+                let keys = Keys::generate();
+                let client = NostrClient::new(keys);
+                let nprofile = Nip19Profile::from_bech32(&transport.target)?;
 
-            println!("{:?}", nprofile.relays);
+                println!("{:?}", nprofile.relays);
 
-            let rumor = EventBuilder::new(
-                nostr_sdk::Kind::from_u16(14),
-                serde_json::to_string(&payload)?,
-                [],
-            );
+                let rumor = EventBuilder::new(
+                    nostr_sdk::Kind::from_u16(14),
+                    serde_json::to_string(&payload)?,
+                )
+                .build(nprofile.public_key);
+                let relays = nprofile.relays;
 
-            let relays = nprofile.relays;
+                for relay in relays.iter() {
+                    client.add_write_relay(relay).await?;
+                }
 
-            for relay in relays.iter() {
-                client.add_write_relay(relay).await?;
-            }
+                client.connect().await;
 
-            client.connect().await;
+                let gift_wrap = client
+                    .gift_wrap_to(relays, &nprofile.public_key, rumor, None)
+                    .await?;
 
-            let gift_wrap = client
-                .gift_wrap_to(relays, &nprofile.public_key, rumor, None)
-                .await?;
-
-            println!(
-                "Published event {} succufully to {}",
-                gift_wrap.val,
-                gift_wrap
-                    .success
-                    .iter()
-                    .map(|s| s.to_string())
-                    .collect::<Vec<_>>()
-                    .join(", ")
-            );
-
-            if !gift_wrap.failed.is_empty() {
                 println!(
-                    "Could not publish to {:?}",
+                    "Published event {} succufully to {}",
+                    gift_wrap.val,
                     gift_wrap
-                        .failed
-                        .keys()
-                        .map(|relay| relay.to_string())
+                        .success
+                        .iter()
+                        .map(|s| s.to_string())
                         .collect::<Vec<_>>()
                         .join(", ")
                 );
+
+                if !gift_wrap.failed.is_empty() {
+                    println!(
+                        "Could not publish to {:?}",
+                        gift_wrap
+                            .failed
+                            .keys()
+                            .map(|relay| relay.to_string())
+                            .collect::<Vec<_>>()
+                            .join(", ")
+                    );
+                }
             }
-        }
 
-        TransportType::HttpPost => {
-            let client = Client::new();
-
-            let res = client
-                .post(transport.target.clone())
-                .json(&payload)
-                .send()
-                .await?;
-
-            let status = res.status();
-            if status.is_success() {
-                println!("Successfully posted payment");
-            } else {
-                println!("{:?}", res);
-                println!("Error posting payment");
+            TransportType::HttpPost => {
+                let client = Client::new();
+
+                let res = client
+                    .post(transport.target.clone())
+                    .json(&payload)
+                    .send()
+                    .await?;
+
+                let status = res.status();
+                if status.is_success() {
+                    println!("Successfully posted payment");
+                } else {
+                    println!("{res:?}");
+                    println!("Error posting payment");
+                }
             }
         }
+    } else {
+        // If no transport is available, print the token
+        let token = Token::new(
+            matching_wallet.mint_url.clone(),
+            proofs,
+            None,
+            matching_wallet.unit.clone(),
+        );
+        println!("Token: {token}");
     }
 
     Ok(())

+ 1 - 1
crates/cdk-cli/src/sub_commands/pending_mints.rs

@@ -5,7 +5,7 @@ pub async fn mint_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
     let amounts = multi_mint_wallet.check_all_mint_quotes(None).await?;
 
     for (unit, amount) in amounts {
-        println!("Unit: {}, Amount: {}", unit, amount);
+        println!("Unit: {unit}, Amount: {amount}");
     }
 
     Ok(())

+ 14 - 21
crates/cdk-cli/src/sub_commands/receive.rs

@@ -1,6 +1,7 @@
 use std::collections::HashSet;
 use std::path::Path;
 use std::str::FromStr;
+use std::time::Duration;
 
 use anyhow::{anyhow, Result};
 use cdk::nuts::{SecretKey, Token};
@@ -14,6 +15,7 @@ use nostr_sdk::nips::nip04;
 use nostr_sdk::{Filter, Keys, Kind, Timestamp};
 
 use crate::nostr_storage;
+use crate::utils::get_or_create_wallet;
 
 #[derive(Args)]
 pub struct ReceiveSubCommand {
@@ -114,7 +116,7 @@ pub async fn receive(
                         total_amount += amount;
                     }
                     Err(err) => {
-                        println!("{}", err);
+                        println!("{err}");
                     }
                 }
             }
@@ -123,7 +125,7 @@ pub async fn receive(
         }
     };
 
-    println!("Received: {}", amount);
+    println!("Received: {amount}");
 
     Ok(())
 }
@@ -137,17 +139,14 @@ async fn receive_token(
     let token: Token = Token::from_str(token_str)?;
 
     let mint_url = token.mint_url()?;
-
-    let wallet_key = WalletKey::new(mint_url.clone(), token.unit().unwrap_or_default());
-
-    if multi_mint_wallet.get_wallet(&wallet_key).await.is_none() {
-        multi_mint_wallet
-            .create_and_add_wallet(
-                &mint_url.to_string(),
-                token.unit().unwrap_or_default(),
-                None,
-            )
-            .await?;
+    let unit = token.unit().unwrap_or_default();
+
+    if multi_mint_wallet
+        .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
+        .await
+        .is_none()
+    {
+        get_or_create_wallet(multi_mint_wallet, &mint_url, unit).await?;
     }
 
     let amount = multi_mint_wallet
@@ -173,7 +172,7 @@ async fn nostr_receive(
 
     let x_only_pubkey = verifying_key.x_only_public_key();
 
-    let nostr_pubkey = nostr_sdk::PublicKey::from_hex(x_only_pubkey.to_string())?;
+    let nostr_pubkey = nostr_sdk::PublicKey::from_hex(&x_only_pubkey.to_string())?;
 
     let since = since.map(|s| Timestamp::from(s as u64));
 
@@ -192,13 +191,7 @@ async fn nostr_receive(
     client.connect().await;
 
     let events = client
-        .get_events_of(
-            vec![filter],
-            nostr_sdk::EventSource::Relays {
-                timeout: None,
-                specific_relays: Some(relays),
-            },
-        )
+        .fetch_events_from(relays, filter, Duration::from_secs(30))
         .await?;
 
     let mut tokens: HashSet<String> = HashSet::new();

+ 1 - 1
crates/cdk-cli/src/sub_commands/restore.rs

@@ -37,7 +37,7 @@ pub async fn restore(
 
     let amount = wallet.restore().await?;
 
-    println!("Restored {}", amount);
+    println!("Restored {amount}");
 
     Ok(())
 }

+ 72 - 36
crates/cdk-cli/src/sub_commands/send.rs

@@ -1,15 +1,16 @@
-use std::io;
-use std::io::Write;
 use std::str::FromStr;
 
-use anyhow::{bail, Result};
+use anyhow::{anyhow, Result};
 use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions};
-use cdk::wallet::types::{SendKind, WalletKey};
+use cdk::wallet::types::SendKind;
 use cdk::wallet::{MultiMintWallet, SendMemo, SendOptions};
 use cdk::Amount;
 use clap::Args;
 
 use crate::sub_commands::balance::mint_balances;
+use crate::utils::{
+    check_sufficient_funds, get_number_input, get_wallet_by_index, get_wallet_by_mint_url,
+};
 
 #[derive(Args)]
 pub struct SendSubCommand {
@@ -17,8 +18,11 @@ pub struct SendSubCommand {
     #[arg(short, long)]
     memo: Option<String>,
     /// Preimage
-    #[arg(long)]
+    #[arg(long, conflicts_with = "hash")]
     preimage: Option<String>,
+    /// Hash for HTLC (alternative to preimage)
+    #[arg(long, conflicts_with = "preimage")]
+    hash: Option<String>,
     /// Required number of signatures
     #[arg(long)]
     required_sigs: Option<u64>,
@@ -43,6 +47,9 @@ pub struct SendSubCommand {
     /// Amount willing to overpay to avoid a swap
     #[arg(short, long)]
     tolerance: Option<u64>,
+    /// Mint URL to use for sending
+    #[arg(long)]
+    mint_url: Option<String>,
     /// Currency unit e.g. sat
     #[arg(default_value = "sat")]
     unit: String,
@@ -55,33 +62,34 @@ pub async fn send(
     let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
     let mints_amounts = mint_balances(multi_mint_wallet, &unit).await?;
 
-    println!("Enter mint number to create token");
-
-    let mut user_input = String::new();
-    let stdin = io::stdin();
-    io::stdout().flush().unwrap();
-    stdin.read_line(&mut user_input)?;
-
-    let mint_number: usize = user_input.trim().parse()?;
-
-    if mint_number.gt(&(mints_amounts.len() - 1)) {
-        bail!("Invalid mint number");
-    }
+    // Get wallet either by mint URL or by index
+    let wallet = if let Some(mint_url) = &sub_command_args.mint_url {
+        // Use the provided mint URL
+        get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit).await?
+    } else {
+        // Fallback to the index-based selection
+        let mint_number: usize = get_number_input("Enter mint number to create token")?;
+        get_wallet_by_index(multi_mint_wallet, &mints_amounts, mint_number, unit).await?
+    };
 
-    println!("Enter value of token in sats");
+    let token_amount = Amount::from(get_number_input::<u64>("Enter value of token in sats")?);
 
-    let mut user_input = String::new();
-    let stdin = io::stdin();
-    io::stdout().flush().unwrap();
-    stdin.read_line(&mut user_input)?;
-    let token_amount = Amount::from(user_input.trim().parse::<u64>()?);
+    // Find the mint amount for the selected wallet to check if we have sufficient funds
+    let mint_url = &wallet.mint_url;
+    let mint_amount = mints_amounts
+        .iter()
+        .find(|(url, _)| url == mint_url)
+        .map(|(_, amount)| *amount)
+        .ok_or_else(|| anyhow!("Could not find balance for mint: {}", mint_url))?;
 
-    if token_amount.gt(&mints_amounts[mint_number].1) {
-        bail!("Not enough funds");
-    }
+    check_sufficient_funds(mint_amount, token_amount)?;
 
-    let conditions = match &sub_command_args.preimage {
-        Some(preimage) => {
+    let conditions = match (&sub_command_args.preimage, &sub_command_args.hash) {
+        (Some(_), Some(_)) => {
+            // This case shouldn't be reached due to Clap's conflicts_with attribute
+            unreachable!("Both preimage and hash were provided despite conflicts_with attribute")
+        }
+        (Some(preimage), None) => {
             let pubkeys = match sub_command_args.pubkey.is_empty() {
                 true => None,
                 false => Some(
@@ -118,7 +126,41 @@ pub async fn send(
                 Some(conditions),
             )?)
         }
-        None => match sub_command_args.pubkey.is_empty() {
+        (None, Some(hash)) => {
+            let pubkeys = match sub_command_args.pubkey.is_empty() {
+                true => None,
+                false => Some(
+                    sub_command_args
+                        .pubkey
+                        .iter()
+                        .map(|p| PublicKey::from_str(p).unwrap())
+                        .collect(),
+                ),
+            };
+
+            let refund_keys = match sub_command_args.refund_keys.is_empty() {
+                true => None,
+                false => Some(
+                    sub_command_args
+                        .refund_keys
+                        .iter()
+                        .map(|p| PublicKey::from_str(p).unwrap())
+                        .collect(),
+                ),
+            };
+
+            let conditions = Conditions::new(
+                sub_command_args.locktime,
+                pubkeys,
+                refund_keys,
+                sub_command_args.required_sigs,
+                None,
+            )
+            .unwrap();
+
+            Some(SpendingConditions::new_htlc_hash(hash, Some(conditions))?)
+        }
+        (None, None) => match sub_command_args.pubkey.is_empty() {
             true => None,
             false => {
                 let pubkeys: Vec<PublicKey> = sub_command_args
@@ -156,12 +198,6 @@ pub async fn send(
         },
     };
 
-    let wallet = mints_amounts[mint_number].0.clone();
-    let wallet = multi_mint_wallet
-        .get_wallet(&WalletKey::new(wallet, unit))
-        .await
-        .expect("Known wallet");
-
     let send_kind = match (sub_command_args.offline, sub_command_args.tolerance) {
         (true, Some(amount)) => SendKind::OfflineTolerance(Amount::from(amount)),
         (true, None) => SendKind::OfflineExact,
@@ -193,7 +229,7 @@ pub async fn send(
             println!("{}", token.to_v3_string());
         }
         false => {
-            println!("{}", token);
+            println!("{token}");
         }
     }
 

+ 1 - 1
crates/cdk-cli/src/sub_commands/update_mint_url.rs

@@ -33,7 +33,7 @@ pub async fn update_mint_url(
 
     wallet.update_mint_url(new_mint_url.clone()).await?;
 
-    println!("Mint Url changed from {} to {}", old_mint_url, new_mint_url);
+    println!("Mint Url changed from {old_mint_url} to {new_mint_url}");
 
     Ok(())
 }

+ 100 - 0
crates/cdk-cli/src/utils.rs

@@ -0,0 +1,100 @@
+use std::io::{self, Write};
+use std::str::FromStr;
+
+use anyhow::{bail, Result};
+use cdk::mint_url::MintUrl;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::multi_mint_wallet::MultiMintWallet;
+use cdk::wallet::types::WalletKey;
+use cdk::Amount;
+
+/// Helper function to get user input with a prompt
+pub fn get_user_input(prompt: &str) -> Result<String> {
+    println!("{prompt}");
+    let mut user_input = String::new();
+    io::stdout().flush()?;
+    io::stdin().read_line(&mut user_input)?;
+    Ok(user_input.trim().to_string())
+}
+
+/// Helper function to get a number from user input with a prompt
+pub fn get_number_input<T>(prompt: &str) -> Result<T>
+where
+    T: FromStr,
+    T::Err: std::error::Error + Send + Sync + 'static,
+{
+    let input = get_user_input(prompt)?;
+    let number = input.parse::<T>()?;
+    Ok(number)
+}
+
+/// Helper function to validate a mint number against available mints
+pub fn validate_mint_number(mint_number: usize, mint_count: usize) -> Result<()> {
+    if mint_number >= mint_count {
+        bail!("Invalid mint number");
+    }
+    Ok(())
+}
+
+/// Helper function to check if there are enough funds for an operation
+pub fn check_sufficient_funds(available: Amount, required: Amount) -> Result<()> {
+    if required.gt(&available) {
+        bail!("Not enough funds");
+    }
+    Ok(())
+}
+
+/// Helper function to get a wallet from the multi-mint wallet by mint URL
+pub async fn get_wallet_by_mint_url(
+    multi_mint_wallet: &MultiMintWallet,
+    mint_url_str: &str,
+    unit: CurrencyUnit,
+) -> Result<cdk::wallet::Wallet> {
+    let mint_url = MintUrl::from_str(mint_url_str)?;
+
+    let wallet_key = WalletKey::new(mint_url.clone(), unit);
+    let wallet = multi_mint_wallet
+        .get_wallet(&wallet_key)
+        .await
+        .ok_or_else(|| anyhow::anyhow!("Wallet not found for mint URL: {}", mint_url_str))?;
+
+    Ok(wallet.clone())
+}
+
+/// Helper function to get a wallet from the multi-mint wallet
+pub async fn get_wallet_by_index(
+    multi_mint_wallet: &MultiMintWallet,
+    mint_amounts: &[(MintUrl, Amount)],
+    mint_number: usize,
+    unit: CurrencyUnit,
+) -> Result<cdk::wallet::Wallet> {
+    validate_mint_number(mint_number, mint_amounts.len())?;
+
+    let wallet_key = WalletKey::new(mint_amounts[mint_number].0.clone(), unit);
+    let wallet = multi_mint_wallet
+        .get_wallet(&wallet_key)
+        .await
+        .ok_or_else(|| anyhow::anyhow!("Wallet not found"))?;
+
+    Ok(wallet.clone())
+}
+
+/// Helper function to create or get a wallet
+pub async fn get_or_create_wallet(
+    multi_mint_wallet: &MultiMintWallet,
+    mint_url: &MintUrl,
+    unit: CurrencyUnit,
+) -> Result<cdk::wallet::Wallet> {
+    match multi_mint_wallet
+        .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
+        .await
+    {
+        Some(wallet) => Ok(wallet.clone()),
+        None => {
+            tracing::debug!("Wallet does not exist creating..");
+            multi_mint_wallet
+                .create_and_add_wallet(&mint_url.to_string(), unit, None)
+                .await
+        }
+    }
+}

+ 2 - 2
crates/cdk-cln/Cargo.toml

@@ -13,8 +13,8 @@ readme = "README.md"
 [dependencies]
 async-trait.workspace = true
 bitcoin.workspace = true
-cdk = { workspace = true, features = ["mint"] }
-cln-rpc = "0.3.0"
+cdk-common = { workspace = true, features = ["mint"] }
+cln-rpc = "0.4.0"
 futures.workspace = true
 tokio.workspace = true
 tokio-util.workspace = true

+ 15 - 11
crates/cdk-cln/README.md

@@ -1,16 +1,20 @@
+# CDK CLN
 
-## Minimum Supported Rust Version (MSRV)
+[![crates.io](https://img.shields.io/crates/v/cdk-cln.svg)](https://crates.io/crates/cdk-cln)
+[![Documentation](https://docs.rs/cdk-cln/badge.svg)](https://docs.rs/cdk-cln)
+[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE)
 
-The `cdk-cln` library should always compile with any combination of features on Rust **1.63.0**.
+**ALPHA** This library is in early development, the API will change and should be used with caution.
 
-To build and test with the MSRV you will need to pin the below dependency versions:
+Core Lightning (CLN) backend implementation for the Cashu Development Kit (CDK).
 
-```shell
-cargo update -p half --precise 2.2.1
-cargo update -p tokio --precise 1.38.1
-cargo update -p tokio-util --precise 0.7.11
-cargo update -p reqwest --precise 0.12.4
-cargo update -p serde_with --precise 3.1.0
-cargo update -p regex --precise 1.9.6
-cargo update -p backtrace --precise 0.3.58
+## Installation
+
+Add this to your `Cargo.toml`:
+
+```toml
+[dependencies]
+cdk-cln = "*"
 ```
+
+This project is licensed under the [MIT License](../../LICENSE).

+ 2 - 2
crates/cdk-cln/src/error.rs

@@ -25,10 +25,10 @@ pub enum Error {
     ClnRpc(#[from] cln_rpc::RpcError),
     /// Amount Error
     #[error(transparent)]
-    Amount(#[from] cdk::amount::Error),
+    Amount(#[from] cdk_common::amount::Error),
 }
 
-impl From<Error> for cdk::cdk_payment::Error {
+impl From<Error> for cdk_common::payment::Error {
     fn from(e: Error) -> Self {
         Self::Lightning(Box::new(e))
     }

+ 7 - 7
crates/cdk-cln/src/lib.rs

@@ -12,15 +12,15 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 
 use async_trait::async_trait;
-use cdk::amount::{to_unit, Amount};
-use cdk::cdk_payment::{
+use cdk_common::amount::{to_unit, Amount};
+use cdk_common::common::FeeReserve;
+use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState};
+use cdk_common::payment::{
     self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment,
     PaymentQuoteResponse,
 };
-use cdk::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState};
-use cdk::types::FeeReserve;
-use cdk::util::{hex, unix_time};
-use cdk::{mint, Bolt11Invoice};
+use cdk_common::util::{hex, unix_time};
+use cdk_common::{mint, Bolt11Invoice};
 use cln_rpc::model::requests::{
     InvoiceRequest, ListinvoicesRequest, ListpaysRequest, PayRequest, WaitanyinvoiceRequest,
 };
@@ -65,7 +65,7 @@ impl Cln {
 
 #[async_trait]
 impl MintPayment for Cln {
-    type Err = cdk_payment::Error;
+    type Err = payment::Error;
 
     async fn get_settings(&self) -> Result<Value, Self::Err> {
         Ok(serde_json::to_value(Bolt11Settings {

+ 10 - 20
crates/cdk-common/README.md

@@ -4,20 +4,11 @@
 [![Documentation](https://docs.rs/cdk-common/badge.svg)](https://docs.rs/cdk-common)
 [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE)
 
-Common types, traits, and utilities for the Cashu Development Kit (CDK).
+**ALPHA** This library is in early development, the API will change and should be used with caution.
 
-## Overview
+Common types and utilities shared across the Cashu Development Kit (CDK) crates.
 
-The `cdk-common` crate provides shared functionality used across the CDK crates. It contains core data structures, error types, and utility functions that are essential for implementing Cashu wallets and mints.
-
-## Features
-
-- **Core Data Types**: Implementations of fundamental Cashu types like `MintUrl`, `ProofInfo`, and `Melted`
-- **Error Handling**: Comprehensive error types for Cashu operations
-- **Database Abstractions**: Traits for database operations used by wallets and mints
-- **NUT Implementations**: Common functionality for Cashu NUTs (Notation, Usage, and Terminology)
-
-## Usage
+## Installation
 
 Add this to your `Cargo.toml`:
 
@@ -26,16 +17,15 @@ Add this to your `Cargo.toml`:
 cdk-common = "*"
 ```
 
-## Components
+## Features
 
-The crate includes several key modules:
+This crate provides common functionality used across CDK crates including:
 
-- **common**: Core data structures used throughout the CDK
-- **database**: Traits for database operations
-- **error**: Error types and handling
-- **mint_url**: Implementation of the MintUrl type
-- **nuts**: Common functionality for Cashu NUTs
+- Common data types and structures
+- Shared traits and interfaces
+- Utility functions
+- Error types
 
 ## License
 
-This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/LICENSE).
+This project is licensed under the [MIT License](../../LICENSE).

+ 6 - 8
crates/cdk-common/src/database/mint/mod.rs

@@ -10,8 +10,8 @@ use super::Error;
 use crate::common::{PaymentProcessorKey, QuoteTTL};
 use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote};
 use crate::nuts::{
-    BlindSignature, CurrencyUnit, Id, MeltBolt11Request, MeltQuoteState, MintQuoteState, Proof,
-    Proofs, PublicKey, State,
+    BlindSignature, CurrencyUnit, Id, MeltQuoteState, MeltRequest, MintQuoteState, Proof, Proofs,
+    PublicKey, State,
 };
 
 #[cfg(feature = "auth")]
@@ -42,6 +42,7 @@ pub trait KeysDatabase {
     /// Get [`MintKeySetInfo`]s
     async fn get_keyset_infos(&self) -> Result<Vec<MintKeySetInfo>, Self::Err>;
 }
+
 /// Mint Quote Database trait
 #[async_trait]
 pub trait QuotesDatabase {
@@ -96,14 +97,14 @@ pub trait QuotesDatabase {
     /// Add melt request
     async fn add_melt_request(
         &self,
-        melt_request: MeltBolt11Request<Uuid>,
+        melt_request: MeltRequest<Uuid>,
         ln_key: PaymentProcessorKey,
     ) -> Result<(), Self::Err>;
     /// Get melt request
     async fn get_melt_request(
         &self,
         quote_id: &Uuid,
-    ) -> Result<Option<(MeltBolt11Request<Uuid>, PaymentProcessorKey)>, Self::Err>;
+    ) -> Result<Option<(MeltRequest<Uuid>, PaymentProcessorKey)>, Self::Err>;
 }
 
 /// Mint Proof Database trait
@@ -172,10 +173,7 @@ pub trait SignaturesDatabase {
 /// Mint Database trait
 #[async_trait]
 pub trait Database<Error>:
-    KeysDatabase<Err = Error>
-    + QuotesDatabase<Err = Error>
-    + ProofsDatabase<Err = Error>
-    + SignaturesDatabase<Err = Error>
+    QuotesDatabase<Err = Error> + ProofsDatabase<Err = Error> + SignaturesDatabase<Err = Error>
 {
     /// Set [`MintInfo`]
     async fn set_mint_info(&self, mint_info: MintInfo) -> Result<(), Error>;

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

@@ -12,7 +12,7 @@ use super::*;
 use crate::mint::MintKeySetInfo;
 
 #[inline]
-async fn setup_keyset<E: Debug, DB: Database<E>>(db: &DB) -> Id {
+async fn setup_keyset<E: Debug, DB: Database<E> + KeysDatabase<Err = E>>(db: &DB) -> Id {
     let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap();
     let keyset_info = MintKeySetInfo {
         id: keyset_id,
@@ -30,7 +30,7 @@ async fn setup_keyset<E: Debug, DB: Database<E>>(db: &DB) -> Id {
 }
 
 /// State transition test
-pub async fn state_transition<E: Debug, DB: Database<E>>(db: DB) {
+pub async fn state_transition<E: Debug, DB: Database<E> + KeysDatabase<Err = E>>(db: DB) {
     let keyset_id = setup_keyset(&db).await;
 
     let proofs = vec![

+ 17 - 4
crates/cdk-common/src/error.rs

@@ -92,6 +92,14 @@ pub enum Error {
     #[error("Amountless invoices are not supported for unit `{0}` and method `{1}`")]
     AmountlessInvoiceNotSupported(CurrencyUnit, PaymentMethod),
 
+    /// Internal Error - Send error
+    #[error("Internal send error: {0}")]
+    SendError(String),
+
+    /// Internal Error - Recv error
+    #[error("Internal receive error: {0}")]
+    RecvError(String),
+
     // Mint Errors
     /// Minting is disabled
     #[error("Minting is disabled")]
@@ -226,6 +234,9 @@ pub enum Error {
     /// Invalid transaction id
     #[error("Invalid transaction id")]
     InvalidTransactionId,
+    /// Transaction not found
+    #[error("Transaction not found")]
+    TransactionNotFound,
     /// Custom Error
     #[error("`{0}`")]
     Custom(String),
@@ -309,12 +320,15 @@ pub enum Error {
     /// NUT20 Error
     #[error(transparent)]
     NUT20(#[from] crate::nuts::nut20::Error),
-    /// NUTXX Error
+    /// NUT21 Error
     #[error(transparent)]
     NUT21(#[from] crate::nuts::nut21::Error),
-    /// NUTXX1 Error
+    /// NUT22 Error
     #[error(transparent)]
     NUT22(#[from] crate::nuts::nut22::Error),
+    /// NUT23 Error
+    #[error(transparent)]
+    NUT23(#[from] crate::nuts::nut23::Error),
     /// Database Error
     #[error(transparent)]
     Database(crate::database::Error),
@@ -407,8 +421,7 @@ impl From<Error> for ErrorResponse {
                 ErrorResponse {
                     code: ErrorCode::TransactionUnbalanced,
                     error: Some(format!(
-                        "Inputs: {}, Outputs: {}, expected_fee: {}",
-                        inputs_total, outputs_total, fee_expected,
+                        "Inputs: {inputs_total}, Outputs: {outputs_total}, expected_fee: {fee_expected}",
                     )),
                     detail: Some("Transaction inputs should equal outputs less fee".to_string()),
                 }

+ 3 - 0
crates/cdk-common/src/payment.rs

@@ -52,6 +52,9 @@ pub enum Error {
     /// NUT05 Error
     #[error(transparent)]
     NUT05(#[from] crate::nuts::nut05::Error),
+    /// NUT23 Error
+    #[error(transparent)]
+    NUT23(#[from] crate::nuts::nut23::Error),
     /// Custom
     #[error("`{0}`")]
     Custom(String),

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

@@ -1,6 +1,4 @@
 //! WS Index
-//!
-//!
 
 use std::fmt::Debug;
 use std::ops::Deref;

+ 8 - 17
crates/cdk-fake-wallet/README.md

@@ -1,21 +1,14 @@
 # CDK Fake Wallet
 
-[![crates.io](https://img.shields.io/crates/v/cdk-fake-wallet.svg)](https://crates.io/crates/cdk-fake-wallet) [![Documentation](https://docs.rs/cdk-fake-wallet/badge.svg)](https://docs.rs/cdk-fake-wallet)
+[![crates.io](https://img.shields.io/crates/v/cdk-fake-wallet.svg)](https://crates.io/crates/cdk-fake-wallet)
+[![Documentation](https://docs.rs/cdk-fake-wallet/badge.svg)](https://docs.rs/cdk-fake-wallet)
+[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE)
 
-The CDK Fake Wallet is a component of the [Cashu Development Kit](https://github.com/cashubtc/cdk) that provides a simulated Lightning Network backend for testing Cashu mints.
+**ALPHA** This library is in early development, the API will change and should be used with caution.
 
-## Overview
+A fake Lightning wallet implementation for the Cashu Development Kit (CDK). This is intended for testing purposes only - quotes are automatically filled without actual Lightning Network interaction.
 
-This crate implements the `MintPayment` trait with a fake Lightning backend that automatically completes payments without requiring actual Lightning Network transactions. It's designed for development and testing purposes only.
-
-## Features
-
-- Simulated Lightning Network payments
-- Automatic completion of payment quotes
-- Support for testing mint functionality without real funds
-- Implementation of the standard `MintPayment` interface
-
-## Usage
+## Installation
 
 Add this to your `Cargo.toml`:
 
@@ -26,10 +19,8 @@ cdk-fake-wallet = "*"
 
 ## Warning
 
-**This is for testing purposes only!** 
-
-The fake wallet should never be used in production environments as it does not perform actual Lightning Network transactions. It simply simulates the payment flow by automatically marking invoices as paid.
+This is for testing purposes only. Do not use in production environments.
 
 ## License
 
-This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/LICENSE).
+This project is licensed under the [MIT License](../../LICENSE).

+ 1 - 2
crates/cdk-integration-tests/src/bin/start_regtest.rs

@@ -31,8 +31,7 @@ async fn main() -> Result<()> {
     let rustls_filter = "rustls=warn";
 
     let env_filter = EnvFilter::new(format!(
-        "{},{},{},{},{}",
-        default_filter, sqlx_filter, hyper_filter, h2_filter, rustls_filter
+        "{default_filter},{sqlx_filter},{hyper_filter},{h2_filter},{rustls_filter}"
     ));
 
     tracing_subscriber::fmt().with_env_filter(env_filter).init();

+ 7 - 3
crates/cdk-integration-tests/src/init_auth_mint.rs

@@ -4,23 +4,25 @@ use std::sync::Arc;
 use anyhow::Result;
 use bip39::Mnemonic;
 use cashu::{AuthRequired, Method, ProtectedEndpoint, RoutePath};
-use cdk::cdk_database::{self, MintAuthDatabase, MintDatabase};
+use cdk::cdk_database::{self, MintAuthDatabase, MintDatabase, MintKeysDatabase};
 use cdk::mint::{MintBuilder, MintMeltLimits};
 use cdk::nuts::{CurrencyUnit, PaymentMethod};
 use cdk::types::FeeReserve;
 use cdk::wallet::AuthWallet;
 use cdk_fake_wallet::FakeWallet;
 
-pub async fn start_fake_mint_with_auth<D, A>(
+pub async fn start_fake_mint_with_auth<D, A, K>(
     _addr: &str,
     _port: u16,
     openid_discovery: String,
     database: D,
     auth_database: A,
+    key_store: K,
 ) -> Result<()>
 where
     D: MintDatabase<cdk_database::Error> + Send + Sync + 'static,
     A: MintAuthDatabase<Err = cdk_database::Error> + Send + Sync + 'static,
+    K: MintKeysDatabase<Err = cdk_database::Error> + Send + Sync + 'static,
 {
     let fee_reserve = FeeReserve {
         min_fee_reserve: 1.into(),
@@ -31,7 +33,9 @@ where
 
     let mut mint_builder = MintBuilder::new();
 
-    mint_builder = mint_builder.with_localstore(Arc::new(database));
+    mint_builder = mint_builder
+        .with_localstore(Arc::new(database))
+        .with_keystore(Arc::new(key_store));
 
     mint_builder = mint_builder
         .add_ln_backend(

+ 48 - 49
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -9,14 +9,14 @@ use anyhow::{anyhow, bail, Result};
 use async_trait::async_trait;
 use bip39::Mnemonic;
 use cdk::amount::SplitTarget;
-use cdk::cdk_database::{self, MintDatabase, WalletDatabase};
+use cdk::cdk_database::{self, WalletDatabase};
 use cdk::mint::{MintBuilder, MintMeltLimits};
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{
     CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeySet, KeysetResponse,
-    MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request,
-    MintBolt11Response, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, PaymentMethod,
-    RestoreRequest, RestoreResponse, SwapRequest, SwapResponse,
+    MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, MintInfo, MintQuoteBolt11Request,
+    MintQuoteBolt11Response, MintRequest, MintResponse, PaymentMethod, RestoreRequest,
+    RestoreResponse, SwapRequest, SwapResponse,
 };
 use cdk::types::{FeeReserve, QuoteTTL};
 use cdk::util::unix_time;
@@ -56,18 +56,15 @@ impl Debug for DirectMintConnection {
 #[async_trait]
 impl MintConnector for DirectMintConnection {
     async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {
-        self.mint.pubkeys().await.map(|pks| pks.keysets)
+        Ok(self.mint.pubkeys().keysets)
     }
 
     async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error> {
-        self.mint
-            .keyset(&keyset_id)
-            .await
-            .and_then(|res| res.ok_or(Error::UnknownKeySet))
+        self.mint.keyset(&keyset_id).ok_or(Error::UnknownKeySet)
     }
 
     async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error> {
-        self.mint.keysets().await
+        Ok(self.mint.keysets())
     }
 
     async fn post_mint_quote(
@@ -91,10 +88,7 @@ impl MintConnector for DirectMintConnection {
             .map(Into::into)
     }
 
-    async fn post_mint(
-        &self,
-        request: MintBolt11Request<String>,
-    ) -> Result<MintBolt11Response, Error> {
+    async fn post_mint(&self, request: MintRequest<String>) -> Result<MintResponse, Error> {
         let request_uuid = request.try_into().unwrap();
         self.mint.process_mint_request(request_uuid).await
     }
@@ -122,7 +116,7 @@ impl MintConnector for DirectMintConnection {
 
     async fn post_melt(
         &self,
-        request: MeltBolt11Request<String>,
+        request: MeltRequest<String>,
     ) -> Result<MeltQuoteBolt11Response<String>, Error> {
         let request_uuid = request.try_into().unwrap();
         self.mint.melt_bolt11(&request_uuid).await.map(Into::into)
@@ -166,10 +160,7 @@ pub fn setup_tracing() {
     let sqlx_filter = "sqlx=warn";
     let hyper_filter = "hyper=warn";
 
-    let env_filter = EnvFilter::new(format!(
-        "{},{},{}",
-        default_filter, sqlx_filter, hyper_filter
-    ));
+    let env_filter = EnvFilter::new(format!("{default_filter},{sqlx_filter},{hyper_filter}"));
 
     // Ok if successful, Err if already initialized
     // Allows us to setup tracing at the start of several parallel tests
@@ -179,40 +170,42 @@ pub fn setup_tracing() {
 }
 
 pub async fn create_and_start_test_mint() -> Result<Mint> {
-    let mut mint_builder = MintBuilder::new();
-
     // Read environment variable to determine database type
     let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set");
 
-    let localstore: Arc<dyn MintDatabase<cdk_database::Error> + Send + Sync> =
-        match db_type.to_lowercase().as_str() {
-            "sqlite" => {
-                // 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();
-                let database = cdk_sqlite::MintSqliteDatabase::new(&path)
+    let mut mint_builder = match db_type.to_lowercase().as_str() {
+        "sqlite" => {
+            // 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();
+            let database = Arc::new(
+                cdk_sqlite::MintSqliteDatabase::new(&path)
                     .await
-                    .expect("Could not create sqlite db");
-                Arc::new(database)
-            }
-            "redb" => {
-                // Create a temporary directory for ReDB database
-                let temp_dir = create_temp_dir("cdk-test-redb-mint")?;
-                let path = temp_dir.join("mint.redb");
-                let database = cdk_redb::MintRedbDatabase::new(&path)
-                    .expect("Could not create redb mint database");
-                Arc::new(database)
-            }
-            "memory" => {
-                let database = cdk_sqlite::mint::memory::empty().await?;
-                Arc::new(database)
-            }
-            _ => {
-                bail!("Db type not set")
-            }
-        };
-
-    mint_builder = mint_builder.with_localstore(localstore.clone());
+                    .expect("Could not create sqlite db"),
+            );
+            MintBuilder::new()
+                .with_localstore(database.clone())
+                .with_keystore(database)
+        }
+        "redb" => {
+            // Create a temporary directory for ReDB database
+            let temp_dir = create_temp_dir("cdk-test-redb-mint")?;
+            let path = temp_dir.join("mint.redb");
+            let database = Arc::new(
+                cdk_redb::MintRedbDatabase::new(&path)
+                    .expect("Could not create redb mint database"),
+            );
+            MintBuilder::new()
+                .with_localstore(database.clone())
+                .with_keystore(database)
+        }
+        "memory" => MintBuilder::new()
+            .with_localstore(Arc::new(cdk_sqlite::mint::memory::empty().await?))
+            .with_keystore(Arc::new(cdk_sqlite::mint::memory::empty().await?)),
+        _ => {
+            bail!("Db type not set")
+        }
+    };
 
     let fee_reserve = FeeReserve {
         min_fee_reserve: 1.into(),
@@ -243,6 +236,12 @@ pub async fn create_and_start_test_mint() -> Result<Mint> {
         .with_urls(vec!["https://aaa".to_string()])
         .with_seed(mnemonic.to_seed_normalized("").to_vec());
 
+    let localstore = mint_builder
+        .localstore
+        .as_ref()
+        .map(|x| x.clone())
+        .expect("localstore");
+
     localstore
         .set_mint_info(mint_builder.mint_info.clone())
         .await?;

+ 3 - 3
crates/cdk-integration-tests/src/init_regtest.rs

@@ -36,7 +36,7 @@ pub fn get_mint_addr() -> String {
 }
 
 pub fn get_mint_port(which: &str) -> u16 {
-    let dir = env::var(format!("CDK_ITESTS_MINT_PORT_{}", which)).expect("Mint port not set");
+    let dir = env::var(format!("CDK_ITESTS_MINT_PORT_{which}")).expect("Mint port not set");
     dir.parse().unwrap()
 }
 
@@ -244,7 +244,7 @@ pub async fn start_regtest_end(sender: Sender<()>, notify: Arc<Notify>) -> anyho
     tracing::info!("Started lnd node");
 
     let lnd_client = LndClient::new(
-        format!("https://{}", LND_RPC_ADDR),
+        format!("https://{LND_RPC_ADDR}"),
         get_lnd_cert_file_path(&lnd_dir),
         get_lnd_macaroon_path(&lnd_dir),
     )
@@ -261,7 +261,7 @@ pub async fn start_regtest_end(sender: Sender<()>, notify: Arc<Notify>) -> anyho
     tracing::info!("Started second lnd node");
 
     let lnd_two_client = LndClient::new(
-        format!("https://{}", LND_TWO_RPC_ADDR),
+        format!("https://{LND_TWO_RPC_ADDR}"),
         get_lnd_cert_file_path(&lnd_two_dir),
         get_lnd_macaroon_path(&lnd_two_dir),
     )

+ 4 - 8
crates/cdk-integration-tests/src/lib.rs

@@ -60,7 +60,7 @@ pub async fn attempt_to_swap_pending(wallet: &Wallet) -> Result<()> {
         Err(err) => match err {
             cdk::error::Error::TokenPending => (),
             _ => {
-                println!("{:?}", err);
+                println!("{err:?}");
                 bail!("Wrong error")
             }
         },
@@ -154,13 +154,9 @@ pub async fn init_lnd_client() -> LndClient {
     let lnd_dir = get_lnd_dir("one");
     let cert_file = lnd_dir.join("tls.cert");
     let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon");
-    LndClient::new(
-        format!("https://{}", LND_RPC_ADDR),
-        cert_file,
-        macaroon_file,
-    )
-    .await
-    .unwrap()
+    LndClient::new(format!("https://{LND_RPC_ADDR}"), cert_file, macaroon_file)
+        .await
+        .unwrap()
 }
 
 /// Pays a Bolt11Invoice if it's on the regtest network, otherwise returns Ok

+ 5 - 5
crates/cdk-integration-tests/tests/fake_auth.rs

@@ -8,9 +8,9 @@ use cdk::amount::{Amount, SplitTarget};
 use cdk::mint_url::MintUrl;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{
-    AuthProof, AuthToken, BlindAuthToken, CheckStateRequest, CurrencyUnit, MeltBolt11Request,
-    MeltQuoteBolt11Request, MeltQuoteState, MintBolt11Request, MintQuoteBolt11Request,
-    RestoreRequest, State, SwapRequest,
+    AuthProof, AuthToken, BlindAuthToken, CheckStateRequest, CurrencyUnit, MeltQuoteBolt11Request,
+    MeltQuoteState, MeltRequest, MintQuoteBolt11Request, MintRequest, RestoreRequest, State,
+    SwapRequest,
 };
 use cdk::wallet::{AuthHttpClient, AuthMintConnector, HttpClient, MintConnector, WalletBuilder};
 use cdk::{Error, OidcClient};
@@ -109,7 +109,7 @@ async fn test_mint_without_auth() {
     }
 
     {
-        let request = MintBolt11Request {
+        let request = MintRequest {
             quote: "123e4567-e89b-12d3-a456-426614174000".to_string(),
             outputs: vec![],
             signature: None,
@@ -207,7 +207,7 @@ async fn test_melt_without_auth() {
 
     // Test melt
     {
-        let request = MeltBolt11Request::new(
+        let request = MeltRequest::new(
             "123e4567-e89b-12d3-a456-426614174000".to_string(),
             vec![],
             None,

Datei-Diff unterdrückt, da er zu groß ist
+ 268 - 211
crates/cdk-integration-tests/tests/fake_wallet.rs


+ 115 - 99
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -9,15 +9,14 @@
 //! whether to use real Lightning Network payments (regtest mode) or simulated payments.
 
 use core::panic;
+use std::env;
 use std::fmt::Debug;
 use std::str::FromStr;
 use std::sync::Arc;
 use std::time::Duration;
-use std::{char, env};
 
-use anyhow::{bail, Result};
 use bip39::Mnemonic;
-use cashu::{MeltBolt11Request, PreMintSecrets};
+use cashu::{MeltRequest, PreMintSecrets};
 use cdk::amount::{Amount, SplitTarget};
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{CurrencyUnit, MeltQuoteState, NotificationPayload, State};
@@ -78,14 +77,15 @@ async fn get_notification<T: StreamExt<Item = Result<Message, E>> + Unpin, E: De
 /// This ensures the entire mint-melt flow works correctly and that
 /// WebSocket notifications are properly sent at each state transition.
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_happy_mint_melt_round_trip() -> Result<()> {
+async fn test_happy_mint_melt_round_trip() {
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
     let (ws_stream, _) = connect_async(format!(
         "{}/v1/ws",
@@ -95,22 +95,23 @@ async fn test_happy_mint_melt_round_trip() -> Result<()> {
     .expect("Failed to connect");
     let (mut write, mut reader) = ws_stream.split();
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
 
-    let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
     pay_if_regtest(&invoice).await.unwrap();
 
     let proofs = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
-    let mint_amount = proofs.total_amount()?;
+    let mint_amount = proofs.total_amount().unwrap();
 
     assert!(mint_amount == 100.into());
 
     let invoice = create_invoice_for_env(Some(50)).await.unwrap();
 
-    let melt = wallet.melt_quote(invoice, None).await?;
+    let melt = wallet.melt_quote(invoice, None).await.unwrap();
 
     write
         .send(Message::Text(
@@ -126,22 +127,25 @@ async fn test_happy_mint_melt_round_trip() -> Result<()> {
                       "subId": "test-sub",
                     }
 
-            }))?
+            }))
+            .unwrap()
             .into(),
         ))
-        .await?;
+        .await
+        .unwrap();
 
-    assert_eq!(
-        reader
-            .next()
-            .await
-            .unwrap()
-            .unwrap()
-            .to_text()
-            .unwrap()
-            .replace(char::is_whitespace, ""),
-        r#"{"jsonrpc":"2.0","result":{"status":"OK","subId":"test-sub"},"id":2}"#
-    );
+    // Parse both JSON strings to objects and compare them instead of comparing strings directly
+    let binding = reader.next().await.unwrap().unwrap();
+    let response_str = binding.to_text().unwrap();
+
+    let response_json: serde_json::Value =
+        serde_json::from_str(response_str).expect("Valid JSON response");
+    let expected_json: serde_json::Value = serde_json::from_str(
+        r#"{"jsonrpc":"2.0","result":{"status":"OK","subId":"test-sub"},"id":2}"#,
+    )
+    .expect("Valid JSON expected");
+
+    assert_eq!(response_json, expected_json);
 
     let melt_response = wallet.melt(&melt.id).await.unwrap();
     assert!(melt_response.preimage.is_some());
@@ -179,8 +183,6 @@ async fn test_happy_mint_melt_round_trip() -> Result<()> {
     assert_eq!(payload.amount, 50.into());
     assert_eq!(payload.quote.to_string(), melt.id);
     assert_eq!(payload.state, MeltQuoteState::Paid);
-
-    Ok(())
 }
 
 /// Tests basic minting functionality with payment verification
@@ -194,35 +196,37 @@ async fn test_happy_mint_melt_round_trip() -> Result<()> {
 ///
 /// This ensures the basic minting flow works correctly from quote to token issuance.
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_happy_mint() -> Result<()> {
+async fn test_happy_mint() {
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
     let mint_amount = Amount::from(100);
 
-    let mint_quote = wallet.mint_quote(mint_amount, None).await?;
+    let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap();
 
     assert_eq!(mint_quote.amount, mint_amount);
 
-    let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
-    pay_if_regtest(&invoice).await?;
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
+    pay_if_regtest(&invoice).await.unwrap();
 
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
+        .await
+        .unwrap();
 
     let proofs = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
-    let mint_amount = proofs.total_amount()?;
+    let mint_amount = proofs.total_amount().unwrap();
 
     assert!(mint_amount == 100.into());
-
-    Ok(())
 }
 
 /// Tests wallet restoration and proof state verification
@@ -240,66 +244,70 @@ async fn test_happy_mint() -> Result<()> {
 /// This ensures wallet restoration works correctly and that
 /// the mint properly tracks spent proofs across wallet instances.
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_restore() -> Result<()> {
-    let seed = Mnemonic::generate(12)?.to_seed_normalized("");
+async fn test_restore() {
+    let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
+        Arc::new(memory::empty().await.unwrap()),
         &seed,
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
 
-    let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
-    pay_if_regtest(&invoice).await?;
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
+    pay_if_regtest(&invoice).await.unwrap();
 
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
+        .await
+        .unwrap();
 
     let _mint_amount = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
-    assert_eq!(wallet.total_balance().await?, 100.into());
+    assert_eq!(wallet.total_balance().await.unwrap(), 100.into());
 
     let wallet_2 = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
+        Arc::new(memory::empty().await.unwrap()),
         &seed,
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    assert_eq!(wallet_2.total_balance().await?, 0.into());
+    assert_eq!(wallet_2.total_balance().await.unwrap(), 0.into());
 
-    let restored = wallet_2.restore().await?;
-    let proofs = wallet_2.get_unspent_proofs().await?;
+    let restored = wallet_2.restore().await.unwrap();
+    let proofs = wallet_2.get_unspent_proofs().await.unwrap();
 
-    let expected_fee = wallet.get_proofs_fee(&proofs).await?;
+    let expected_fee = wallet.get_proofs_fee(&proofs).await.unwrap();
     wallet_2
         .swap(None, SplitTarget::default(), proofs, None, false)
-        .await?;
+        .await
+        .unwrap();
 
     assert_eq!(restored, 100.into());
 
     // Since we have to do a swap we expect to restore amount - fee
     assert_eq!(
-        wallet_2.total_balance().await?,
+        wallet_2.total_balance().await.unwrap(),
         Amount::from(100) - expected_fee
     );
 
-    let proofs = wallet.get_unspent_proofs().await?;
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
 
-    let states = wallet.check_proofs_spent(proofs).await?;
+    let states = wallet.check_proofs_spent(proofs).await.unwrap();
 
     for state in states {
         if state.state != State::Spent {
-            bail!("All proofs should be spent");
+            panic!("All proofs should be spent");
         }
     }
-
-    Ok(())
 }
 
 /// Tests that change outputs in a melt quote are correctly handled
@@ -313,50 +321,55 @@ async fn test_restore() -> Result<()> {
 /// This ensures the mint correctly processes change outputs during melting operations
 /// and that the wallet can properly verify the change amounts match expectations.
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_fake_melt_change_in_quote() -> Result<()> {
+async fn test_fake_melt_change_in_quote() {
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
 
-    let bolt11 = Bolt11Invoice::from_str(&mint_quote.request)?;
+    let bolt11 = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
 
-    pay_if_regtest(&bolt11).await?;
+    pay_if_regtest(&bolt11).await.unwrap();
 
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
+        .await
+        .unwrap();
 
     let _mint_amount = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
-    let invoice = create_invoice_for_env(Some(9)).await?;
+    let invoice = create_invoice_for_env(Some(9)).await.unwrap();
 
-    let proofs = wallet.get_unspent_proofs().await?;
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
 
-    let keyset = wallet.get_active_mint_keyset().await?;
+    let keyset = wallet.get_active_mint_keyset().await.unwrap();
 
-    let premint_secrets = PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default())?;
+    let premint_secrets =
+        PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default()).unwrap();
 
-    let client = HttpClient::new(get_mint_url_from_env().parse()?, None);
+    let client = HttpClient::new(get_mint_url_from_env().parse().unwrap(), None);
 
-    let melt_request = MeltBolt11Request::new(
+    let melt_request = MeltRequest::new(
         melt_quote.id.clone(),
         proofs.clone(),
         Some(premint_secrets.blinded_messages()),
     );
 
-    let melt_response = client.post_melt(melt_request).await?;
+    let melt_response = client.post_melt(melt_request).await.unwrap();
 
     assert!(melt_response.change.is_some());
 
-    let check = wallet.melt_quote_status(&melt_quote.id).await?;
+    let check = wallet.melt_quote_status(&melt_quote.id).await.unwrap();
     let mut melt_change = melt_response.change.unwrap();
     melt_change.sort_by(|a, b| a.amount.cmp(&b.amount));
 
@@ -364,11 +377,10 @@ async fn test_fake_melt_change_in_quote() -> Result<()> {
     check.sort_by(|a, b| a.amount.cmp(&b.amount));
 
     assert_eq!(melt_change, check);
-    Ok(())
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_pay_invoice_twice() -> Result<()> {
+async fn test_pay_invoice_twice() {
     let ln_backend = match env::var("LN_BACKEND") {
         Ok(val) => Some(val),
         Err(_) => env::var("CDK_MINTD_LN_BACKEND").ok(),
@@ -376,38 +388,44 @@ async fn test_pay_invoice_twice() -> Result<()> {
 
     if ln_backend.map(|ln| ln.to_uppercase()) == Some("FAKEWALLET".to_string()) {
         // We can only perform this test on regtest backends as fake wallet just marks the quote as paid
-        return Ok(());
+        return;
     }
 
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
 
-    pay_if_regtest(&mint_quote.request.parse()?).await?;
+    pay_if_regtest(&mint_quote.request.parse().unwrap())
+        .await
+        .unwrap();
 
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
+        .await
+        .unwrap();
 
     let proofs = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
-    let mint_amount = proofs.total_amount()?;
+    let mint_amount = proofs.total_amount().unwrap();
 
     assert_eq!(mint_amount, 100.into());
 
-    let invoice = create_invoice_for_env(Some(25)).await?;
+    let invoice = create_invoice_for_env(Some(25)).await.unwrap();
 
-    let melt_quote = wallet.melt_quote(invoice.clone(), None).await?;
+    let melt_quote = wallet.melt_quote(invoice.clone(), None).await.unwrap();
 
     let melt = wallet.melt(&melt_quote.id).await.unwrap();
 
-    let melt_two = wallet.melt_quote(invoice, None).await?;
+    let melt_two = wallet.melt_quote(invoice, None).await.unwrap();
 
     let melt_two = wallet.melt(&melt_two.id).await;
 
@@ -415,17 +433,15 @@ async fn test_pay_invoice_twice() -> Result<()> {
         Err(err) => match err {
             cdk::Error::RequestAlreadyPaid => (),
             err => {
-                bail!("Wrong invoice already paid: {}", err.to_string());
+                panic!("Wrong invoice already paid: {}", err.to_string());
             }
         },
         Ok(_) => {
-            bail!("Should not have allowed second payment");
+            panic!("Should not have allowed second payment");
         }
     }
 
-    let balance = wallet.total_balance().await?;
+    let balance = wallet.total_balance().await.unwrap();
 
     assert_eq!(balance, (Amount::from(100) - melt.fee_paid - melt.amount));
-
-    Ok(())
 }

+ 25 - 63
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -8,13 +8,14 @@ use std::assert_eq;
 use std::collections::{HashMap, HashSet};
 use std::hash::RandomState;
 use std::str::FromStr;
+use std::time::Duration;
 
 use cashu::amount::SplitTarget;
 use cashu::dhke::construct_proofs;
 use cashu::mint_url::MintUrl;
 use cashu::{
-    CurrencyUnit, Id, MeltBolt11Request, NotificationPayload, PreMintSecrets, ProofState,
-    SecretKey, SpendingConditions, State, SwapRequest,
+    CurrencyUnit, Id, MeltRequest, NotificationPayload, PreMintSecrets, ProofState, SecretKey,
+    SpendingConditions, State, SwapRequest,
 };
 use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
@@ -24,6 +25,7 @@ use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions};
 use cdk::Amount;
 use cdk_fake_wallet::create_fake_invoice;
 use cdk_integration_tests::init_pure_tests::*;
+use tokio::time::sleep;
 
 /// Tests the token swap and send functionality:
 /// 1. Alice gets funded with 64 sats
@@ -235,15 +237,7 @@ async fn test_mint_double_spend() {
         .await
         .expect("Could not get proofs");
 
-    let keys = mint_bob
-        .pubkeys()
-        .await
-        .unwrap()
-        .keysets
-        .first()
-        .unwrap()
-        .clone()
-        .keys;
+    let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys;
     let keyset_id = Id::from(&keys);
 
     let preswap = PreMintSecrets::random(
@@ -300,15 +294,7 @@ async fn test_attempt_to_swap_by_overflowing() {
 
     let amount = 2_u64.pow(63);
 
-    let keys = mint_bob
-        .pubkeys()
-        .await
-        .unwrap()
-        .keysets
-        .first()
-        .unwrap()
-        .clone()
-        .keys;
+    let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys;
     let keyset_id = Id::from(&keys);
 
     let pre_mint_amount =
@@ -429,15 +415,7 @@ pub async fn test_p2pk_swap() {
 
     let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages());
 
-    let keys = mint_bob
-        .pubkeys()
-        .await
-        .unwrap()
-        .keysets
-        .first()
-        .cloned()
-        .unwrap()
-        .keys;
+    let keys = mint_bob.pubkeys().keysets.first().cloned().unwrap().keys;
 
     let post_swap = mint_bob.process_swap_request(swap_request).await.unwrap();
 
@@ -496,6 +474,8 @@ pub async fn test_p2pk_swap() {
 
     assert!(attempt_swap.is_ok());
 
+    sleep(Duration::from_secs(1)).await;
+
     let mut msgs = HashMap::new();
     while let Ok((sub_id, msg)) = listener.try_recv() {
         assert_eq!(sub_id, "test".into());
@@ -509,10 +489,16 @@ pub async fn test_p2pk_swap() {
         }
     }
 
-    for keys in public_keys_to_listen {
-        let statuses = msgs.remove(&keys).expect("some events");
+    for (i, key) in public_keys_to_listen.into_iter().enumerate() {
+        let statuses = msgs.remove(&key).expect("some events");
         // Every input pk receives two state updates, as there are only two state transitions
-        assert_eq!(statuses, vec![State::Pending, State::Spent]);
+        assert_eq!(
+            statuses,
+            vec![State::Pending, State::Spent],
+            "failed to test key {:?} (pos {})",
+            key,
+            i,
+        );
     }
 
     assert!(listener.try_recv().is_err(), "no other event is happening");
@@ -527,7 +513,7 @@ async fn test_swap_overpay_underpay_fee() {
         .expect("Failed to create test mint");
 
     mint_bob
-        .rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new())
+        .rotate_keyset(CurrencyUnit::Sat, 32, 1)
         .await
         .unwrap();
 
@@ -545,15 +531,7 @@ async fn test_swap_overpay_underpay_fee() {
         .await
         .expect("Could not get proofs");
 
-    let keys = mint_bob
-        .pubkeys()
-        .await
-        .unwrap()
-        .keysets
-        .first()
-        .unwrap()
-        .clone()
-        .keys;
+    let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys;
     let keyset_id = Id::from(&keys);
 
     let preswap = PreMintSecrets::random(keyset_id, 9998.into(), &SplitTarget::default()).unwrap();
@@ -597,7 +575,7 @@ async fn test_mint_enforce_fee() {
         .expect("Failed to create test mint");
 
     mint_bob
-        .rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new())
+        .rotate_keyset(CurrencyUnit::Sat, 32, 1)
         .await
         .unwrap();
 
@@ -619,15 +597,7 @@ async fn test_mint_enforce_fee() {
         .await
         .expect("Could not get proofs");
 
-    let keys = mint_bob
-        .pubkeys()
-        .await
-        .unwrap()
-        .keysets
-        .first()
-        .unwrap()
-        .clone()
-        .keys;
+    let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys;
     let keyset_id = Id::from(&keys);
 
     let five_proofs: Vec<_> = proofs.drain(..5).collect();
@@ -689,7 +659,7 @@ async fn test_mint_change_with_fee_melt() {
         .expect("Failed to create test mint");
 
     mint_bob
-        .rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new())
+        .rotate_keyset(CurrencyUnit::Sat, 32, 1)
         .await
         .unwrap();
 
@@ -855,7 +825,7 @@ async fn test_concurrent_double_spend_melt() {
     let mint_clone2 = mint_bob.clone();
     let mint_clone3 = mint_bob.clone();
 
-    let melt_request = MeltBolt11Request::new(quote_id.parse().unwrap(), proofs.clone(), None);
+    let melt_request = MeltRequest::new(quote_id.parse().unwrap(), proofs.clone(), None);
     let melt_request2 = melt_request.clone();
     let melt_request3 = melt_request.clone();
 
@@ -914,14 +884,6 @@ async fn test_concurrent_double_spend_melt() {
 }
 
 async fn get_keyset_id(mint: &Mint) -> Id {
-    let keys = mint
-        .pubkeys()
-        .await
-        .unwrap()
-        .keysets
-        .first()
-        .unwrap()
-        .clone()
-        .keys;
+    let keys = mint.pubkeys().keysets.first().unwrap().clone().keys;
     Id::from(&keys)
 }

+ 27 - 26
crates/cdk-integration-tests/tests/mint.rs

@@ -7,7 +7,6 @@
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
-use anyhow::Result;
 use bip39::Mnemonic;
 use cdk::cdk_database::MintDatabase;
 use cdk::mint::{MintBuilder, MintMeltLimits};
@@ -19,8 +18,8 @@ use cdk_sqlite::mint::memory;
 pub const MINT_URL: &str = "http://127.0.0.1:8088";
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_correct_keyset() -> Result<()> {
-    let mnemonic = Mnemonic::generate(12)?;
+async fn test_correct_keyset() {
+    let mnemonic = Mnemonic::generate(12).unwrap();
     let fee_reserve = FeeReserve {
         min_fee_reserve: 1.into(),
         percent_fee_reserve: 1.0,
@@ -32,7 +31,9 @@ async fn test_correct_keyset() -> Result<()> {
 
     let mut mint_builder = MintBuilder::new();
     let localstore = Arc::new(database);
-    mint_builder = mint_builder.with_localstore(localstore.clone());
+    mint_builder = mint_builder
+        .with_localstore(localstore.clone())
+        .with_keystore(localstore.clone());
 
     mint_builder = mint_builder
         .add_ln_backend(
@@ -41,52 +42,52 @@ async fn test_correct_keyset() -> Result<()> {
             MintMeltLimits::new(1, 5_000),
             Arc::new(fake_wallet),
         )
-        .await?;
+        .await
+        .unwrap();
 
     mint_builder = mint_builder
         .with_name("regtest mint".to_string())
         .with_description("regtest mint".to_string())
         .with_seed(mnemonic.to_seed_normalized("").to_vec());
 
-    let mint = mint_builder.build().await?;
+    let mint = mint_builder.build().await.unwrap();
 
     localstore
         .set_mint_info(mint_builder.mint_info.clone())
-        .await?;
+        .await
+        .unwrap();
     let quote_ttl = QuoteTTL::new(10000, 10000);
-    localstore.set_quote_ttl(quote_ttl).await?;
+    localstore.set_quote_ttl(quote_ttl).await.unwrap();
+
+    let active = mint.get_active_keysets();
+
+    let active = active
+        .get(&CurrencyUnit::Sat)
+        .expect("There is a keyset for unit");
+    let old_keyset_info = mint.get_keyset_info(active).expect("There is keyset");
 
-    mint.rotate_next_keyset(CurrencyUnit::Sat, 32, 0).await?;
-    mint.rotate_next_keyset(CurrencyUnit::Sat, 32, 0).await?;
+    mint.rotate_keyset(CurrencyUnit::Sat, 32, 0).await.unwrap();
 
-    let active = mint.localstore.get_active_keysets().await?;
+    let active = mint.get_active_keysets();
 
     let active = active
         .get(&CurrencyUnit::Sat)
         .expect("There is a keyset for unit");
 
-    let keyset_info = mint
-        .localstore
-        .get_keyset_info(active)
-        .await?
-        .expect("There is keyset");
+    let keyset_info = mint.get_keyset_info(active).expect("There is keyset");
 
-    assert!(keyset_info.derivation_path_index == Some(2));
+    assert_ne!(keyset_info.id, old_keyset_info.id);
 
-    let mint = mint_builder.build().await?;
+    mint.rotate_keyset(CurrencyUnit::Sat, 32, 0).await.unwrap();
+    let mint = mint_builder.build().await.unwrap();
 
-    let active = mint.localstore.get_active_keysets().await?;
+    let active = mint.get_active_keysets();
 
     let active = active
         .get(&CurrencyUnit::Sat)
         .expect("There is a keyset for unit");
 
-    let keyset_info = mint
-        .localstore
-        .get_keyset_info(active)
-        .await?
-        .expect("There is keyset");
+    let new_keyset_info = mint.get_keyset_info(active).expect("There is keyset");
 
-    assert!(keyset_info.derivation_path_index == Some(2));
-    Ok(())
+    assert_ne!(new_keyset_info.id, keyset_info.id);
 }

+ 0 - 2
crates/cdk-integration-tests/tests/nutshell_wallet.rs

@@ -104,8 +104,6 @@ async fn get_wallet_balance(base_url: &str) -> u64 {
         .await
         .expect("Failed to parse balance response");
 
-    println!("Wallet balance: {:?}", balance);
-
     balance["balance"]
         .as_u64()
         .expect("Could not parse balance as u64")

+ 119 - 79
crates/cdk-integration-tests/tests/regtest.rs

@@ -2,12 +2,11 @@ use std::str::FromStr;
 use std::sync::Arc;
 use std::time::Duration;
 
-use anyhow::{bail, Result};
 use bip39::Mnemonic;
 use cashu::ProofsMethods;
 use cdk::amount::{Amount, SplitTarget};
 use cdk::nuts::{
-    CurrencyUnit, MeltOptions, MeltQuoteState, MintBolt11Request, MintQuoteState, Mpp,
+    CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState, MintRequest, Mpp,
     NotificationPayload, PreMintSecrets,
 };
 use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription};
@@ -40,46 +39,59 @@ async fn init_lnd_client() -> LndClient {
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_internal_payment() -> Result<()> {
+async fn test_internal_payment() {
     let lnd_client = init_lnd_client().await;
 
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
 
-    lnd_client.pay_invoice(mint_quote.request).await?;
+    lnd_client
+        .pay_invoice(mint_quote.request)
+        .await
+        .expect("failed to pay invoice");
 
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
+        .await
+        .unwrap();
 
     let _mint_amount = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
-    assert!(wallet.total_balance().await? == 100.into());
+    assert!(wallet.total_balance().await.unwrap() == 100.into());
 
     let wallet_2 = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    let mint_quote = wallet_2.mint_quote(10.into(), None).await?;
+    let mint_quote = wallet_2.mint_quote(10.into(), None).await.unwrap();
 
-    let melt = wallet.melt_quote(mint_quote.request.clone(), None).await?;
+    let melt = wallet
+        .melt_quote(mint_quote.request.clone(), None)
+        .await
+        .unwrap();
 
     assert_eq!(melt.amount, 10.into());
 
     let _melted = wallet.melt(&melt.id).await.unwrap();
 
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
+        .await
+        .unwrap();
 
     let _wallet_2_mint = wallet_2
         .mint(&mint_quote.id, SplitTarget::default(), None)
@@ -89,9 +101,9 @@ async fn test_internal_payment() -> Result<()> {
     let check_paid = match get_mint_port("0") {
         8085 => {
             let cln_one_dir = get_cln_dir("one");
-            let cln_client = ClnClient::new(cln_one_dir.clone(), None).await?;
+            let cln_client = ClnClient::new(cln_one_dir.clone(), None).await.unwrap();
 
-            let payment_hash = Bolt11Invoice::from_str(&mint_quote.request)?;
+            let payment_hash = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
             cln_client
                 .check_incoming_payment_status(&payment_hash.payment_hash().to_string())
                 .await
@@ -104,8 +116,9 @@ async fn test_internal_payment() -> Result<()> {
                 get_lnd_cert_file_path(&lnd_two_dir),
                 get_lnd_macaroon_path(&lnd_two_dir),
             )
-            .await?;
-            let payment_hash = Bolt11Invoice::from_str(&mint_quote.request)?;
+            .await
+            .unwrap();
+            let payment_hash = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
             lnd_client
                 .check_incoming_payment_status(&payment_hash.payment_hash().to_string())
                 .await
@@ -117,33 +130,32 @@ async fn test_internal_payment() -> Result<()> {
     match check_paid {
         InvoiceStatus::Unpaid => (),
         _ => {
-            bail!("Invoice has incorrect status: {:?}", check_paid);
+            panic!("Invoice has incorrect status: {:?}", check_paid);
         }
     }
 
-    let wallet_2_balance = wallet_2.total_balance().await?;
+    let wallet_2_balance = wallet_2.total_balance().await.unwrap();
 
     assert!(wallet_2_balance == 10.into());
 
-    let wallet_1_balance = wallet.total_balance().await?;
+    let wallet_1_balance = wallet.total_balance().await.unwrap();
 
     assert!(wallet_1_balance == 90.into());
-
-    Ok(())
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_websocket_connection() -> Result<()> {
+async fn test_websocket_connection() {
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(wallet::memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(wallet::memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
     // Create a small mint quote to test notifications
-    let mint_quote = wallet.mint_quote(10.into(), None).await?;
+    let mint_quote = wallet.mint_quote(10.into(), None).await.unwrap();
 
     // Subscribe to notifications for this quote
     let mut subscription = wallet
@@ -156,76 +168,92 @@ async fn test_websocket_connection() -> Result<()> {
     let msg = timeout(Duration::from_secs(10), subscription.recv())
         .await
         .expect("timeout waiting for unpaid notification")
-        .ok_or_else(|| anyhow::anyhow!("No unpaid notification received"))?;
+        .expect("No paid notification received");
 
     match msg {
         NotificationPayload::MintQuoteBolt11Response(response) => {
             assert_eq!(response.quote.to_string(), mint_quote.id);
             assert_eq!(response.state, MintQuoteState::Unpaid);
         }
-        _ => bail!("Unexpected notification type"),
+        _ => panic!("Unexpected notification type"),
     }
 
     let lnd_client = init_lnd_client().await;
-    lnd_client.pay_invoice(mint_quote.request).await?;
+    lnd_client
+        .pay_invoice(mint_quote.request)
+        .await
+        .expect("failed to pay invoice");
 
     // Wait for paid notification with 10 second timeout
     let msg = timeout(Duration::from_secs(10), subscription.recv())
         .await
         .expect("timeout waiting for paid notification")
-        .ok_or_else(|| anyhow::anyhow!("No paid notification received"))?;
+        .expect("No paid notification received");
 
     match msg {
         NotificationPayload::MintQuoteBolt11Response(response) => {
             assert_eq!(response.quote.to_string(), mint_quote.id);
             assert_eq!(response.state, MintQuoteState::Paid);
-            Ok(())
         }
-        _ => bail!("Unexpected notification type"),
+        _ => panic!("Unexpected notification type"),
     }
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_multimint_melt() -> Result<()> {
+async fn test_multimint_melt() {
     let lnd_client = init_lnd_client().await;
 
-    let db = Arc::new(memory::empty().await?);
+    let db = Arc::new(memory::empty().await.unwrap());
     let wallet1 = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
         db,
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    let db = Arc::new(memory::empty().await?);
+    let db = Arc::new(memory::empty().await.unwrap());
     let wallet2 = Wallet::new(
         &get_second_mint_url_from_env(),
         CurrencyUnit::Sat,
         db,
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
     let mint_amount = Amount::from(100);
 
     // Fund the wallets
-    let quote = wallet1.mint_quote(mint_amount, None).await?;
-    lnd_client.pay_invoice(quote.request.clone()).await?;
-    wait_for_mint_to_be_paid(&wallet1, &quote.id, 60).await?;
+    let quote = wallet1.mint_quote(mint_amount, None).await.unwrap();
+    lnd_client
+        .pay_invoice(quote.request.clone())
+        .await
+        .expect("failed to pay invoice");
+    wait_for_mint_to_be_paid(&wallet1, &quote.id, 60)
+        .await
+        .unwrap();
     wallet1
         .mint(&quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
-    let quote = wallet2.mint_quote(mint_amount, None).await?;
-    lnd_client.pay_invoice(quote.request.clone()).await?;
-    wait_for_mint_to_be_paid(&wallet2, &quote.id, 60).await?;
+    let quote = wallet2.mint_quote(mint_amount, None).await.unwrap();
+    lnd_client
+        .pay_invoice(quote.request.clone())
+        .await
+        .expect("failed to pay invoice");
+    wait_for_mint_to_be_paid(&wallet2, &quote.id, 60)
+        .await
+        .unwrap();
     wallet2
         .mint(&quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
     // Get an invoice
-    let invoice = lnd_client.create_invoice(Some(50)).await?;
+    let invoice = lnd_client.create_invoice(Some(50)).await.unwrap();
 
     // Get multi-part melt quotes
     let melt_options = MeltOptions::Mpp {
@@ -254,33 +282,38 @@ async fn test_multimint_melt() -> Result<()> {
     // Check
     assert!(result1.state == result2.state);
     assert!(result1.state == MeltQuoteState::Paid);
-    Ok(())
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_cached_mint() -> Result<()> {
+async fn test_cached_mint() {
     let lnd_client = init_lnd_client().await;
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
     let mint_amount = Amount::from(100);
 
-    let quote = wallet.mint_quote(mint_amount, None).await?;
-    lnd_client.pay_invoice(quote.request.clone()).await?;
+    let quote = wallet.mint_quote(mint_amount, None).await.unwrap();
+    lnd_client
+        .pay_invoice(quote.request.clone())
+        .await
+        .expect("failed to pay invoice");
 
-    wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?;
+    wait_for_mint_to_be_paid(&wallet, &quote.id, 60)
+        .await
+        .unwrap();
 
-    let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
+    let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id;
     let http_client = HttpClient::new(get_mint_url_from_env().parse().unwrap(), None);
     let premint_secrets =
         PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap();
 
-    let mut request = MintBolt11Request {
+    let mut request = MintRequest {
         quote: quote.id,
         outputs: premint_secrets.blinded_messages(),
         signature: None,
@@ -288,52 +321,59 @@ async fn test_cached_mint() -> Result<()> {
 
     let secret_key = quote.secret_key;
 
-    request.sign(secret_key.expect("Secret key on quote"))?;
+    request
+        .sign(secret_key.expect("Secret key on quote"))
+        .unwrap();
 
-    let response = http_client.post_mint(request.clone()).await?;
-    let response1 = http_client.post_mint(request).await?;
+    let response = http_client.post_mint(request.clone()).await.unwrap();
+    let response1 = http_client.post_mint(request).await.unwrap();
 
     assert!(response == response1);
-    Ok(())
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_regtest_melt_amountless() -> Result<()> {
+async fn test_regtest_melt_amountless() {
     let lnd_client = init_lnd_client().await;
 
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
     let mint_amount = Amount::from(100);
 
-    let mint_quote = wallet.mint_quote(mint_amount, None).await?;
+    let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap();
 
     assert_eq!(mint_quote.amount, mint_amount);
 
-    lnd_client.pay_invoice(mint_quote.request).await?;
+    lnd_client
+        .pay_invoice(mint_quote.request)
+        .await
+        .expect("failed to pay invoice");
 
     let proofs = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
-    let amount = proofs.total_amount()?;
+    let amount = proofs.total_amount().unwrap();
 
     assert!(mint_amount == amount);
 
-    let invoice = lnd_client.create_invoice(None).await?;
+    let invoice = lnd_client.create_invoice(None).await.unwrap();
 
     let options = MeltOptions::new_amountless(5_000);
 
-    let melt_quote = wallet.melt_quote(invoice.clone(), Some(options)).await?;
+    let melt_quote = wallet
+        .melt_quote(invoice.clone(), Some(options))
+        .await
+        .unwrap();
 
     let melt = wallet.melt(&melt_quote.id).await.unwrap();
 
     assert!(melt.amount == 5.into());
-
-    Ok(())
 }

+ 42 - 35
crates/cdk-integration-tests/tests/test_fees.rs

@@ -1,7 +1,6 @@
 use std::str::FromStr;
 use std::sync::Arc;
 
-use anyhow::Result;
 use bip39::Mnemonic;
 use cashu::{Bolt11Invoice, ProofsMethods};
 use cdk::amount::{Amount, SplitTarget};
@@ -13,28 +12,31 @@ use cdk_integration_tests::{
 use cdk_sqlite::wallet::memory;
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_swap() -> Result<()> {
-    let seed = Mnemonic::generate(12)?.to_seed_normalized("");
+async fn test_swap() {
+    let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
+        Arc::new(memory::empty().await.unwrap()),
         &seed,
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
 
-    let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
-    pay_if_regtest(&invoice).await?;
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
+    pay_if_regtest(&invoice).await.unwrap();
 
     let _mint_amount = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
     let proofs: Vec<Amount> = wallet
         .get_unspent_proofs()
-        .await?
+        .await
+        .unwrap()
         .iter()
         .map(|p| p.amount)
         .collect();
@@ -49,65 +51,72 @@ async fn test_swap() -> Result<()> {
                 ..Default::default()
             },
         )
-        .await?;
+        .await
+        .unwrap();
 
     let proofs = send.proofs();
 
-    let fee = wallet.get_proofs_fee(&proofs).await?;
+    let fee = wallet.get_proofs_fee(&proofs).await.unwrap();
 
     assert_eq!(fee, 1.into());
 
-    let send = wallet.send(send, None).await?;
+    let send = wallet.send(send, None).await.unwrap();
 
     let rec_amount = wallet
         .receive(&send.to_string(), ReceiveOptions::default())
-        .await?;
+        .await
+        .unwrap();
 
     assert_eq!(rec_amount, 3.into());
 
-    let wallet_balance = wallet.total_balance().await?;
+    let wallet_balance = wallet.total_balance().await.unwrap();
 
     assert_eq!(wallet_balance, 99.into());
-
-    Ok(())
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_fake_melt_change_in_quote() -> Result<()> {
+async fn test_fake_melt_change_in_quote() {
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        Arc::new(memory::empty().await.unwrap()),
+        &Mnemonic::generate(12).unwrap().to_seed_normalized(""),
         None,
-    )?;
+    )
+    .expect("failed to create new wallet");
 
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
 
-    let bolt11 = Bolt11Invoice::from_str(&mint_quote.request)?;
+    let bolt11 = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
 
-    pay_if_regtest(&bolt11).await?;
+    pay_if_regtest(&bolt11).await.unwrap();
 
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
+        .await
+        .unwrap();
 
     let _mint_amount = wallet
         .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
+        .await
+        .unwrap();
 
     let invoice_amount = 9;
 
-    let invoice = create_invoice_for_env(Some(invoice_amount)).await?;
+    let invoice = create_invoice_for_env(Some(invoice_amount)).await.unwrap();
 
-    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
 
-    let proofs = wallet.get_unspent_proofs().await?;
+    let proofs = wallet.get_unspent_proofs().await.unwrap();
 
     let proofs_total = proofs.total_amount().unwrap();
 
-    let fee = wallet.get_proofs_fee(&proofs).await?;
-    let melt = wallet.melt_proofs(&melt_quote.id, proofs.clone()).await?;
+    let fee = wallet.get_proofs_fee(&proofs).await.unwrap();
+    let melt = wallet
+        .melt_proofs(&melt_quote.id, proofs.clone())
+        .await
+        .unwrap();
     let change = melt.change.unwrap().total_amount().unwrap();
-    let idk = proofs.total_amount()? - Amount::from(invoice_amount) - change;
+    let idk = proofs.total_amount().unwrap() - Amount::from(invoice_amount) - change;
 
     println!("{}", idk);
     println!("{}", fee);
@@ -117,9 +126,7 @@ async fn test_fake_melt_change_in_quote() -> Result<()> {
     let ln_fee = 1;
 
     assert_eq!(
-        wallet.total_balance().await?,
+        wallet.total_balance().await.unwrap(),
         Amount::from(100 - invoice_amount - u64::from(fee) - ln_fee)
     );
-
-    Ok(())
 }

+ 2 - 2
crates/cdk-lnbits/Cargo.toml

@@ -15,11 +15,11 @@ async-trait.workspace = true
 anyhow.workspace = true
 axum.workspace = true
 bitcoin.workspace = true
-cdk = { workspace = true, features = ["mint"] }
+cdk-common = { workspace = true, features = ["mint"] }
 futures.workspace = true
 tokio.workspace = true
 tokio-util.workspace = true
 tracing.workspace = true
 thiserror.workspace = true
-lnbits-rs = "0.4.0"
+lnbits-rs = "0.6.0"
 serde_json.workspace = true

+ 8 - 16
crates/cdk-lnbits/README.md

@@ -1,22 +1,14 @@
-# CDK LNbits
+# CDK LNBits
 
-[![crates.io](https://img.shields.io/crates/v/cdk-lnbits.svg)](https://crates.io/crates/cdk-lnbits) [![Documentation](https://docs.rs/cdk-lnbits/badge.svg)](https://docs.rs/cdk-lnbits)
+[![crates.io](https://img.shields.io/crates/v/cdk-lnbits.svg)](https://crates.io/crates/cdk-lnbits)
+[![Documentation](https://docs.rs/cdk-lnbits/badge.svg)](https://docs.rs/cdk-lnbits)
+[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE)
 
-The CDK LNbits crate is a component of the [Cashu Development Kit](https://github.com/cashubtc/cdk) that provides integration with [LNbits](https://lnbits.com/) as a Lightning Network backend for Cashu mints.
+**ALPHA** This library is in early development, the API will change and should be used with caution.
 
-## Overview
+LNBits backend implementation for the Cashu Development Kit (CDK). This provides integration with [LNBits](https://lnbits.com/) for Lightning Network functionality.
 
-This crate implements the `MintPayment` trait for LNbits, allowing Cashu mints to use LNbits as a payment backend for handling Lightning Network transactions.
-
-## Features
-
-- Create and pay Lightning invoices via LNbits
-- Handle webhook callbacks for payment notifications
-- Manage fee reserves for Lightning transactions
-- Support for invoice descriptions
-- MPP (Multi-Path Payment) support
-
-## Usage
+## Installation
 
 Add this to your `Cargo.toml`:
 
@@ -27,4 +19,4 @@ cdk-lnbits = "*"
 
 ## License
 
-This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/LICENSE).
+This project is licensed under the [MIT License](../../LICENSE).

+ 1 - 1
crates/cdk-lnbits/src/error.rs

@@ -19,7 +19,7 @@ pub enum Error {
     Anyhow(#[from] anyhow::Error),
 }
 
-impl From<Error> for cdk::cdk_payment::Error {
+impl From<Error> for cdk_common::payment::Error {
     fn from(e: Error) -> Self {
         Self::Lightning(Box::new(e))
     }

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.