127 Commits 0cd1d6a194 ... 3626dd2f6a

Autore SHA1 Messaggio Data
  thesimplekid 3626dd2f6a Merge pull request #727 from thesimplekid/fix_debug_info_panic 1 mese fa
  thesimplekid 717742be05 fix: debug print of info 1 mese fa
  thesimplekid (aider) f5f3e50507 fix: Correct mnemonic hashing in Debug implementation 1 mese fa
  thesimplekid 607cdf23d4 feat: Add robust mnemonic hashing and debug tests for Info struct 1 mese fa
  thesimplekid 7715d45f3b Merge pull request #724 from thesimplekid/token_value_uniqe 1 mese fa
  thesimplekid 43d1d75b7e feat: mint should not enforce expiry (#723) 1 mese fa
  thesimplekid (aider) 3b5c8b5c5e refactor: Ensure unique proofs when calculating token value 1 mese fa
  thesimplekid 0d512c1d15 feat: mint should not enforce expiry 1 mese fa
  lollerfirst dafdf757af CORS Headers in Responses (#719) 1 mese fa
  timesince 5df983c388 chore: fix typo in DEVELOPMENT.md (#720) 1 mese fa
  thesimplekid 96179b7d14 chore: Bump CDK crates version from 0.8.1 to 0.9.0 1 mese fa
  thesimplekid db067a145d docs: Add README.md for cdk-mint-rpc crate (#717) 1 mese fa
  thesimplekid f44f79d3e0 fix: grpc set mint urls, updating description and add get quote ttl (#716) 1 mese fa
  thesimplekid fb613b2e21 Merge pull request #713 from thesimplekid/fix_amountless_enabled_check 1 mese fa
  thesimplekid 92500c27fe Merge pull request #712 from gandlafbtc/patch-2 1 mese fa
  thesimplekid e883d2d684 fix: amountless setting 1 mese fa
  gandlafbtc 017ce71bff fix typo in README.md 1 mese fa
  thesimplekid b1c7aed8e4 Merge pull request #711 from thesimplekid/changel 1 mese fa
  thesimplekid cce8dbfe7e docs: update change log 1 mese fa
  thesimplekid 759201bc7c Export MintDatabase traits in the cdk crate (#710) 1 mese fa
  David Caseria 3514f362eb Export MintDatabase traits in the cdk crate 1 mese fa
  thesimplekid 0b9ca1a474 Time time series (#708) 1 mese fa
  C 43ab1fdde1 Do not create the wallet struct directly; instead, call new. (#707) 1 mese fa
  thesimplekid d224cc57b5 Melt to amountless invoice (#497) 1 mese fa
  thesimplekid 09f339e6c6 Merge pull request #704 from thesimplekid/fix_mint_pending 1 mese fa
  thesimplekid 71bfe1ff9c fix: mint pending get mint info to create auth wallet 1 mese fa
  thesimplekid d68fdd1a0c Merge pull request #703 from thesimplekid/fix_nutshell_tests 1 mese fa
  thesimplekid d6d3955d50 fix: nutshell tests 1 mese fa
  thesimplekid 4a113ff947 Merge pull request #700 from thesimplekid/update_flake_2 1 mese fa
  David Caseria b1dd321f0a Add transactions to database (#686) 1 mese fa
  thesimplekid 7fbe55ea02 Test fees (#698) 1 mese fa
  thesimplekid f0766d0ae4 Merge pull request #701 from luozexuan/main 1 mese fa
  luozexuan 02fd849870 chore: fix some typos in comment 1 mese fa
  thesimplekid afbf844dbe chore: update flake 1 mese fa
  thesimplekid f4c857c3e7 Nutshell wallet (#695) 1 mese fa
  ok300 0eb5805f6f Mint example config: remove stale cln_path reference (#694) 1 mese fa
  thesimplekid 52bfc8c9ce feat: nutshell itests (#691) 1 mese fa
  ok300 240e22c96a Remove stale crate references (#692) 1 mese fa
  thesimplekid fa67271cca Int tests (#685) 1 mese fa
  thesimplekid 5484e7c33a Merge pull request #690 from thesimplekid/request_without_dleq 1 mese fa
  thesimplekid 4ba0b6c6ef Merge pull request #593 from BitcreditProtocol/peanut/mintdatabase_split 1 mese fa
  ok300 de4285bd9c Simplify `MultiMintWallet` interface (#664) 1 mese fa
  codingpeanut157 47903c3bfd split MintDatabase into separate narrower scoped traits 1 mese fa
  thesimplekid 7b4951041e Rust docs (#681) 1 mese fa
  thesimplekid 1e20e8fc2b Merge pull request #682 from thesimplekid/cdk_common_wallet 1 mese fa
  thesimplekid d1c9dbae28 refactor: cashu wallet moved to cdk-common 1 mese fa
  thesimplekid e86531957f Merge pull request #680 from thesimplekid/prepare_v0.8.1 1 mese fa
  thesimplekid f6c11173f9 chore: prepare v0.8.1 1 mese fa
  thesimplekid ef2b07d1e2 Merge pull request #677 from thesimplekid/cdk-redb-cli 1 mese fa
  thesimplekid 29452debe4 Merge pull request #679 from thesimplekid/docker_publish_ci 1 mese fa
  thesimplekid 1634dd6552 fix: remove arm for docker ci 1 mese fa
  thesimplekid 0454f6299e Merge pull request #678 from benthecarman/fix-paths 1 mese fa
  benthecarman b8fbd83772 fix: Fix MintUrls with a path 1 mese fa
  thesimplekid 4224ebdf19 Merge pull request #676 from thesimplekid/proto_exp 1 mese fa
  thesimplekid (aider) 7a9faec984 feat: Add optional redb feature flag for database support 1 mese fa
  thesimplekid 9b87a65940 docs: changelog 1 mese fa
  thesimplekid e260a12e4c fix: exp for mint-rpc 1 mese fa
  thesimplekid ad14f64f36 Merge pull request #671 from optout21/melt-example 1 mese fa
  thesimplekid ff654ab4b1 Merge pull request #673 from davidcaseria/export-mint-keyset-info 1 mese fa
  thesimplekid abbe1682e4 docs: changelog 1 mese fa
  David Caseria cd3a54e03b Export MintKeySetInfo 1 mese fa
  thesimplekid c63fc02a5a Prepare v0.8.0 (#672) 1 mese fa
  optout d5104a94eb sample: Add example program for melt 1 mese fa
  thesimplekid b3ae76d6c7 Fix dleq logging (#670) 1 mese fa
  thesimplekid be93ff2384 Clear and Blind Auth (#510) 1 mese fa
  thesimplekid cd71cd47d9 Merge pull request #669 from ok300/ok300-simplify-fee-reserve 1 mese fa
  ok300 13475be580 Simplify fee calculation 1 mese fa
  thesimplekid 27636c86b7 chore: zip version (#668) 1 mese fa
  thesimplekid e3570c3e98 Wallet dleq (#667) 1 mese fa
  thesimplekid 5ba2699eb7 Merge pull request #665 from thesimplekid/mint_mod_types 1 mese fa
  thesimplekid c48b5202f0 refactor: move Mint and Melt quote to cdk common 1 mese fa
  thesimplekid bcc9871656 Merge pull request #663 from davidcaseria/protoc-fix 1 mese fa
  David Caseria 24a9446581 Fix protoc build error 1 mese fa
  thesimplekid fe06b93db4 docs: changelog 1 mese fa
  David Caseria db1db86509 Prepared Send (#596) 1 mese fa
  thesimplekid c4488ce436 Merge pull request #662 from ok300/ok300-fix-sk-serde 1 mese fa
  ok300 558024d7fe Ser/Deserialize SecretKey either as bytes or string 1 mese fa
  thesimplekid 1cfb51a4c3 Merge pull request #659 from thesimplekid/half_msrv 2 mesi fa
  thesimplekid 619a89060c chore: msrv of half 2 mesi fa
  thesimplekid 4c447cf046 Merge pull request #656 from thesimplekid/payment_request_builder 2 mesi fa
  thesimplekid 0155962d11 Update crates/cashu/src/nuts/nut18.rs 2 mesi fa
  thesimplekid be1e048f2c Update crates/cashu/src/nuts/nut18.rs 2 mesi fa
  thesimplekid eb5899843a feat: export transport type 2 mesi fa
  thesimplekid (aider) 32ded596cd feat: payments request builder 2 mesi fa
  thesimplekid 60367cdd65 Merge pull request #655 from thesimplekid/fix_output_verification 2 mesi fa
  thesimplekid cf9cacaff4 fix: verification of melt quote with empty outputs 2 mesi fa
  thesimplekid 0731f9e809 docs: changelog 2 mesi fa
  thesimplekid 110245e6c8 fix: Improve spending conditions validation in ProofInfo (#654) 2 mesi fa
  thesimplekid 8d1b35f52e fix: Improve spending conditions validation in ProofInfo 2 mesi fa
  ok300 72dff95322 Merge pull request #653 from ok300/ok300-fix-update-mint-url 2 mesi fa
  thesimplekid 158e321e0e Merge pull request #652 from ok300/ok300-fix-integration-test-loop 2 mesi fa
  ok300 3ba3449c81 Integration tests: fix wait_for_mint_to_be_paid loop 2 mesi fa
  thesimplekid 7a97510ec1 Merge pull request #651 from benthecarman/update-sqlx 2 mesi fa
  benthecarman 8cd4ea301a chore: Update sqlx to 0.7.4 2 mesi fa
  thesimplekid 40e1b5dda4 Merge pull request #649 from thesimplekid/ci_clean_up 2 mesi fa
  thesimplekid (aider) 9170fbe86c ci: Make all jobs depend on pre-commit-checks passing 2 mesi fa
  thesimplekid a691df7916 Merge pull request #650 from benthecarman/rm-clone 2 mesi fa
  benthecarman 080f4ef2bf fix: remove unnecessary clone 2 mesi fa
  thesimplekid 3bf908d89d Merge pull request #648 from TechVest/main 2 mesi fa
  thesimplekid a7c7a4caea Merge pull request #597 from thesimplekid/refactor_payment_processor 2 mesi fa
  thesimplekid 162507c492 feat: payment processor 3 mesi fa
  TechVest c5284b889f chore: remove redundant words in comment 2 mesi fa
  ok300 1131711d91 Drop `nostr_last_checked` table, remove references (#647) 2 mesi fa
  thesimplekid 1d4245549b Merge pull request #646 from thesimplekid/flake_update 2 mesi fa
  thesimplekid 4c4bde0fe4 chore: update flake 2 mesi fa
  thesimplekid 379d5590db docs: update changelog 2 mesi fa
  thesimplekid 3b2f31e844 Merge pull request #645 from thesimplekid/remove_nostr_from_db 2 mesi fa
  thesimplekid (aider) cb87fefacd refactor: Remove nostr last checked methods from database trait and implementations 2 mesi fa
  thesimplekid 214b75ac31 Merge pull request #640 from benthecarman/sqlcipher 2 mesi fa
  benthecarman 40c53e83df feat: Add support for sqlcipher 2 mesi fa
  thesimplekid b787951dbc feat: Add feature gates for CLN, LND, fakewallet and LNbits backends (#638) 2 mesi fa
  thesimplekid a3993c3e4c Merge pull request #642 from thesimplekid/remove_unused_supported 2 mesi fa
  thesimplekid 93f7979a70 refactor: remove unused ln_backends in cdk-mintd 2 mesi fa
  thesimplekid 467cc0a027 feat: Add migration for keyset_id as foreign key in SQLite database (#634) 2 mesi fa
  thesimplekid 39a7b15221 Check tls certs exist for grpc management serve (#637) 2 mesi fa
  thesimplekid 22beade553 Amount and unit nut04/05 (#635) 2 mesi fa
  thesimplekid b7380dc858 feat: Add tos_url to mintd config (#636) 2 mesi fa
  NodlAndHodl fcf2e9d603 feat: adding tos to mint (#604) 2 mesi fa
  ok300 5a7362c09f Simplify `process_swap_request` (#631) 2 mesi fa
  thesimplekid 393c95e115 Merge pull request #630 from thesimplekid/db_check_on_delete_proofs 2 mesi fa
  thesimplekid a394145fba Merge pull request #629 from ok300/ok300-ensure-cdk 2 mesi fa
  thesimplekid (aider) d41d3a7c94 refactor: Add state check before deleting proofs to prevent removing spent proofs 2 mesi fa
  ok300 813794353a Add ensure_cdk macro 6 mesi fa
  thesimplekid c6200331cf Merge pull request #626 from thesimplekid/remove_phd 2 mesi fa
  thesimplekid f5be0ceeb6 chore: remove phd 2 mesi fa
  thesimplekid ca1fca2825 chore: update CHANGELOG 2 mesi fa
  thesimplekid e84d6ea7ab chore: Update rust-version (MSRV) to 1.75.0 (#623) 2 mesi fa
100 ha cambiato i file con 8008 aggiunte e 1978 eliminazioni
  1. 158 36
      .github/workflows/ci.yml
  2. 60 0
      .github/workflows/docker-publish.yml
  3. 43 0
      .github/workflows/nutshell_itest.yml
  4. 2 0
      .gitignore
  5. 4 1
      .typos.toml
  6. 105 54
      CHANGELOG.md
  7. 95 1
      Cargo.toml
  8. 1 1
      DEVELOPMENT.md
  9. 21 3
      README.md
  10. 26 26
      crates/cashu/Cargo.toml
  11. 102 0
      crates/cashu/README.md
  12. 55 0
      crates/cashu/src/amount.rs
  13. 15 5
      crates/cashu/src/lib.rs
  14. 41 5
      crates/cashu/src/mint_url.rs
  15. 8 0
      crates/cashu/src/nuts/auth/mod.rs
  16. 410 0
      crates/cashu/src/nuts/auth/nut21.rs
  17. 380 0
      crates/cashu/src/nuts/auth/nut22.rs
  18. 12 1
      crates/cashu/src/nuts/mod.rs
  19. 100 6
      crates/cashu/src/nuts/nut00/mod.rs
  20. 90 38
      crates/cashu/src/nuts/nut00/token.rs
  21. 9 2
      crates/cashu/src/nuts/nut01/public_key.rs
  22. 37 11
      crates/cashu/src/nuts/nut01/secret_key.rs
  23. 23 9
      crates/cashu/src/nuts/nut02.rs
  24. 26 7
      crates/cashu/src/nuts/nut03.rs
  25. 10 13
      crates/cashu/src/nuts/nut04.rs
  26. 93 40
      crates/cashu/src/nuts/nut05.rs
  27. 126 52
      crates/cashu/src/nuts/nut06.rs
  28. 6 2
      crates/cashu/src/nuts/nut07.rs
  29. 1 1
      crates/cashu/src/nuts/nut08.rs
  30. 3 7
      crates/cashu/src/nuts/nut11/mod.rs
  31. 3 3
      crates/cashu/src/nuts/nut14/mod.rs
  32. 2 0
      crates/cashu/src/nuts/nut17/mod.rs
  33. 238 2
      crates/cashu/src/nuts/nut18.rs
  34. 3 0
      crates/cashu/src/nuts/nut19.rs
  35. 3 3
      crates/cashu/src/util/hex.rs
  36. 2 0
      crates/cashu/src/util/mod.rs
  37. 0 87
      crates/cashu/src/wallet.rs
  38. 29 25
      crates/cdk-axum/Cargo.toml
  39. 31 0
      crates/cdk-axum/README.md
  40. 194 0
      crates/cdk-axum/src/auth.rs
  41. 2 2
      crates/cdk-axum/src/cache/mod.rs
  42. 64 10
      crates/cdk-axum/src/lib.rs
  43. 172 27
      crates/cdk-axum/src/router_handlers.rs
  44. 2 3
      crates/cdk-axum/src/ws/mod.rs
  45. 26 29
      crates/cdk-cli/Cargo.toml
  46. 67 37
      crates/cdk-cli/src/main.rs
  47. 37 0
      crates/cdk-cli/src/nostr_storage.rs
  48. 194 0
      crates/cdk-cli/src/sub_commands/cat_device_login.rs
  49. 138 0
      crates/cdk-cli/src/sub_commands/cat_login.rs
  50. 2 2
      crates/cdk-cli/src/sub_commands/create_request.rs
  51. 33 20
      crates/cdk-cli/src/sub_commands/melt.rs
  52. 5 9
      crates/cdk-cli/src/sub_commands/mint.rs
  53. 204 0
      crates/cdk-cli/src/sub_commands/mint_blind_auth.rs
  54. 2 2
      crates/cdk-cli/src/sub_commands/mint_info.rs
  55. 3 0
      crates/cdk-cli/src/sub_commands/mod.rs
  56. 9 11
      crates/cdk-cli/src/sub_commands/pay_request.rs
  57. 31 27
      crates/cdk-cli/src/sub_commands/receive.rs
  58. 4 9
      crates/cdk-cli/src/sub_commands/restore.rs
  59. 14 9
      crates/cdk-cli/src/sub_commands/send.rs
  60. 62 0
      crates/cdk-cli/src/token_storage.rs
  61. 15 13
      crates/cdk-cln/Cargo.toml
  62. 1 1
      crates/cdk-cln/src/error.rs
  63. 47 38
      crates/cdk-cln/src/lib.rs
  64. 25 28
      crates/cdk-common/Cargo.toml
  65. 41 0
      crates/cdk-common/README.md
  66. 108 7
      crates/cdk-common/src/common.rs
  67. 72 0
      crates/cdk-common/src/database/mint/auth/mod.rs
  68. 50 16
      crates/cdk-common/src/database/mint/mod.rs
  69. 23 1
      crates/cdk-common/src/database/mod.rs
  70. 19 19
      crates/cdk-common/src/database/wallet.rs
  71. 95 3
      crates/cdk-common/src/error.rs
  72. 11 10
      crates/cdk-common/src/lib.rs
  73. 70 2
      crates/cdk-common/src/mint.rs
  74. 57 26
      crates/cdk-common/src/payment.rs
  75. 4 0
      crates/cdk-common/src/pub_sub/index.rs
  76. 1 1
      crates/cdk-common/src/pub_sub/mod.rs
  77. 13 1
      crates/cdk-common/src/subscription.rs
  78. 294 0
      crates/cdk-common/src/wallet.rs
  79. 23 0
      crates/cdk-common/src/ws.rs
  80. 17 16
      crates/cdk-fake-wallet/Cargo.toml
  81. 35 0
      crates/cdk-fake-wallet/README.md
  82. 1 1
      crates/cdk-fake-wallet/src/error.rs
  83. 62 44
      crates/cdk-fake-wallet/src/lib.rs
  84. 39 45
      crates/cdk-integration-tests/Cargo.toml
  85. 102 0
      crates/cdk-integration-tests/src/init_auth_mint.rs
  86. 143 40
      crates/cdk-integration-tests/src/init_pure_tests.rs
  87. 4 4
      crates/cdk-integration-tests/src/init_regtest.rs
  88. 113 20
      crates/cdk-integration-tests/src/lib.rs
  89. 863 0
      crates/cdk-integration-tests/tests/fake_auth.rs
  90. 88 124
      crates/cdk-integration-tests/tests/fake_wallet.rs
  91. 431 0
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  92. 906 24
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  93. 16 435
      crates/cdk-integration-tests/tests/mint.rs
  94. 252 0
      crates/cdk-integration-tests/tests/nutshell_wallet.rs
  95. 97 364
      crates/cdk-integration-tests/tests/regtest.rs
  96. 125 0
      crates/cdk-integration-tests/tests/test_fees.rs
  97. 17 15
      crates/cdk-lnbits/Cargo.toml
  98. 30 0
      crates/cdk-lnbits/README.md
  99. 1 1
      crates/cdk-lnbits/src/error.rs
  100. 59 41
      crates/cdk-lnbits/src/lib.rs

+ 158 - 36
.github/workflows/ci.yml

@@ -5,6 +5,8 @@ on:
     branches: [main]
   pull_request:
     branches: [main]
+  release:
+    types: [created]
 
 env:
   CARGO_TERM_COLOR: always
@@ -48,11 +50,13 @@ jobs:
     name: "Run examples"
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: [pre-commit-checks, clippy]
     strategy:
       matrix:
         build-args:
           [
             mint-token,
+            melt-token,
             p2pk,
             proof-selection,
             wallet
@@ -73,6 +77,7 @@ jobs:
     name: "Stable build, clippy and test"
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: pre-commit-checks
     strategy:
       matrix:
         build-args:
@@ -82,35 +87,63 @@ jobs:
             -p cashu --no-default-features --features wallet,
             -p cashu --no-default-features --features mint,
             -p cashu --no-default-features --features "mint swagger",
+            -p cashu --no-default-features --features auth,
+            -p cashu --no-default-features --features "mint auth",
+            -p cashu --no-default-features --features "wallet auth",
             -p cdk-common,
             -p cdk-common --no-default-features,
             -p cdk-common --no-default-features --features wallet,
             -p cdk-common --no-default-features --features mint,
             -p cdk-common --no-default-features --features "mint swagger",
+            -p cdk-common --no-default-features --features "auth",
+            -p cdk-common --no-default-features --features "mint auth",
+            -p cdk-common --no-default-features --features "wallet auth",
             -p cdk,
             -p cdk --no-default-features,
             -p cdk --no-default-features --features wallet,
             -p cdk --no-default-features --features mint,
             -p cdk --no-default-features --features "mint swagger",
+            -p cdk --no-default-features --features auth,
+            -p cdk --features auth,
+            -p cdk --no-default-features --features "auth mint",
+            -p cdk --no-default-features --features "auth wallet",
             -p cdk-redb,
             -p cdk-sqlite,
+            -p cdk-sqlite --features sqlcipher,
             -p cdk-axum --no-default-features,
             -p cdk-axum --no-default-features --features swagger,
             -p cdk-axum --no-default-features --features redis,
             -p cdk-axum --no-default-features --features "redis swagger",
+            -p cdk-axum --no-default-features --features "auth redis",
             -p cdk-axum,
             -p cdk-cln,
             -p cdk-lnd,
-            -p cdk-phoenixd,
-            -p cdk-strike,
             -p cdk-lnbits,
             -p cdk-fake-wallet,
+            -p cdk-payment-processor,
             --bin cdk-cli,
+            --bin cdk-cli --features sqlcipher,
+            --bin cdk-cli --features redb,
+            --bin cdk-cli --features "sqlcipher redb",
             --bin cdk-mintd,
-            --bin cdk-mintd --no-default-features --features swagger,
-            --bin cdk-mintd --no-default-features --features redis,
-            --bin cdk-mintd --no-default-features --features "redis swagger",
-            --bin cdk-mintd --no-default-features --features management-rpc,
+            --bin cdk-mintd --features redis,
+            --bin cdk-mintd --features redb,
+            --bin cdk-mintd --features "redis swagger redb",
+            --bin cdk-mintd --features sqlcipher,
+            --bin cdk-mintd --no-default-features --features lnd,
+            --bin cdk-mintd --no-default-features --features cln,
+            --bin cdk-mintd --no-default-features --features lnbits,
+            --bin cdk-mintd --no-default-features --features fakewallet,
+            --bin cdk-mintd --no-default-features --features grpc-processor,
+            --bin cdk-mintd --no-default-features --features "management-rpc lnd",
+            --bin cdk-mintd --no-default-features --features "management-rpc cln",
+            --bin cdk-mintd --no-default-features --features "management-rpc lnbits",
+            --bin cdk-mintd --no-default-features --features "management-rpc grpc-processor",
+            --bin cdk-mintd --no-default-features --features "swagger lnd",
+            --bin cdk-mintd --no-default-features --features "swagger cln",
+            --bin cdk-mintd --no-default-features --features "swagger lnbits",
+            --bin cdk-mintd --no-default-features --features "auth lnd",
+            --bin cdk-mintd --no-default-features --features "auth cln",
             --bin cdk-mint-cli,
           ]
     steps:
@@ -122,17 +155,16 @@ jobs:
         uses: DeterminateSystems/magic-nix-cache-action@v6
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
-      - name: Build
-        run: nix develop -i -L .#stable --command cargo build ${{ matrix.build-args }}
       - name: Clippy
         run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings
       - name: Test
         run: nix develop -i -L .#stable --command cargo test ${{ matrix.build-args }}
 
-  itest:
+  regtest-itest:
     name: "Integration regtest tests"
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest]
     strategy:
       matrix:
         build-args:
@@ -153,15 +185,14 @@ jobs:
         uses: DeterminateSystems/magic-nix-cache-action@v6
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
-      - name: Clippy
-        run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings
       - name: Test
         run: nix develop -i -L .#stable --command just itest ${{ matrix.database }}
           
-  fake-wallet-itest:
-    name: "Integration fake wallet tests"
+  fake-mint-itest:
+    name: "Integration fake mint tests"
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: [pre-commit-checks, clippy]
     strategy:
       matrix:
         build-args:
@@ -183,14 +214,23 @@ jobs:
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Clippy
-        run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings
-      - name: Test fake mint
+        run: nix develop -i -L .#stable --command cargo clippy -- -D warnings
+      - name: Test fake auth mint
         run: nix develop -i -L .#stable --command just fake-mint-itest ${{ matrix.database }}
                 
   pure-itest:
     name: "Integration fake wallet tests"
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: [pre-commit-checks, clippy]
+    strategy:
+      matrix:
+        database: 
+          [
+          memory,
+          sqlite,
+          redb
+          ]
     steps:
       - name: checkout
         uses: actions/checkout@v4
@@ -201,27 +241,23 @@ jobs:
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Test fake mint
+        run: nix develop -i -L .#stable --command just test-pure ${{ matrix.database }}
+      - name: Test mint
         run: nix develop -i -L .#stable --command just test
 
-  msrv-build:
-    name: "MSRV build"
+
+  payment-processor-itests:
+    name: "Payment processor tests"
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest, regtest-itest]
     strategy:
       matrix:
-        build-args:
+        ln: 
           [
-            -p cashu --no-default-features --features "wallet mint",
-            -p cdk-common --no-default-features --features "wallet mint",
-            -p cdk --no-default-features --features "mint mint",
-            -p cdk-axum,
-            -p cdk-axum --no-default-features --features redis,
-            -p cdk-strike,
-            -p cdk-lnbits,
-            -p cdk-phoenixd,
-            -p cdk-fake-wallet,
-            -p cdk-cln,
-            -p cdk-mint-rpc,
+          FAKEWALLET,
+          CLN,
+          LND
           ]
     steps:
       - name: checkout
@@ -232,20 +268,34 @@ jobs:
         uses: DeterminateSystems/magic-nix-cache-action@v6
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
-      - name: Build
-        run: nix develop -i -L .#msrv --command cargo build ${{ matrix.build-args }}
+      - name: Test
+        run: nix develop -i -L .#stable --command just itest-payment-processor ${{matrix.ln}}
 
-  
-  db-msrv-build:
-    name: "DB MSRV build"
+  msrv-build:
+    name: "MSRV build"
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: [pre-commit-checks, clippy, pure-itest]
     strategy:
       matrix:
         build-args:
           [
+            -p cashu --no-default-features --features "wallet mint",
+            -p cdk-common --no-default-features --features "wallet mint",
+            -p cdk,
+            -p cdk --no-default-features --features "mint auth",
+            -p cdk --no-default-features --features "wallet auth",
+            -p cdk --no-default-features --features "http_subscription",
+            -p cdk-axum,
+            -p cdk-axum --no-default-features --features redis,
+            -p cdk-lnbits,
+            -p cdk-fake-wallet,
+            -p cdk-cln,
+            -p cdk-lnd,
+            -p cdk-mint-rpc,
             -p cdk-sqlite,
-            -p cdk-redb,
+            -p cdk-mintd,
+            -p cdk-payment-processor --no-default-features,
           ]
     steps:
       - name: checkout
@@ -257,12 +307,14 @@ jobs:
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
       - name: Build
-        run: nix develop -i -L .#db_shell --command cargo build ${{ matrix.build-args }}
+        run: nix develop -i -L .#msrv --command cargo build ${{ matrix.build-args }}
 
+  
   check-wasm:
     name: Check WASM
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: [pre-commit-checks, clippy, pure-itest]
     strategy:
       matrix:
         rust:
@@ -292,6 +344,7 @@ jobs:
     name: Check WASM
     runs-on: ubuntu-latest
     timeout-minutes: 15
+    needs: [pre-commit-checks, clippy, msrv-build]
     strategy:
       matrix:
         rust:
@@ -315,3 +368,72 @@ jobs:
         uses: Swatinem/rust-cache@v2
       - name: Build cdk wasm
         run: nix develop -i -L ".#${{ matrix.rust }}" --command cargo build ${{ matrix.build-args }} --target ${{ matrix.target }}
+
+  fake-mint-auth-itest:
+    name: "Integration fake mint auth tests"
+    runs-on: ubuntu-latest
+    timeout-minutes: 15
+    needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest]
+    strategy:
+      matrix:
+        database: 
+          [
+          REDB,
+          SQLITE,
+          ]
+    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
+      - name: Rust Cache
+        uses: Swatinem/rust-cache@v2
+      - name: Start Keycloak with Backup
+        run: |
+          docker compose -f misc/keycloak/docker-compose-recover.yml up -d
+          until docker logs $(docker ps -q --filter "ancestor=quay.io/keycloak/keycloak:25.0.6") | grep "Keycloak 25.0.6 on JVM (powered by Quarkus 3.8.5) started"; do sleep 1; done
+
+      - name: Verify Keycloak Import
+        run: |
+          docker logs $(docker ps -q --filter "ancestor=quay.io/keycloak/keycloak:25.0.6") | grep "Imported"
+      - name: Test fake auth mint
+        run: nix develop -i -L .#stable --command just fake-auth-mint-itest ${{ matrix.database }} http://127.0.0.1:8080/realms/cdk-test-realm/.well-known/openid-configuration
+      - name: Stop and clean up Docker Compose
+        run: |
+          docker compose -f misc/keycloak/docker-compose-recover.yml down
+          
+  doc-tests:
+    name: "Documentation Tests"
+    runs-on: ubuntu-latest
+    timeout-minutes: 15
+    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
+      - name: Rust Cache
+        uses: Swatinem/rust-cache@v2
+      - name: Run doc tests
+        run: nix develop -i -L .#stable --command cargo test --doc
+        
+  strict-docs:
+    name: "Strict Documentation Check"
+    runs-on: ubuntu-latest
+    timeout-minutes: 15
+    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
+      - name: Rust Cache
+        uses: Swatinem/rust-cache@v2
+      - name: Check docs with strict warnings
+        run: nix develop -i -L .#stable --command just docs-strict

+ 60 - 0
.github/workflows/docker-publish.yml

@@ -0,0 +1,60 @@
+name: Publish Docker Image
+
+on:
+  release:
+    types: [published]
+  workflow_dispatch:
+    inputs:
+      tag:
+        description: 'Tag to build and publish'
+        required: true
+        default: 'latest'
+
+env:
+  REGISTRY: docker.io
+  IMAGE_NAME: thesimplekid/cdk-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 || '' }}
+
+      - name: Build and push Docker image
+        uses: docker/build-push-action@v5
+        with:
+          context: .
+          push: true
+          platforms: linux/amd64
+          tags: ${{ steps.meta.outputs.tags }}
+          labels: ${{ steps.meta.outputs.labels }}
+          cache-from: type=gha
+          cache-to: type=gha,mode=max

+ 43 - 0
.github/workflows/nutshell_itest.yml

@@ -0,0 +1,43 @@
+name: Nutshell integration
+
+on: [push, pull_request]
+
+jobs:
+  nutshell-integration-tests:
+    name: Nutshell Mint Integration Tests
+    runs-on: ubuntu-latest
+    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
+      - name: Rust Cache
+        uses: Swatinem/rust-cache@v2
+      - name: Test Nutshell
+        run: nix develop -i -L .#integration --command just test-nutshell
+      - name: Show logs if tests fail
+        if: failure()
+        run: docker logs nutshell
+
+  nutshell-wallet-integration-tests:
+    name: Nutshell Wallet Integration Tests
+    runs-on: ubuntu-latest
+    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
+      - name: Rust Cache
+        uses: Swatinem/rust-cache@v2
+      - name: Test Nutshell Wallet
+        run: |
+          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

+ 2 - 0
.gitignore

@@ -10,3 +10,5 @@ config.toml
 result
 Cargo.lock
 .aider*
+**/postgres_data/
+**/.env

+ 4 - 1
.typos.toml

@@ -3,5 +3,8 @@ extend-ignore-re = [
     # Ignore cashu tokens
     "cashuA[A-Za-z0-9-_]+",
     "cashuB[A-Za-z0-9-_]+",
-    "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9"
+    "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9",
+    "autheticator",
+    "Gam",
+    "flate2"
 ]

+ 105 - 54
CHANGELOG.md

@@ -4,21 +4,82 @@
 <!-- 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]
+## [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]).
+- `create_time`, `paid_time` to mint and melt quotes [PR](https://github.com/cashubtc/cdk/pull/708) ([thesimplekid]).
+- cdk-mint-rpc: Added get mint and melt quotes ttl [PR](https://github.com/cashubtc/cdk/pull/716) ([thesimplekid]).
+
+### Changed
+- cashu: Move wallet mod to cdk-common ([thesimplekid]).
+- Export Mint DB Traits [PR](https://github.com/cashubtc/cdk/pull/710) ([davidcaseria]).
+- Move Mint and Melt quote to `cdk` commit from `cashu` [PR](https://github.com/cashubtc/cdk/pull/665) ([thesimplekid]).
+
+### Fixed
+- Creation of memory sqlite db [PR](https://github.com/cashubtc/cdk/pull/707) ([crodas]).
+- cdk-cli: Ensure auth wallet is created before attempting to mint pending [PR](https://github.com/cashubtc/cdk/pull/704) ([thesimplekid]).
+- cdk-mint-rpc: Adding mint urls was not updating correctly [PR](https://github.com/cashubtc/cdk/pull/716) ([thesimplekid]).
+- cdk-mint-rpc: Fixed setting long description [PR](https://github.com/cashubtc/cdk/pull/716) ([thesimplekid]).
+
+
+## [v0.8.1](https://github.com/cashubtc/cdk/releases/tag/v0.8.1)
 ### Fixed
+- cashu: Handle url with paths [PR](https://github.com/cashubtc/cdk/pull/678) ([benthecarman]).
+
+### Changed
+- cdk: Export `MintKeySetInfo` [PR](https://github.com/cashubtc/cdk/pull/673) ([davidcaseria]).
+
+## [v0.8.0](https://github.com/cashubtc/cdk/releases/tag/v0.8.0)
+### Fixed
+- cdk: Proof matches conditions was not matching payment conditions correctly ([thesimplekid]).
+- cdk: Updating mint_url would remove proofs when we want to keep them ([ok300]).
+- Wallet: Fix ability to receive cashu tokens that include DLEQ proofs ([ok300]).
+- cdk-sqlite: Wallet was not storing dleq proofs ([thesimplekid]).
+
 ### Changed
+- Updated MSRV to 1.75.0 ([thesimplekid]).
+- cdk-sqlite: Do not use `UPDATE OR REPLACE` ([crodas]).
+- cdk: Refactor keyset init ([lollerfirst]).
+- Feature-gated lightning backends (CLN, LND, LNbits, FakeWallet) for selective compilation ([thesimplekid]).
+- cdk-sqlite: Update sqlx to 0.7.4 ([benthecarman]).
+- Unifies and optimizes the proof selection algorithm to use Wallet::select_proofs ([davidcaseria]).
+- Wallet::send now requires a PreparedSend ([davidcaseria]).
+- WalletDatabase proof state update functions have been consolidated into update_proofs_state ([davidcaseria]).
+- Moved `MintQuote` and `MeltQuote` from `cashu` to `cdk-common` ([thesimplekid]).
+
 ### Added
+- Added redb feature to mintd in order to meet MSRV target ([thesimplekid]).
+- cdk-sqlite: In memory sqlite database ([crodas]).
+- Add `tos_url` to `MintInfo` ([nodlAndHodl]).
+- cdk: Add tos_url setter to `MintBuilder` ([thesimplekid]).
+- Added optional "request" and "unit" fields to MeltQuoteBolt11Response [NUT Change](https://github.com/cashubtc/nuts/pull/235) ([thesimplekid]).
+- Added optional "amount" and "unit" fields to MintQuoteBolt11Response [NUT Change](https://github.com/cashubtc/nuts/pull/235) ([thesimplekid]).
+- Compile-time error when no lightning backend features are enabled ([thesimplekid]).
+- Add support for sqlcipher ([benthecarman]).
+- Payment processor ([thesimplekid]).
+- Payment request builder ([thesimplekid]).
+- Sends should be initiated by calling Wallet::prepare_send ([davidcaseria]).
+- A SendOptions struct controls optional functionality for sends ([davidcaseria]).
+- Allow Amount splitting to target a fee rate amount ([davidcaseria]).
+- Utility functions for Proofs ([davidcaseria]).
+- Utility functions for SendKind ([davidcaseria]).
+- Completed checked arithmetic operations for Amount (i.e., checked_mul and checked_div) ([davidcaseria]).
+
 ### Removed
+- Remove support for Memory Database in cdk ([crodas]).
+- Remove `AmountStr` ([crodas]).
+- Remove `get_nostr_last_checked` from `WalletDatabase` ([thesimplekid]).
+- Remove `add_nostr_last_checked` from `WalletDatabase` ([thesimplekid]).
 
-## [cdk-mind:v0.7.4](https://github.com/cashubtc/cdk/releases/tag/cdk-mintd-v0.7.4)
+## [cdk-mintd:v0.7.4](https://github.com/cashubtc/cdk/releases/tag/cdk-mintd-v0.7.4)
 ### Changed
-- cdk-mintd: update to cdk v0.7.2 ([thesimplekid]).
+- cdk-mintd: Update to cdk v0.7.2 ([thesimplekid]).
 
 ## [cdk:v0.7.2](https://github.com/cashubtc/cdk/releases/tag/cdk-v0.7.2)
 ### Fixed
 - cdk: Ordering of swap verification checks ([thesimplekid]).
 
-## [cdk-mintd-v0.7.2]
+## [cdk-mintd-v0.7.2](https://github.com/cashubtc/cdk/releases/tag/cdk-mintd-v0.7.2)
 ### Fixed
 - cdk-mintd: Fixed mint and melt error on mint initialized with RPC interface disabled ([ok300]).
 
@@ -27,64 +88,63 @@
 ### Changed
 - cdk: Debug print of `Id` is hex ([thesimplekid]).
 - cdk: Debug print of mint secret is the hash ([thesimplekid]).
-- cdk: Use check_incoming payment on attempted mint or check mint qutoe ([thesimplekid]).
+- cdk: Use check_incoming payment on attempted mint or check mint quote ([thesimplekid]).
 - cdk-cln: Use `call_typed` for cln rpc calls ([daywalker90]).
 
 ### Added
 - cdk: Mint builder add ability to set custom derivation paths ([thesimplekid]).
 
 ### Fixed
-- cdk-cln: return error on stream error ([thesimplekid]).
+- cdk-cln: Return error on stream error ([thesimplekid]).
 
 
 ## [v0.7.0](https://github.com/cashubtc/cdk/releases/tag/v0.7.0)
 ### Changed
-* Moved db traits to `cdk-common` ([crodas]).
-* Moved other common types to `cdk-common` ([crodas]).
-* `Wallet::mint` returns the minted `Proofs` and not just the amount ([davidcaseria]).
+- Moved db traits to `cdk-common` ([crodas]).
+- Moved other common types to `cdk-common` ([crodas]).
+- `Wallet::mint` returns the minted `Proofs` and not just the amount ([davidcaseria]).
 
 ### Added
-* `Token::to_raw_bytes` serializes generic token to raw bytes ([lollerfirst]).
-* `Token::try_from` for `Vec<u8>` constructs a generic token from raw bytes ([lollerfirst]).
-* `TokenV4::to_raw_bytes()` serializes a TokenV4 to raw bytes following the spec ([lollerfirst]).
-* `Wallet::receive_raw` which receives raw binary tokens ([lollerfirst]).
-* cdk-mint-rpc: Mint management gRPC client and server ([thesimplekid]).
-* cdk-common: cdk specific types and traits ([crodas]).
-* cashu: Core types and functions defined in NUTs ([crodas]).
+- `Token::to_raw_bytes` serializes generic token to raw bytes ([lollerfirst]).
+- `Token::try_from` for `Vec<u8>` constructs a generic token from raw bytes ([lollerfirst]).
+- `TokenV4::to_raw_bytes()` serializes a TokenV4 to raw bytes following the spec ([lollerfirst]).
+- `Wallet::receive_raw` which receives raw binary tokens ([lollerfirst]).
+- cdk-mint-rpc: Mint management gRPC client and server ([thesimplekid]).
+- cdk-common: cdk specific types and traits ([crodas]).
+- cashu: Core types and functions defined in NUTs ([crodas]).
 
 ### Fixed
-* Multimint unit check when wallet receiving token ([thesimplekid]).
-* Mint start up with most recent keyset after a rotation ([thesimplekid]).
+- Multimint unit check when wallet receiving token ([thesimplekid]).
+- Mint start up with most recent keyset after a rotation ([thesimplekid]).
 
 
 ## [cdk-v0.6.1, cdk-mintd-v0.6.2](https://github.com/cashubtc/cdk/releases/tag/cdk-mintd-v0.6.1)
 ### Fixed
-cdk: Missing check on mint that outputs equals the quote amount ([thesimplekid]).
-cdk: Reset mint quote status if in state that cannot continue ([thesimplekid]).
+- cdk: Missing check on mint that outputs equals the quote amount ([thesimplekid]).
+- cdk: Reset mint quote status if in state that cannot continue ([thesimplekid]).
 
 ## [v0.6.1](https://github.com/cashubtc/cdk/releases/tag/cdk-v0.6.1)
 ### Added
-cdk-mintd: Get work-dir from env var ([thesimplekid])
+- cdk-mintd: Get work-dir from env var ([thesimplekid]).
 
 ## [v0.6.0](https://github.com/cashubtc/cdk/releases/tag/v0.6.0)
-
 ### Changed
-cdk: Enforce `quote_id` to uuid type in mint ([tdelabro]).
-cdk: Refactor wallet mint connector ([ok300]).
+- cdk: Enforce `quote_id` to uuid type in mint ([tdelabro]).
+- cdk: Refactor wallet mint connector ([ok300]).
 
 ### Added
-cdk: `NUT19` Settings in `NUT06` info ([thesimplekid]).
-cdk: `NUT17` Websocket support for wallet ([crodas]).
-cdk-axum: Redis cache backend ([crodas]).
-cdk-mints: Get mint settings from env vars ([thesimplekid]).
-cdk-axum: HTTP compression support ([ok300]).
+- cdk: `NUT19` Settings in `NUT06` info ([thesimplekid]).
+- cdk: `NUT17` Websocket support for wallet ([crodas]).
+- cdk-axum: Redis cache backend ([crodas]).
+- cdk-mints: Get mint settings from env vars ([thesimplekid]).
+- cdk-axum: HTTP compression support ([ok300]).
 
 ### Fixed
-cdk-sqlite: keyset counter was overwritten when keyset was fetched from mint ([thesimplekid]).
-cdk-cli: on `mint` use `unit` from cli args ([thesimplekid]).
-cdk-cli: on `restore` create `wallet` if it does not exist ([thesimplekid]).
-cdk: Signaling support for optional nuts ([thesimplekid]).
-cdk-phd: Check payment has valid uuis ([thesimplekid]).
+- cdk-sqlite: Keyset counter was overwritten when keyset was fetched from mint ([thesimplekid]).
+- cdk-cli: On `mint` use `unit` from cli args ([thesimplekid]).
+- cdk-cli: On `restore` create `wallet` if it does not exist ([thesimplekid]).
+- cdk: Signaling support for optional nuts ([thesimplekid]).
+- cdk-phd: Check payment has valid uuid ([thesimplekid]).
 
 ## [v0.5.0](https://github.com/cashubtc/cdk/releases/tag/v0.5.0)
 ### Changed
@@ -102,20 +162,20 @@ cdk-phd: Check payment has valid uuis ([thesimplekid]).
 - cdk: Use `MintUrl` directly in wallet client ([ok300]).
 - cdk-cli: Change cdk-cli pay command to melt ([mubarak23]).
 - cdk: Rename `Wallet::get_proofs` to `Wallet::get_unspent_proofs` ([ok300]).
-- cdk: `Id` to `u32` changed from `TryFrom` to `From` ([vnprc]). 
+- cdk: `Id` to `u32` changed from `TryFrom` to `From` ([vnprc]).
 
 
 ### Added
 - cdk: Added description to `MintQuoteBolt11Request` ([lollerfirst]).
 - cdk(wallet): Added description to `mint_quote` ([lollerfirst]).
 - cdk: Add `amount` and `fee_paid` to `Melted` ([davidcaseria]).
-- cdk: Add `from_proofs` on `Melted` ([davidcaseria]). 
+- cdk: Add `from_proofs` on `Melted` ([davidcaseria]).
 - cdk: Add unit on `PaymentResponse` ([thesimplekid]).
 - cdk: Add description for mint quote ([lollerfirst]).
 - cdk-axum: Add cache to some endpoints ([lollerfirst]).
 - cdk: Add Proofs trait ([ok300]).
 - cdk: Wallet verifies keyset id when first fetching keys ([thesimplekid]).
-- cdk-mind: Add swagger docs ([ok300]).
+- cdk-mintd: Add swagger docs ([ok300]).
 - cdk: NUT18 payment request support ([thesimplekid]).
 - cdk: Add `Wallet::get_proofs_with` ([ok300]).
 - cdk: Mint NUT-17 Websocket support ([crodas]).
@@ -133,8 +193,6 @@ cdk-phd: Check payment has valid uuis ([thesimplekid]).
 
 
 ## [v0.4.0](https://github.com/cashubtc/cdk/releases/tag/v0.4.0)
-### Summary
-
 ### Changed
 - cdk: Reduce MSRV to 1.63.0 ([thesimplekid]).
 - cdk-axum: Reduce MSRV to 1.63.0 ([thesimplekid]).
@@ -153,19 +211,15 @@ cdk-phd: Check payment has valid uuis ([thesimplekid]).
 ### Added
 - cdk: Multiple error types ([thesimplekid]).
 
-
 ### Fixed
-- cdk(mint): use checked addition on amount to ensure there is no overflow ([thesimplekid]).
+- cdk(mint): Use checked addition on amount to ensure there is no overflow ([thesimplekid]).
 
 ### Removed
 - cdk(wallet): Removed CDK wallet error ([thesimplekid]).
 - cdk(mint): Removed CDK mint error ([thesimplekid]).
 
 
-## [0.3.0](https://github.com/cashubtc/cdk/releases/tag/v0.3.0)
-
-### Summary
-
+## [v0.3.0](https://github.com/cashubtc/cdk/releases/tag/v0.3.0)
 ### Changed
 - cdk(wallet): `fn send` returns `Token` so the user can use the struct of convert it to a v3 or v4 string ([thesimplekid]).
 - cdk(wallet): Publicly export `MultiMintWallet` ([thesimplekid]).
@@ -176,7 +230,7 @@ cdk-phd: Check payment has valid uuis ([thesimplekid]).
 - cdk-cli: Receive will add wallet when receiving if mint is unknown ([thesimplekid]).
 - cdk(cdk-database/mint): Rename `get_blinded_signatures` to `get_blind_signatures` ([thesimplekid]).
 - cdk(cdk-database/mint): Rename `get_blinded_signatures_for_keyset` to `get_blind_signatures_for_keyset` ([thesimplekid]).
-- cdk(mint): typo rename `total_redeame` to `total_redeemed` ([vnprc])
+- cdk(mint): Typo rename `total_redeame` to `total_redeemed` ([vnprc]).
 - cdk(mint): Refactored `MintKeySet::generate_from_xpriv` and `MintKeySet::generate_from_seed` methods to accept max_order, currency_unit, and derivation_path parameters directly ([vnprc]).
 - cdk(wallet): Return WalletKey for UnknownWallet error ([davidcaseria]).
 - cdk(cdk-lightning): `CreateInvoiceResponse` added expiry time to better support backends where it cannot be set ([thesimplekid]).
@@ -195,7 +249,7 @@ cdk-phd: Check payment has valid uuis ([thesimplekid]).
 - cdk(mint): Add `total_issued` and `total_redeamed` ([thesimplekid]).
 - cdk(cdk-database/mint) Add `get_proofs_by_keyset_id` ([thesimplekid]).
 - cdk(wallet/mint): Add `mint_icon_url` ([cjbeery24]).
-- cdk: Add `MintUrl` that sanatizes mint url by removing trailing `/` ([cjbeery24]).
+- cdk: Add `MintUrl` that sanitizes mint url by removing trailing `/` ([cjbeery24]).
 - cdk(cdk-database/mint): Add `update_proofs` that both adds new `ProofInfo`s to the db and deletes ([davidcaseria]).
 - cdk(cdk-database/mint): Add `set_pending_proofs`, `reserve_proofs`, and `set_unspent_proofs` ([davidcaseria]).
 
@@ -214,7 +268,6 @@ cdk-phd: Check payment has valid uuis ([thesimplekid]).
 - cdk(cdk-database/mint): Remove `set_proof_state`, `remove_proofs` and `add_proofs` ([davidcaseria]).
 
 ## [v0.2.0](https://github.com/cashubtc/cdk/releases/tag/v0.2.0)
-
 ### Summary
 This release introduces TokenV4, which uses CBOR encoding as the default token format. It also includes fee support for both wallet and mint operations.
 
@@ -245,16 +298,12 @@ Additionally, this release introduces a Mint binary cdk-mintd that uses the cdk-
 - cdk: NUT06 deserialize `MintInfo` ([thesimplekid]).
 
 
-## [v0.1.1]
-
-### Summary
-
+## [v0.1.1](https://github.com/cashubtc/cdk/releases/tag/v0.1.1)
 ### Changed
 - cdk(wallet): `wallet::total_pending_balance` does not include reserved proofs ([thesimplekid]).
 
-
 ### Added
-- cdk(wallet): Added get reserved proofs [thesimplekid](https://github.com/thesimplekid).
+- cdk(wallet): Added get reserved proofs ([thesimplekid]).
 
 <!-- Contributors -->
 [thesimplekid]: https://github.com/thesimplekid
@@ -269,3 +318,5 @@ Additionally, this release introduces a Mint binary cdk-mintd that uses the cdk-
 [crodas]: https://github.com/crodas
 [tdelabro]: https://github.com/tdelabro
 [daywalker90]: https://github.com/daywalker90
+[nodlAndHodl]: https://github.com/nodlAndHodl
+[benthecarman]: https://github.com/benthecarman

+ 95 - 1
Cargo.toml

@@ -4,14 +4,108 @@ members = [
 ]
 resolver = "2"
 
+[workspace.lints.rust]
+unsafe_code = "forbid"
+unreachable_pub = "warn"
+missing_debug_implementations = "warn"
+large_enum_variant = "warn"
+
+[workspace.lints.clippy]
+pedantic = "warn"
+unwrap_used = "warn"
+clone_on_ref_ptr = "warn"
+missing_errors_doc = "warn"
+missing_panics_doc = "warn"
+missing_safety_doc = "warn"
+nursery = "warn"
+redundant_else = "warn"
+redundant_closure_for_method_calls = "warn"
+unneeded_field_pattern = "warn"
+use_debug = "warn"
+
+[workspace.lints.rustdoc]
+missing_docs = "warn"
+bare_urls = "warn"
+
 [workspace.package]
+edition = "2021"
+rust-version = "1.75.0"
 license = "MIT"
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
+version = "0.9.0"
+readme = "README.md"
+
+[workspace.dependencies]
+anyhow = "1"
+async-trait = "0.1"
+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" }
+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"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+thiserror = { version = "1" }
+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"] }
+tokio-tungstenite = { version = "0.26.0", default-features = false }
+tokio-stream = "0.1.15"
+tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] }
+tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
+url = "2.3"
+uuid = { version = "=1.12.1", features = ["v4", "serde"] }
+utoipa = { version = "5.3.1", features = [
+    "preserve_order",
+    "preserve_path_order",
+]}
+serde_with = "3"
+reqwest = { version = "0.12", default-features = false, features = [
+    "json",
+    "rustls-tls",
+    "rustls-tls-native-roots",
+    "socks",
+    "zstd",
+    "brotli",
+    "gzip",
+    "deflate",
+]}
+once_cell = "1.20.2"
+instant = { version = "0.1", default-features = false }
+rand = "0.8.5"
+regex = "1"
+home = "0.5.5"
+tonic = { version = "0.12.3", features = [
+    "channel",
+    "tls",
+    "tls-webpki-roots",
+] }
+prost = "0.13.1"
+tonic-build = "0.12"
+strum = "0.27.1"
+strum_macros = "0.27.1"
+
+
 
 [workspace.metadata]
 authors = ["CDK Developers"]
-edition = "2021"
 description = "Cashu Development Kit"
 readme = "README.md"
 repository = "https://github.com/cashubtc/cdk"

+ 1 - 1
DEVELOPMENT.md

@@ -83,7 +83,7 @@ just test
 
 ### Running Integration Tests
 ```bash
-just itest REDB/SQLITE/MEMEORY
+just itest REDB/SQLITE/MEMORY
 ```
 
 NOTE: if this command fails on macos change the nix channel to unstable (in the `flake.nix` file modify `nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";` to `nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";`)

+ 21 - 3
README.md

@@ -22,21 +22,35 @@ The project is split up into several crates in the `crates/` directory:
     * [**cdk-axum**](./crates/cdk-axum/): Axum webserver for mint.
     * [**cdk-cln**](./crates/cdk-cln/): CLN Lightning backend for mint.
     * [**cdk-lnd**](./crates/cdk-lnd/): Lnd Lightning backend for mint.
-    * [**cdk-strike**](./crates/cdk-strike/): Strike Lightning backend for mint.
     * [**cdk-lnbits**](./crates/cdk-lnbits/): [LNbits](https://lnbits.com/) Lightning backend for mint.
-    * [**cdk-phoenixd**](./crates/cdk-phoenixd/): Phoenixd Lightning backend for mint.
     * [**cdk-fake-wallet**](./crates/cdk-fake-wallet/): Fake Lightning backend for mint. To be used only for testing, quotes are automatically filled.
     * [**cdk-mint-rpc**](./crates/cdk-mint-rpc/): Mint management gRPC server and cli.
 * Binaries:
     * [**cdk-cli**](./crates/cdk-cli/): Cashu wallet CLI.
     * [**cdk-mintd**](./crates/cdk-mintd/): Cashu Mint Binary.
-    * [**cdk-mint-cli**](./crates/cdk-mint-rpc/): Cashu Mint managemtn gRPC client cli.
+    * [**cdk-mint-cli**](./crates/cdk-mint-rpc/): Cashu Mint management gRPC client cli.
 
 
 ## Development 
 
 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
@@ -69,6 +83,8 @@ For a guide to settings up a development environment see [DEVELOPMENT.md](./DEVE
 | [18][18] | Payment Requests  | :heavy_check_mark: |
 | [19][19] | Cached responses  | :heavy_check_mark: |
 | [20][20] | Signature on Mint Quote  | :heavy_check_mark: |
+| [21][21] | Clear Authentication | :heavy_check_mark: |
+| [22][22] | Blind Authentication  | :heavy_check_mark: |
 
 
 ## Bindings
@@ -109,3 +125,5 @@ 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

+ 26 - 26
crates/cashu/Cargo.toml

@@ -1,44 +1,44 @@
 [package]
 name = "cashu"
-version = "0.7.1"
-edition = "2021"
+version.workspace = true
+edition.workspace = true
 authors = ["CDK Developers"]
 description = "Cashu shared types and crypto utilities, used as the foundation for the CDK and their crates"
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-rust-version = "1.63.0"                                                                                      # MSRV
-license = "MIT"
+rust-version.workspace = true # MSRV
+license.workspace = true
+readme = "README.md"
 
 [features]
-default = ["mint", "wallet"]
+default = ["mint", "wallet", "auth"]
 swagger = ["dep:utoipa"]
 mint = ["dep:uuid"]
 wallet = []
+auth = ["dep:strum", "dep:strum_macros", "dep:regex"]
 bench = []
 
 [dependencies]
-uuid = { version = "=1.12.1", features = ["v4", "serde"], optional = true }
-bitcoin = { version = "0.32.2", features = [
-    "base64",
-    "serde",
-    "rand",
-    "rand-std",
-] }
-cbor-diag = "0.1.12"
-ciborium = { version = "0.2.2", default-features = false, features = ["std"] }
-once_cell = "1.20.2"
-serde = { version = "1", features = ["derive"] }
-lightning-invoice = { version = "0.32.0", features = ["serde", "std"] }
-thiserror = "2"
-tracing = "0.1"
-url = "2.3"
-utoipa = { version = "4", optional = true }
-serde_json = "1"
-serde_with = "3"
+uuid = { workspace = true, optional = true }
+bitcoin.workspace = true
+cbor-diag.workspace = true
+ciborium.workspace = true
+once_cell.workspace = true
+serde.workspace = true
+lightning-invoice.workspace = true
+thiserror.workspace = true
+tracing.workspace = true
+url.workspace = true
+utoipa = { workspace = true, optional = true }
+serde_json.workspace = true
+serde_with.workspace = true
+regex = { workspace = true, optional = true }
+strum = { workspace = true, optional = true }
+strum_macros = { workspace = true, optional = true }
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
-instant = { version = "0.1", features = ["wasm-bindgen", "inaccurate"] }
+instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] }
 
 [dev-dependencies]
-bip39 = "2.0"
-uuid = { version = "=1.12.1", features = ["v4", "serde"] }
+bip39.workspace = true
+uuid.workspace = true

+ 102 - 0
crates/cashu/README.md

@@ -0,0 +1,102 @@
+# Cashu
+
+[![crates.io](https://img.shields.io/crates/v/cashu.svg)](https://crates.io/crates/cashu)
+[![Documentation](https://docs.rs/cashu/badge.svg)](https://docs.rs/cashu)
+[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE)
+
+A Rust implementation of the [Cashu](https://github.com/cashubtc) protocol, providing the core functionality for Cashu e-cash operations.
+
+## Overview
+
+ This crate implements the core Cashu protocol as defined in the [Cashu NUTs (Notation, Usage, and Terminology)](https://github.com/cashubtc/nuts/).
+
+## Features
+
+- **Cryptographic Operations**: Secure blind signatures and verification
+- **Token Management**: Creation, validation, and manipulation of Cashu tokens
+- **NUTs Implementation**: Support for the core Cashu protocol specifications
+- **Type-safe API**: Strongly-typed interfaces for working with Cashu primitives
+
+## Usage
+
+Add this to your `Cargo.toml`:
+
+```toml
+[dependencies]
+cashu = "*"
+```
+
+### Basic Example
+
+```rust
+use cashu::amount::Amount;
+use cashu::nuts::nut00::Token;
+use std::str::FromStr;
+
+// Parse a Cashu token from a string
+let token_str = "cashuBo2FteCJodHRwczovL25vZmVlcy50ZXN0bnV0LmNhc2h1LnNwYWNlYXVjc2F0YXSBomFpSAC0zSfYhhpEYXCCpGFhAmFzeEAzYzNlOWRhMDU3ZjQzNmExOTc2MmRhOWYyYTBjMzc5YzE5N2RlNDMzZDY5MWU1NDI0ZmRjODcxNjZjMmNlMjZmYWNYIQKKtwESLR-yn5rqNAL3_8_H5BtpwjSPs7uOJ18kPn2mV2Fko2FlWCCsMAK1xoLlwVRxpv8hfsxKYXlXTOomiVt3JCbzNgQpUmFzWCD9MfRUr0asiF_jUJMSylphLvKUd2SLz9oSpcvuLCXPp2FyWCA_1toQ_l158xW0zorqTBXvh76o-_D3e-Ru1Ea-51UrFaRhYQhhc3hAMTM5YWRjZDJlY2Q5MWQyNjNjMDhhMzdhNjBmODZjNDVkYWE3NjNmNjM4NTY0NzEyMmFiZjhlMDM3OGQ0NjA5OGFjWCECHZh5Qx9o-8PaY6t0d5hRTbWeez1dh3md7ehfE25f2N5hZKNhZVgg5MLkVzIw2tDzdUpYwFe-MLhIPJ4hkCpPGL0X7RxpPIRhc1ggyEtcsq3FX8wZOGpwTXOP7BsqfdYdMhGG1X8jVjncDcVhclggyLVOc2xy4m1_YeYGef2HQ8WyJX7LjZq403CS9Dt_eME=";
+let token = Token::from_str(token_str).expect("Valid token");
+
+// Get the total amount
+let amount: Amount = token.value().expect("Value");
+println!("Token amount: {}", amount);
+```
+
+## Implemented NUTs
+
+### Mandatory
+
+| NUT #    | Description                       |
+|----------|-----------------------------------|
+| [00][00] | Cryptography and Models           |
+| [01][01] | Mint public keys                  |
+| [02][02] | Keysets and fees                  |
+| [03][03] | Swapping tokens                   |
+| [04][04] | Minting tokens                    |
+| [05][05] | Melting tokens                    |
+| [06][06] | Mint info                         |
+
+### Optional
+
+| # | Description | Status
+| --- | --- | --- |
+| [07][07] | Token state check | Implemented |
+| [08][08] | Overpaid Lightning fees | Implemented |
+| [09][09] | Signature restore | Implemented |
+| [10][10] | Spending conditions | Implemented |
+| [11][11] | Pay-To-Pubkey (P2PK) | Implemented |
+| [12][12] | DLEQ proofs | Implemented |
+| [13][13] | Deterministic secrets | Implemented |
+| [14][14] | Hashed Timelock Contracts (HTLCs) | Implemented |
+| [15][15] | Partial multi-path payments (MPP) | Implemented |
+| [16][16] | Animated QR codes | Not implemented |
+| [17][17] | WebSocket subscriptions  | Implemented |
+| [18][18] | Payment Requests  | Implemented |
+| [19][19] | Cached responses  | Implemented |
+| [20][20] | Signature on Mint Quote  | Implemented |
+
+## License
+
+This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/LICENSE).
+
+[00]: https://github.com/cashubtc/nuts/blob/main/00.md
+[01]: https://github.com/cashubtc/nuts/blob/main/01.md
+[02]: https://github.com/cashubtc/nuts/blob/main/02.md
+[03]: https://github.com/cashubtc/nuts/blob/main/03.md
+[04]: https://github.com/cashubtc/nuts/blob/main/04.md
+[05]: https://github.com/cashubtc/nuts/blob/main/05.md
+[06]: https://github.com/cashubtc/nuts/blob/main/06.md
+[07]: https://github.com/cashubtc/nuts/blob/main/07.md
+[08]: https://github.com/cashubtc/nuts/blob/main/08.md
+[09]: https://github.com/cashubtc/nuts/blob/main/09.md
+[10]: https://github.com/cashubtc/nuts/blob/main/10.md
+[11]: https://github.com/cashubtc/nuts/blob/main/11.md
+[12]: https://github.com/cashubtc/nuts/blob/main/12.md
+[13]: https://github.com/cashubtc/nuts/blob/main/13.md
+[14]: https://github.com/cashubtc/nuts/blob/main/14.md
+[15]: https://github.com/cashubtc/nuts/blob/main/15.md
+[16]: https://github.com/cashubtc/nuts/blob/main/16.md
+[17]: https://github.com/cashubtc/nuts/blob/main/17.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

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

@@ -49,6 +49,9 @@ impl Amount {
     /// Amount zero
     pub const ZERO: Amount = Amount(0);
 
+    /// Amount one
+    pub const ONE: Amount = Amount(1);
+
     /// Split into parts that are powers of two
     pub fn split(&self) -> Vec<Self> {
         let sats = self.0;
@@ -119,6 +122,27 @@ impl Amount {
         Ok(parts)
     }
 
+    /// Splits amount into powers of two while accounting for the swap fee
+    pub fn split_with_fee(&self, fee_ppk: u64) -> Result<Vec<Self>, Error> {
+        let without_fee_amounts = self.split();
+        let fee_ppk = fee_ppk * without_fee_amounts.len() as u64;
+        let fee = Amount::from(fee_ppk.div_ceil(1000));
+        let new_amount = self.checked_add(fee).ok_or(Error::AmountOverflow)?;
+
+        let split = new_amount.split();
+        let split_fee_ppk = split.len() as u64 * fee_ppk;
+        let split_fee = Amount::from(split_fee_ppk.div_ceil(1000));
+
+        if let Some(net_amount) = new_amount.checked_sub(split_fee) {
+            if net_amount >= *self {
+                return Ok(split);
+            }
+        }
+        self.checked_add(Amount::ONE)
+            .ok_or(Error::AmountOverflow)?
+            .split_with_fee(fee_ppk)
+    }
+
     /// Checked addition for Amount. Returns None if overflow occurs.
     pub fn checked_add(self, other: Amount) -> Option<Amount> {
         self.0.checked_add(other.0).map(Amount)
@@ -129,6 +153,16 @@ impl Amount {
         self.0.checked_sub(other.0).map(Amount)
     }
 
+    /// Checked multiplication for Amount. Returns None if overflow occurs.
+    pub fn checked_mul(self, other: Amount) -> Option<Amount> {
+        self.0.checked_mul(other.0).map(Amount)
+    }
+
+    /// Checked division for Amount. Returns None if overflow occurs.
+    pub fn checked_div(self, other: Amount) -> Option<Amount> {
+        self.0.checked_div(other.0).map(Amount)
+    }
+
     /// Try sum to check for overflow
     pub fn try_sum<I>(iter: I) -> Result<Self, Error>
     where
@@ -335,6 +369,27 @@ mod tests {
     }
 
     #[test]
+    fn test_split_with_fee() {
+        let amount = Amount(2);
+        let fee_ppk = 1;
+
+        let split = amount.split_with_fee(fee_ppk).unwrap();
+        assert_eq!(split, vec![Amount(2), Amount(1)]);
+
+        let amount = Amount(3);
+        let fee_ppk = 1;
+
+        let split = amount.split_with_fee(fee_ppk).unwrap();
+        assert_eq!(split, vec![Amount(4)]);
+
+        let amount = Amount(3);
+        let fee_ppk = 1000;
+
+        let split = amount.split_with_fee(fee_ppk).unwrap();
+        assert_eq!(split, vec![Amount(32)]);
+    }
+
+    #[test]
     fn test_split_values() {
         let amount = Amount(10);
 

+ 15 - 5
crates/cashu/src/lib.rs

@@ -1,17 +1,27 @@
-//! CDK common types and traits
+#![doc = include_str!("../README.md")]
+#![warn(missing_docs)]
+#![warn(rustdoc::bare_urls)]
+
 pub mod amount;
 pub mod dhke;
-#[cfg(feature = "mint")]
-pub mod mint;
 pub mod mint_url;
 pub mod nuts;
 pub mod secret;
 pub mod util;
-#[cfg(feature = "wallet")]
-pub mod wallet;
 
 pub use lightning_invoice::{self, Bolt11Invoice};
 
 pub use self::amount::Amount;
+pub use self::mint_url::MintUrl;
 pub use self::nuts::*;
 pub use self::util::SECP256K1;
+
+#[doc(hidden)]
+#[macro_export]
+macro_rules! ensure_cdk {
+    ($cond:expr, $err:expr) => {
+        if !$cond {
+            return Err($err);
+        }
+    };
+}

+ 41 - 5
crates/cashu/src/mint_url.rs

@@ -10,6 +10,8 @@ use serde::{Deserialize, Serialize};
 use thiserror::Error;
 use url::{ParseError, Url};
 
+use crate::ensure_cdk;
+
 /// Url Error
 #[derive(Debug, Error, PartialEq, Eq)]
 pub enum Error {
@@ -27,9 +29,8 @@ pub struct MintUrl(String);
 
 impl MintUrl {
     fn format_url(url: &str) -> Result<String, Error> {
-        if url.is_empty() {
-            return Err(Error::InvalidUrl);
-        }
+        ensure_cdk!(!url.is_empty(), Error::InvalidUrl);
+
         let url = url.trim_end_matches('/');
         // https://URL.com/path/TO/resource -> https://url.com/path/TO/resource
         let protocol = url
@@ -55,7 +56,7 @@ impl MintUrl {
             .join("/");
         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)
     }
@@ -120,7 +121,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!(
@@ -128,4 +129,39 @@ mod tests {
             cased_url_with_path_formatted.to_string()
         );
     }
+
+    #[test]
+    fn test_join_paths() {
+        let url_no_path = "http://url-to-check.com";
+
+        let url = MintUrl::from_str(url_no_path).unwrap();
+        assert_eq!(
+            format!("{url_no_path}/hello/world"),
+            url.join_paths(&["hello", "world"]).unwrap().to_string()
+        );
+
+        let url_no_path_with_slash = "http://url-to-check.com/";
+
+        let url = MintUrl::from_str(url_no_path_with_slash).unwrap();
+        assert_eq!(
+            format!("{url_no_path_with_slash}hello/world"),
+            url.join_paths(&["hello", "world"]).unwrap().to_string()
+        );
+
+        let url_with_path = "http://url-to-check.com/my/path";
+
+        let url = MintUrl::from_str(url_with_path).unwrap();
+        assert_eq!(
+            format!("{url_with_path}/hello/world"),
+            url.join_paths(&["hello", "world"]).unwrap().to_string()
+        );
+
+        let url_with_path_with_slash = "http://url-to-check.com/my/path/";
+
+        let url = MintUrl::from_str(url_with_path_with_slash).unwrap();
+        assert_eq!(
+            format!("{url_with_path_with_slash}hello/world"),
+            url.join_paths(&["hello", "world"]).unwrap().to_string()
+        );
+    }
 }

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

@@ -0,0 +1,8 @@
+pub mod nut21;
+pub mod nut22;
+
+pub use nut21::{Method, ProtectedEndpoint, RoutePath, Settings as ClearAuthSettings};
+pub use nut22::{
+    AuthProof, AuthRequired, AuthToken, BlindAuthToken, MintAuthRequest,
+    Settings as BlindAuthSettings,
+};

+ 410 - 0
crates/cashu/src/nuts/auth/nut21.rs

@@ -0,0 +1,410 @@
+//! 21 Clear Auth
+
+use std::collections::HashSet;
+use std::str::FromStr;
+
+use regex::Regex;
+use serde::{Deserialize, Serialize};
+use strum::IntoEnumIterator;
+use strum_macros::EnumIter;
+use thiserror::Error;
+
+/// NUT21 Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Invalid regex pattern
+    #[error("Invalid regex pattern: {0}")]
+    InvalidRegex(#[from] regex::Error),
+}
+
+/// Clear Auth Settings
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct Settings {
+    /// Openid discovery
+    pub openid_discovery: String,
+    /// Client ID
+    pub client_id: String,
+    /// Protected endpoints
+    pub protected_endpoints: Vec<ProtectedEndpoint>,
+}
+
+impl Settings {
+    /// Create new [`Settings`]
+    pub fn new(
+        openid_discovery: String,
+        client_id: String,
+        protected_endpoints: Vec<ProtectedEndpoint>,
+    ) -> Self {
+        Self {
+            openid_discovery,
+            client_id,
+            protected_endpoints,
+        }
+    }
+}
+
+// Custom deserializer for Settings to expand regex patterns in protected endpoints
+impl<'de> Deserialize<'de> for Settings {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        // Define a temporary struct to deserialize the raw data
+        #[derive(Deserialize)]
+        struct RawSettings {
+            openid_discovery: String,
+            client_id: String,
+            protected_endpoints: Vec<RawProtectedEndpoint>,
+        }
+
+        #[derive(Deserialize)]
+        struct RawProtectedEndpoint {
+            method: Method,
+            path: String,
+        }
+
+        // Deserialize into the temporary struct
+        let raw = RawSettings::deserialize(deserializer)?;
+
+        // Process protected endpoints, expanding regex patterns if present
+        let mut protected_endpoints = HashSet::new();
+
+        for raw_endpoint in raw.protected_endpoints {
+            let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
+                serde::de::Error::custom(format!(
+                    "Invalid regex pattern '{}': {}",
+                    raw_endpoint.path, e
+                ))
+            })?;
+
+            for path in expanded_paths {
+                protected_endpoints.insert(ProtectedEndpoint::new(raw_endpoint.method, path));
+            }
+        }
+
+        // Create the final Settings struct
+        Ok(Settings {
+            openid_discovery: raw.openid_discovery,
+            client_id: raw.client_id,
+            protected_endpoints: protected_endpoints.into_iter().collect(),
+        })
+    }
+}
+
+/// List of the methods and paths that are protected
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct ProtectedEndpoint {
+    /// HTTP Method
+    pub method: Method,
+    /// Route path
+    pub path: RoutePath,
+}
+
+impl ProtectedEndpoint {
+    /// Create [`ProtectedEndpoint`]
+    pub fn new(method: Method, path: RoutePath) -> Self {
+        Self { method, path }
+    }
+}
+
+/// HTTP method
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "UPPERCASE")]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub enum Method {
+    /// Get
+    Get,
+    /// POST
+    Post,
+}
+
+/// Route path
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+#[serde(rename_all = "snake_case")]
+pub enum RoutePath {
+    /// Bolt11 Mint Quote
+    #[serde(rename = "/v1/mint/quote/bolt11")]
+    MintQuoteBolt11,
+    /// Bolt11 Mint
+    #[serde(rename = "/v1/mint/bolt11")]
+    MintBolt11,
+    /// Bolt11 Melt Quote
+    #[serde(rename = "/v1/melt/quote/bolt11")]
+    MeltQuoteBolt11,
+    /// Bolt11 Melt
+    #[serde(rename = "/v1/melt/bolt11")]
+    MeltBolt11,
+    /// Swap
+    #[serde(rename = "/v1/swap")]
+    Swap,
+    /// Checkstate
+    #[serde(rename = "/v1/checkstate")]
+    Checkstate,
+    /// Restore
+    #[serde(rename = "/v1/restore")]
+    Restore,
+    /// Mint Blind Auth
+    #[serde(rename = "/v1/auth/blind/mint")]
+    MintBlindAuth,
+}
+
+/// Returns [`RoutePath`]s that match regex
+pub fn matching_route_paths(pattern: &str) -> Result<Vec<RoutePath>, Error> {
+    let regex = Regex::from_str(pattern)?;
+
+    Ok(RoutePath::iter()
+        .filter(|path| regex.is_match(&path.to_string()))
+        .collect())
+}
+
+impl std::fmt::Display for RoutePath {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        // Use serde to serialize to a JSON string, then extract the value without quotes
+        let json_str = match serde_json::to_string(self) {
+            Ok(s) => s,
+            Err(_) => return write!(f, "<error>"),
+        };
+        // Remove the quotes from the JSON string
+        let path = json_str.trim_matches('"');
+        write!(f, "{}", path)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+
+    use super::*;
+
+    #[test]
+    fn test_matching_route_paths_all() {
+        // Regex that matches all paths
+        let paths = matching_route_paths(".*").unwrap();
+
+        // Should match all variants
+        assert_eq!(paths.len(), RoutePath::iter().count());
+
+        // Verify all variants are included
+        assert!(paths.contains(&RoutePath::MintQuoteBolt11));
+        assert!(paths.contains(&RoutePath::MintBolt11));
+        assert!(paths.contains(&RoutePath::MeltQuoteBolt11));
+        assert!(paths.contains(&RoutePath::MeltBolt11));
+        assert!(paths.contains(&RoutePath::Swap));
+        assert!(paths.contains(&RoutePath::Checkstate));
+        assert!(paths.contains(&RoutePath::Restore));
+        assert!(paths.contains(&RoutePath::MintBlindAuth));
+    }
+
+    #[test]
+    fn test_matching_route_paths_mint_only() {
+        // Regex that matches only mint paths
+        let paths = matching_route_paths("^/v1/mint/.*").unwrap();
+
+        // Should match only mint paths
+        assert_eq!(paths.len(), 2);
+        assert!(paths.contains(&RoutePath::MintQuoteBolt11));
+        assert!(paths.contains(&RoutePath::MintBolt11));
+
+        // Should not match other paths
+        assert!(!paths.contains(&RoutePath::MeltQuoteBolt11));
+        assert!(!paths.contains(&RoutePath::MeltBolt11));
+        assert!(!paths.contains(&RoutePath::Swap));
+    }
+
+    #[test]
+    fn test_matching_route_paths_quote_only() {
+        // Regex that matches only quote paths
+        let paths = matching_route_paths(".*/quote/.*").unwrap();
+
+        // Should match only quote paths
+        assert_eq!(paths.len(), 2);
+        assert!(paths.contains(&RoutePath::MintQuoteBolt11));
+        assert!(paths.contains(&RoutePath::MeltQuoteBolt11));
+
+        // Should not match non-quote paths
+        assert!(!paths.contains(&RoutePath::MintBolt11));
+        assert!(!paths.contains(&RoutePath::MeltBolt11));
+    }
+
+    #[test]
+    fn test_matching_route_paths_no_match() {
+        // Regex that matches nothing
+        let paths = matching_route_paths("/nonexistent/path").unwrap();
+
+        // Should match nothing
+        assert!(paths.is_empty());
+    }
+
+    #[test]
+    fn test_matching_route_paths_quote_bolt11_only() {
+        // Regex that matches only quote paths
+        let paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap();
+
+        // Should match only quote paths
+        assert_eq!(paths.len(), 1);
+        assert!(paths.contains(&RoutePath::MintQuoteBolt11));
+    }
+
+    #[test]
+    fn test_matching_route_paths_invalid_regex() {
+        // Invalid regex pattern
+        let result = matching_route_paths("(unclosed parenthesis");
+
+        // Should return an error for invalid regex
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidRegex(_)));
+    }
+
+    #[test]
+    fn test_route_path_to_string() {
+        // Test that to_string() returns the correct path strings
+        assert_eq!(
+            RoutePath::MintQuoteBolt11.to_string(),
+            "/v1/mint/quote/bolt11"
+        );
+        assert_eq!(RoutePath::MintBolt11.to_string(), "/v1/mint/bolt11");
+        assert_eq!(
+            RoutePath::MeltQuoteBolt11.to_string(),
+            "/v1/melt/quote/bolt11"
+        );
+        assert_eq!(RoutePath::MeltBolt11.to_string(), "/v1/melt/bolt11");
+        assert_eq!(RoutePath::Swap.to_string(), "/v1/swap");
+        assert_eq!(RoutePath::Checkstate.to_string(), "/v1/checkstate");
+        assert_eq!(RoutePath::Restore.to_string(), "/v1/restore");
+        assert_eq!(RoutePath::MintBlindAuth.to_string(), "/v1/auth/blind/mint");
+    }
+
+    #[test]
+    fn test_settings_deserialize_direct_paths() {
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "/v1/mint/bolt11"
+                },
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+
+        assert_eq!(
+            settings.openid_discovery,
+            "https://example.com/.well-known/openid-configuration"
+        );
+        assert_eq!(settings.client_id, "client123");
+        assert_eq!(settings.protected_endpoints.len(), 2);
+
+        // Check that both paths are included
+        let paths = settings
+            .protected_endpoints
+            .iter()
+            .map(|ep| (ep.method, ep.path))
+            .collect::<Vec<_>>();
+        assert!(paths.contains(&(Method::Get, RoutePath::MintBolt11)));
+        assert!(paths.contains(&(Method::Post, RoutePath::Swap)));
+    }
+
+    #[test]
+    fn test_settings_deserialize_with_regex() {
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "^/v1/mint/.*"
+                },
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+
+        assert_eq!(
+            settings.openid_discovery,
+            "https://example.com/.well-known/openid-configuration"
+        );
+        assert_eq!(settings.client_id, "client123");
+        assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path
+
+        let expected_protected: HashSet<ProtectedEndpoint> = HashSet::from_iter(vec![
+            ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
+            ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11),
+            ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
+        ]);
+
+        let deserlized_protected = settings.protected_endpoints.into_iter().collect();
+
+        assert_eq!(expected_protected, deserlized_protected);
+    }
+
+    #[test]
+    fn test_settings_deserialize_invalid_regex() {
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "(unclosed parenthesis"
+                }
+            ]
+        }"#;
+
+        let result = serde_json::from_str::<Settings>(json);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn test_settings_deserialize_exact_path_match() {
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "/v1/mint/quote/bolt11"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert_eq!(settings.protected_endpoints.len(), 1);
+        assert_eq!(settings.protected_endpoints[0].method, Method::Get);
+        assert_eq!(
+            settings.protected_endpoints[0].path,
+            RoutePath::MintQuoteBolt11
+        );
+    }
+
+    #[test]
+    fn test_settings_deserialize_all_paths() {
+        let json = r#"{
+            "openid_discovery": "https://example.com/.well-known/openid-configuration",
+            "client_id": "client123",
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": ".*"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            settings.protected_endpoints.len(),
+            RoutePath::iter().count()
+        );
+    }
+}

+ 380 - 0
crates/cashu/src/nuts/auth/nut22.rs

@@ -0,0 +1,380 @@
+//! 22 Blind Auth
+
+use std::fmt;
+
+use bitcoin::base64::engine::general_purpose;
+use bitcoin::base64::Engine;
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use super::nut21::ProtectedEndpoint;
+use crate::dhke::hash_to_curve;
+use crate::secret::Secret;
+use crate::util::hex;
+use crate::{BlindedMessage, Id, Proof, ProofDleq, PublicKey};
+
+/// NUT22 Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Invalid Prefix
+    #[error("Invalid prefix")]
+    InvalidPrefix,
+    /// Dleq proof not included
+    #[error("Dleq Proof not included for auth proof")]
+    DleqProofNotIncluded,
+    /// Hex Error
+    #[error(transparent)]
+    HexError(#[from] hex::Error),
+    /// Base64 error
+    #[error(transparent)]
+    Base64Error(#[from] bitcoin::base64::DecodeError),
+    /// Serde Json error
+    #[error(transparent)]
+    SerdeJsonError(#[from] serde_json::Error),
+    /// Utf8 parse error
+    #[error(transparent)]
+    Utf8ParseError(#[from] std::string::FromUtf8Error),
+    /// DHKE error
+    #[error(transparent)]
+    DHKE(#[from] crate::dhke::Error),
+}
+
+/// Blind auth settings
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct Settings {
+    /// Max number of blind auth tokens that can be minted per request
+    pub bat_max_mint: u64,
+    /// Protected endpoints
+    pub protected_endpoints: Vec<ProtectedEndpoint>,
+}
+
+impl Settings {
+    /// Create new [`Settings`]
+    pub fn new(bat_max_mint: u64, protected_endpoints: Vec<ProtectedEndpoint>) -> Self {
+        Self {
+            bat_max_mint,
+            protected_endpoints,
+        }
+    }
+}
+
+// Custom deserializer for Settings to expand regex patterns in protected endpoints
+impl<'de> Deserialize<'de> for Settings {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        use std::collections::HashSet;
+
+        use super::nut21::matching_route_paths;
+
+        // Define a temporary struct to deserialize the raw data
+        #[derive(Deserialize)]
+        struct RawSettings {
+            bat_max_mint: u64,
+            protected_endpoints: Vec<RawProtectedEndpoint>,
+        }
+
+        #[derive(Deserialize)]
+        struct RawProtectedEndpoint {
+            method: super::nut21::Method,
+            path: String,
+        }
+
+        // Deserialize into the temporary struct
+        let raw = RawSettings::deserialize(deserializer)?;
+
+        // Process protected endpoints, expanding regex patterns if present
+        let mut protected_endpoints = HashSet::new();
+
+        for raw_endpoint in raw.protected_endpoints {
+            let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| {
+                serde::de::Error::custom(format!(
+                    "Invalid regex pattern '{}': {}",
+                    raw_endpoint.path, e
+                ))
+            })?;
+
+            for path in expanded_paths {
+                protected_endpoints.insert(super::nut21::ProtectedEndpoint::new(
+                    raw_endpoint.method,
+                    path,
+                ));
+            }
+        }
+
+        // Create the final Settings struct
+        Ok(Settings {
+            bat_max_mint: raw.bat_max_mint,
+            protected_endpoints: protected_endpoints.into_iter().collect(),
+        })
+    }
+}
+
+/// Auth Token
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum AuthToken {
+    /// Clear Auth token
+    ClearAuth(String),
+    /// Blind Auth token
+    BlindAuth(BlindAuthToken),
+}
+
+impl fmt::Display for AuthToken {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::ClearAuth(cat) => cat.fmt(f),
+            Self::BlindAuth(bat) => bat.fmt(f),
+        }
+    }
+}
+
+impl AuthToken {
+    /// Header key for auth token type
+    pub fn header_key(&self) -> String {
+        match self {
+            Self::ClearAuth(_) => "Clear-auth".to_string(),
+            Self::BlindAuth(_) => "Blind-auth".to_string(),
+        }
+    }
+}
+
+/// Required Auth
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub enum AuthRequired {
+    /// Clear Auth token
+    Clear,
+    /// Blind Auth token
+    Blind,
+}
+
+/// Auth Proofs
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct AuthProof {
+    /// `Keyset id`
+    #[serde(rename = "id")]
+    pub keyset_id: Id,
+    /// Secret message
+    #[cfg_attr(feature = "swagger", schema(value_type = String))]
+    pub secret: Secret,
+    /// Unblinded signature
+    #[serde(rename = "C")]
+    #[cfg_attr(feature = "swagger", schema(value_type = String))]
+    pub c: PublicKey,
+    /// Auth Proof Dleq
+    pub dleq: Option<ProofDleq>,
+}
+
+impl AuthProof {
+    /// Y of AuthProof
+    pub fn y(&self) -> Result<PublicKey, Error> {
+        Ok(hash_to_curve(self.secret.as_bytes())?)
+    }
+}
+
+impl From<AuthProof> for Proof {
+    fn from(value: AuthProof) -> Self {
+        Self {
+            amount: 1.into(),
+            keyset_id: value.keyset_id,
+            secret: value.secret,
+            c: value.c,
+            witness: None,
+            dleq: value.dleq,
+        }
+    }
+}
+
+impl TryFrom<Proof> for AuthProof {
+    type Error = Error;
+    fn try_from(value: Proof) -> Result<Self, Self::Error> {
+        Ok(Self {
+            keyset_id: value.keyset_id,
+            secret: value.secret,
+            c: value.c,
+            dleq: value.dleq,
+        })
+    }
+}
+
+/// Blind Auth Token
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct BlindAuthToken {
+    /// [AuthProof]
+    pub auth_proof: AuthProof,
+}
+
+impl BlindAuthToken {
+    /// Create new [ `BlindAuthToken`]
+    pub fn new(auth_proof: AuthProof) -> Self {
+        BlindAuthToken { auth_proof }
+    }
+
+    /// Remove DLEQ
+    ///
+    /// We do not send the DLEQ to the mint as it links redemption and creation
+    pub fn without_dleq(&self) -> Self {
+        Self {
+            auth_proof: AuthProof {
+                keyset_id: self.auth_proof.keyset_id,
+                secret: self.auth_proof.secret.clone(),
+                c: self.auth_proof.c,
+                dleq: None,
+            },
+        }
+    }
+}
+
+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)
+    }
+}
+
+impl std::str::FromStr for BlindAuthToken {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        // Check prefix and extract the base64 encoded part in one step
+        let encoded = s.strip_prefix("authA").ok_or(Error::InvalidPrefix)?;
+
+        // Decode the base64 URL-safe string
+        let json_string = general_purpose::URL_SAFE.decode(encoded)?;
+
+        // Convert bytes to UTF-8 string
+        let json_str = String::from_utf8(json_string)?;
+
+        // Deserialize the JSON string into AuthProof
+        let auth_proof: AuthProof = serde_json::from_str(&json_str)?;
+
+        Ok(BlindAuthToken { auth_proof })
+    }
+}
+
+/// Mint auth request [NUT-XX]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
+pub struct MintAuthRequest {
+    /// Outputs
+    #[cfg_attr(feature = "swagger", schema(max_items = 1_000))]
+    pub outputs: Vec<BlindedMessage>,
+}
+
+impl MintAuthRequest {
+    /// Count of tokens
+    pub fn amount(&self) -> u64 {
+        self.outputs.len() as u64
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use std::collections::HashSet;
+
+    use strum::IntoEnumIterator;
+
+    use super::super::nut21::{Method, RoutePath};
+    use super::*;
+
+    #[test]
+    fn test_settings_deserialize_direct_paths() {
+        let json = r#"{
+            "bat_max_mint": 10,
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "/v1/mint/bolt11"
+                },
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+
+        assert_eq!(settings.bat_max_mint, 10);
+        assert_eq!(settings.protected_endpoints.len(), 2);
+
+        // Check that both paths are included
+        let paths = settings
+            .protected_endpoints
+            .iter()
+            .map(|ep| (ep.method, ep.path))
+            .collect::<Vec<_>>();
+        assert!(paths.contains(&(Method::Get, RoutePath::MintBolt11)));
+        assert!(paths.contains(&(Method::Post, RoutePath::Swap)));
+    }
+
+    #[test]
+    fn test_settings_deserialize_with_regex() {
+        let json = r#"{
+            "bat_max_mint": 5,
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "^/v1/mint/.*"
+                },
+                {
+                    "method": "POST",
+                    "path": "/v1/swap"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+
+        assert_eq!(settings.bat_max_mint, 5);
+        assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path
+
+        let expected_protected: HashSet<ProtectedEndpoint> = HashSet::from_iter(vec![
+            ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
+            ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11),
+            ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
+        ]);
+
+        let deserialized_protected = settings.protected_endpoints.into_iter().collect();
+
+        assert_eq!(expected_protected, deserialized_protected);
+    }
+
+    #[test]
+    fn test_settings_deserialize_invalid_regex() {
+        let json = r#"{
+            "bat_max_mint": 5,
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": "(unclosed parenthesis"
+                }
+            ]
+        }"#;
+
+        let result = serde_json::from_str::<Settings>(json);
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn test_settings_deserialize_all_paths() {
+        let json = r#"{
+            "bat_max_mint": 5,
+            "protected_endpoints": [
+                {
+                    "method": "GET",
+                    "path": ".*"
+                }
+            ]
+        }"#;
+
+        let settings: Settings = serde_json::from_str(json).unwrap();
+        assert_eq!(
+            settings.protected_endpoints.len(),
+            RoutePath::iter().count()
+        );
+    }
+}

+ 12 - 1
crates/cashu/src/nuts/mod.rs

@@ -24,6 +24,14 @@ pub mod nut18;
 pub mod nut19;
 pub mod nut20;
 
+#[cfg(feature = "auth")]
+mod auth;
+
+#[cfg(feature = "auth")]
+pub use auth::{
+    nut21, nut22, AuthProof, AuthRequired, AuthToken, BlindAuthSettings, BlindAuthToken,
+    ClearAuthSettings, Method, MintAuthRequest, ProtectedEndpoint, RoutePath,
+};
 pub use nut00::{
     BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proof, Proofs, ProofsMethods,
     Token, TokenV3, TokenV4, Witness,
@@ -54,4 +62,7 @@ pub use nut12::{BlindSignatureDleq, ProofDleq};
 pub use nut14::HTLCWitness;
 pub use nut15::{Mpp, MppMethodSettings, Settings as NUT15Settings};
 pub use nut17::NotificationPayload;
-pub use nut18::{PaymentRequest, PaymentRequestPayload, Transport};
+pub use nut18::{
+    PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload, Transport, TransportBuilder,
+    TransportType,
+};

+ 100 - 6
crates/cashu/src/nuts/nut00/mod.rs

@@ -3,6 +3,7 @@
 //! <https://github.com/cashubtc/nuts/blob/main/00.md>
 
 use std::cmp::Ordering;
+use std::collections::{HashMap, HashSet};
 use std::fmt;
 use std::hash::{Hash, Hasher};
 use std::str::FromStr;
@@ -38,25 +39,102 @@ pub type Proofs = Vec<Proof>;
 
 /// Utility methods for [Proofs]
 pub trait ProofsMethods {
+    /// Count proofs by keyset
+    fn count_by_keyset(&self) -> HashMap<Id, u64>;
+
+    /// Sum proofs by keyset
+    fn sum_by_keyset(&self) -> HashMap<Id, Amount>;
+
     /// Try to sum up the amounts of all [Proof]s
     fn total_amount(&self) -> Result<Amount, Error>;
 
     /// Try to fetch the pubkeys of all [Proof]s
     fn ys(&self) -> Result<Vec<PublicKey>, Error>;
+
+    /// Create a copy of proofs without dleqs
+    fn without_dleqs(&self) -> Proofs;
 }
 
 impl ProofsMethods for Proofs {
+    fn count_by_keyset(&self) -> HashMap<Id, u64> {
+        count_by_keyset(self.iter())
+    }
+
+    fn sum_by_keyset(&self) -> HashMap<Id, Amount> {
+        sum_by_keyset(self.iter())
+    }
+
     fn total_amount(&self) -> Result<Amount, Error> {
-        Amount::try_sum(self.iter().map(|p| p.amount)).map_err(Into::into)
+        total_amount(self.iter())
     }
 
     fn ys(&self) -> Result<Vec<PublicKey>, Error> {
+        ys(self.iter())
+    }
+
+    fn without_dleqs(&self) -> Proofs {
         self.iter()
-            .map(|p| p.y())
-            .collect::<Result<Vec<PublicKey>, _>>()
+            .map(|p| {
+                let mut p = p.clone();
+                p.dleq = None;
+                p
+            })
+            .collect()
     }
 }
 
+impl ProofsMethods for HashSet<Proof> {
+    fn count_by_keyset(&self) -> HashMap<Id, u64> {
+        count_by_keyset(self.iter())
+    }
+
+    fn sum_by_keyset(&self) -> HashMap<Id, Amount> {
+        sum_by_keyset(self.iter())
+    }
+
+    fn total_amount(&self) -> Result<Amount, Error> {
+        total_amount(self.iter())
+    }
+
+    fn ys(&self) -> Result<Vec<PublicKey>, Error> {
+        ys(self.iter())
+    }
+
+    fn without_dleqs(&self) -> Proofs {
+        self.iter()
+            .map(|p| {
+                let mut p = p.clone();
+                p.dleq = None;
+                p
+            })
+            .collect()
+    }
+}
+
+fn count_by_keyset<'a, I: Iterator<Item = &'a Proof>>(proofs: I) -> HashMap<Id, u64> {
+    let mut counts = HashMap::new();
+    for proof in proofs {
+        *counts.entry(proof.keyset_id).or_insert(0) += 1;
+    }
+    counts
+}
+
+fn sum_by_keyset<'a, I: Iterator<Item = &'a Proof>>(proofs: I) -> HashMap<Id, Amount> {
+    let mut sums = HashMap::new();
+    for proof in proofs {
+        *sums.entry(proof.keyset_id).or_insert(Amount::ZERO) += proof.amount;
+    }
+    sums
+}
+
+fn total_amount<'a, I: Iterator<Item = &'a Proof>>(proofs: I) -> Result<Amount, Error> {
+    Amount::try_sum(proofs.map(|p| p.amount)).map_err(Into::into)
+}
+
+fn ys<'a, I: Iterator<Item = &'a Proof>>(proofs: I) -> Result<Vec<PublicKey>, Error> {
+    proofs.map(|p| p.y()).collect::<Result<Vec<PublicKey>, _>>()
+}
+
 /// NUT00 Error
 #[derive(Debug, Error)]
 pub enum Error {
@@ -72,6 +150,9 @@ pub enum Error {
     /// Unsupported token
     #[error("Unsupported payment method")]
     UnsupportedPaymentMethod,
+    /// Duplicate proofs in token
+    #[error("Duplicate proofs in token")]
+    DuplicateProofs,
     /// Serde Json error
     #[error(transparent)]
     SerdeJsonError(#[from] serde_json::Error),
@@ -272,6 +353,11 @@ impl Proof {
         }
     }
 
+    /// Check if proof is in active keyset `Id`s
+    pub fn is_active(&self, active_keyset_ids: &[Id]) -> bool {
+        active_keyset_ids.contains(&self.keyset_id)
+    }
+
     /// Get y from proof
     ///
     /// Where y is `hash_to_curve(secret)`
@@ -385,6 +471,8 @@ pub enum CurrencyUnit {
     Usd,
     /// Euro
     Eur,
+    /// Auth
+    Auth,
     /// Custom currency unit
     Custom(String),
 }
@@ -398,6 +486,7 @@ impl CurrencyUnit {
             Self::Msat => Some(1),
             Self::Usd => Some(2),
             Self::Eur => Some(3),
+            Self::Auth => Some(4),
             _ => None,
         }
     }
@@ -412,6 +501,7 @@ impl FromStr for CurrencyUnit {
             "MSAT" => Ok(Self::Msat),
             "USD" => Ok(Self::Usd),
             "EUR" => Ok(Self::Eur),
+            "AUTH" => Ok(Self::Auth),
             c => Ok(Self::Custom(c.to_string())),
         }
     }
@@ -424,6 +514,7 @@ impl fmt::Display for CurrencyUnit {
             CurrencyUnit::Msat => "MSAT",
             CurrencyUnit::Usd => "USD",
             CurrencyUnit::Eur => "EUR",
+            CurrencyUnit::Auth => "AUTH",
             CurrencyUnit::Custom(unit) => unit,
         };
         if let Some(width) = f.width() {
@@ -455,20 +546,22 @@ impl<'de> Deserialize<'de> for CurrencyUnit {
 
 /// Payment Method
 #[non_exhaustive]
-#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub enum PaymentMethod {
     /// Bolt11 payment type
     #[default]
     Bolt11,
+    /// Custom
+    Custom(String),
 }
 
 impl FromStr for PaymentMethod {
     type Err = Error;
     fn from_str(value: &str) -> Result<Self, Self::Err> {
-        match value {
+        match value.to_lowercase().as_str() {
             "bolt11" => Ok(Self::Bolt11),
-            _ => Err(Error::UnsupportedPaymentMethod),
+            c => Ok(Self::Custom(c.to_string())),
         }
     }
 }
@@ -477,6 +570,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),
         }
     }
 }

+ 90 - 38
crates/cashu/src/nuts/nut00/token.rs

@@ -14,7 +14,7 @@ use super::{Error, Proof, ProofV4, Proofs};
 use crate::mint_url::MintUrl;
 use crate::nuts::nut00::ProofsMethods;
 use crate::nuts::{CurrencyUnit, Id};
-use crate::Amount;
+use crate::{ensure_cdk, Amount};
 
 /// Token Enum
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -103,11 +103,9 @@ impl Token {
             Self::TokenV3(token) => {
                 let mint_urls = token.mint_urls();
 
-                if mint_urls.len() != 1 {
-                    return Err(Error::UnsupportedToken);
-                }
+                ensure_cdk!(mint_urls.len() == 1, Error::UnsupportedToken);
 
-                Ok(mint_urls.first().expect("Length is checked above").clone())
+                mint_urls.first().ok_or(Error::UnsupportedToken).cloned()
             }
             Self::TokenV4(token) => Ok(token.mint_url.clone()),
         }
@@ -164,9 +162,7 @@ impl TryFrom<&Vec<u8>> for Token {
     type Error = Error;
 
     fn try_from(bytes: &Vec<u8>) -> Result<Self, Self::Error> {
-        if bytes.len() < 5 {
-            return Err(Error::UnsupportedToken);
-        }
+        ensure_cdk!(bytes.len() >= 5, Error::UnsupportedToken);
 
         let prefix = String::from_utf8(bytes[..5].to_vec())?;
 
@@ -220,9 +216,7 @@ impl TokenV3 {
         memo: Option<String>,
         unit: Option<CurrencyUnit>,
     ) -> Result<Self, Error> {
-        if proofs.is_empty() {
-            return Err(Error::ProofsRequired);
-        }
+        ensure_cdk!(!proofs.is_empty(), Error::ProofsRequired);
 
         Ok(Self {
             token: vec![TokenV3Token::new(mint_url, proofs)],
@@ -239,15 +233,21 @@ impl TokenV3 {
             .collect()
     }
 
-    /// Value
+    /// Value - errors if duplicate proofs are found
     #[inline]
     pub fn value(&self) -> Result<Amount, Error> {
-        Ok(Amount::try_sum(
-            self.token
-                .iter()
-                .map(|t| t.proofs.total_amount())
-                .collect::<Result<Vec<Amount>, _>>()?,
-        )?)
+        let proofs = self.proofs();
+        let unique_count = proofs
+            .iter()
+            .collect::<std::collections::HashSet<_>>()
+            .len();
+
+        // Check if there are any duplicate proofs
+        if unique_count != proofs.len() {
+            return Err(Error::DuplicateProofs);
+        }
+
+        proofs.total_amount()
     }
 
     /// Memo
@@ -273,6 +273,9 @@ impl TokenV3 {
         mint_urls
     }
 
+    /// Checks if a token has multiple mints
+    ///
+    /// These tokens are not supported by this crate
     pub fn is_multi_mint(&self) -> bool {
         self.token.len() > 1
     }
@@ -339,15 +342,21 @@ impl TokenV4 {
             .collect()
     }
 
-    /// Value
+    /// Value - errors if duplicate proofs are found
     #[inline]
     pub fn value(&self) -> Result<Amount, Error> {
-        Ok(Amount::try_sum(
-            self.token
-                .iter()
-                .map(|t| Amount::try_sum(t.proofs.iter().map(|p| p.amount)))
-                .collect::<Result<Vec<Amount>, _>>()?,
-        )?)
+        let proofs = self.proofs();
+        let unique_count = proofs
+            .iter()
+            .collect::<std::collections::HashSet<_>>()
+            .len();
+
+        // Check if there are any duplicate proofs
+        if unique_count != proofs.len() {
+            return Err(Error::DuplicateProofs);
+        }
+
+        proofs.total_amount()
     }
 
     /// Memo
@@ -400,18 +409,12 @@ impl TryFrom<&Vec<u8>> for TokenV4 {
     type Error = Error;
 
     fn try_from(bytes: &Vec<u8>) -> Result<Self, Self::Error> {
-        if bytes.len() < 5 {
-            return Err(Error::UnsupportedToken);
-        }
+        ensure_cdk!(bytes.len() >= 5, Error::UnsupportedToken);
 
         let prefix = String::from_utf8(bytes[..5].to_vec())?;
+        ensure_cdk!(prefix.as_str() == "crawB", Error::UnsupportedToken);
 
-        if prefix.as_str() == "crawB" {
-            let token: TokenV4 = ciborium::from_reader(&bytes[5..])?;
-            Ok(token)
-        } else {
-            Err(Error::UnsupportedToken)
-        }
+        Ok(ciborium::from_reader(&bytes[5..])?)
     }
 }
 
@@ -421,11 +424,9 @@ impl TryFrom<TokenV3> for TokenV4 {
         let proofs = token.proofs();
         let mint_urls = token.mint_urls();
 
-        if mint_urls.len() != 1 {
-            return Err(Error::UnsupportedToken);
-        }
+        ensure_cdk!(mint_urls.len() == 1, Error::UnsupportedToken);
 
-        let mint_url = mint_urls.first().expect("Len is checked");
+        let mint_url = mint_urls.first().ok_or(Error::UnsupportedToken)?;
 
         let proofs = proofs
             .iter()
@@ -494,6 +495,7 @@ mod tests {
 
     use super::*;
     use crate::mint_url::MintUrl;
+    use crate::secret::Secret;
     use crate::util::hex;
 
     #[test]
@@ -632,4 +634,54 @@ mod tests {
         let tokenv4_bytes_ = tokenv4_.to_raw_bytes().expect("Serialization error");
         assert!(tokenv4_bytes_ == tokenv4_bytes);
     }
+
+    #[test]
+    fn test_token_with_duplicate_proofs() {
+        // Create a token with duplicate proofs
+        let mint_url = MintUrl::from_str("https://example.com").unwrap();
+        let keyset_id = Id::from_str("009a1f293253e41e").unwrap();
+
+        let secret = Secret::generate();
+        // Create two identical proofs
+        let proof1 = Proof {
+            amount: Amount::from(10),
+            keyset_id,
+            secret: secret.clone(),
+            c: "02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea"
+                .parse()
+                .unwrap(),
+            witness: None,
+            dleq: None,
+        };
+
+        let proof2 = proof1.clone(); // Duplicate proof
+
+        // Create a token with the duplicate proofs
+        let proofs = vec![proof1.clone(), proof2].into_iter().collect();
+        let token = Token::new(mint_url.clone(), proofs, None, CurrencyUnit::Sat);
+
+        // Verify that value() returns an error
+        let result = token.value();
+        assert!(result.is_err());
+
+        // Create a token with unique proofs
+        let proof3 = Proof {
+            amount: Amount::from(10),
+            keyset_id,
+            secret: Secret::generate(),
+            c: "03bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea"
+                .parse()
+                .unwrap(), // Different C value
+            witness: None,
+            dleq: None,
+        };
+
+        let proofs = vec![proof1, proof3].into_iter().collect();
+        let token = Token::new(mint_url, proofs, None, CurrencyUnit::Sat);
+
+        // Verify that value() succeeds with unique proofs
+        let result = token.value();
+        assert!(result.is_ok());
+        assert_eq!(result.unwrap(), Amount::from(20));
+    }
 }

+ 9 - 2
crates/cashu/src/nuts/nut01/public_key.rs

@@ -12,13 +12,19 @@ use super::Error;
 use crate::SECP256K1;
 
 /// PublicKey
-#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct PublicKey {
     #[cfg_attr(feature = "swagger", schema(value_type = String))]
     inner: secp256k1::PublicKey,
 }
 
+impl fmt::Debug for PublicKey {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "PublicKey({})", self.to_hex())
+    }
+}
+
 impl Deref for PublicKey {
     type Target = secp256k1::PublicKey;
 
@@ -152,8 +158,9 @@ mod tests {
     }
 }
 
-#[cfg(feature = "bench")]
+#[cfg(all(feature = "bench", test))]
 mod benches {
+    extern crate test;
     use test::{black_box, Bencher};
 
     use super::*;

+ 37 - 11
crates/cashu/src/nuts/nut01/secret_key.rs

@@ -8,6 +8,7 @@ use bitcoin::secp256k1;
 use bitcoin::secp256k1::rand::rngs::OsRng;
 use bitcoin::secp256k1::schnorr::Signature;
 use bitcoin::secp256k1::{Keypair, Message, Scalar};
+use serde::de::Visitor;
 use serde::{Deserialize, Deserializer, Serialize};
 
 use super::{Error, PublicKey};
@@ -115,21 +116,46 @@ impl FromStr for SecretKey {
 }
 
 impl Serialize for SecretKey {
-    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
-    where
-        S: serde::Serializer,
-    {
-        serializer.serialize_str(&self.to_secret_hex())
+    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
+        match serializer.is_human_readable() {
+            // For human-readable formats like JSON, serialize as hex string
+            true => serializer.serialize_str(&self.to_secret_hex()),
+            // For binary formats like CBOR, use the bytes serialization
+            false => serializer.serialize_bytes(self.as_secret_bytes()),
+        }
     }
 }
 
 impl<'de> Deserialize<'de> for SecretKey {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let secret_key: String = String::deserialize(deserializer)?;
-        Self::from_hex(secret_key).map_err(serde::de::Error::custom)
+    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
+        match deserializer.is_human_readable() {
+            // For human-readable formats like JSON, deserialize from hex string
+            true => {
+                let secret_key: String = String::deserialize(deserializer)?;
+                SecretKey::from_hex(secret_key).map_err(serde::de::Error::custom)
+            }
+            // For binary formats like CBOR, use the bytes deserialization
+            false => {
+                struct SecretKeyVisitor;
+
+                impl Visitor<'_> for SecretKeyVisitor {
+                    type Value = SecretKey;
+
+                    fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+                        formatter.write_str("a byte array")
+                    }
+
+                    fn visit_bytes<E>(self, value: &[u8]) -> Result<Self::Value, E>
+                    where
+                        E: serde::de::Error,
+                    {
+                        SecretKey::from_slice(value).map_err(serde::de::Error::custom)
+                    }
+                }
+
+                deserializer.deserialize_bytes(SecretKeyVisitor)
+            }
+        }
     }
 }
 

+ 23 - 9
crates/cashu/src/nuts/nut02.rs

@@ -16,7 +16,7 @@ use bitcoin::hashes::Hash;
 use bitcoin::key::Secp256k1;
 #[cfg(feature = "mint")]
 use bitcoin::secp256k1;
-use serde::{Deserialize, Serialize};
+use serde::{Deserialize, Deserializer, Serialize};
 use serde_with::{serde_as, VecSkipError};
 use thiserror::Error;
 
@@ -25,7 +25,7 @@ use super::nut01::Keys;
 use super::nut01::{MintKeyPair, MintKeys};
 use crate::nuts::nut00::CurrencyUnit;
 use crate::util::hex;
-use crate::Amount;
+use crate::{ensure_cdk, Amount};
 
 /// NUT02 Error
 #[derive(Debug, Error)]
@@ -49,6 +49,7 @@ pub enum Error {
 
 /// Keyset version
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub enum KeySetVersion {
     /// Current Version 00
     Version00,
@@ -85,6 +86,7 @@ impl fmt::Display for KeySetVersion {
 /// which mint or keyset it was generated from.
 #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
 #[serde(into = "String", try_from = "String")]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct Id {
     version: KeySetVersion,
     id: [u8; Self::BYTELEN],
@@ -144,9 +146,7 @@ impl TryFrom<String> for Id {
     type Error = Error;
 
     fn try_from(s: String) -> Result<Self, Self::Error> {
-        if s.len() != 16 {
-            return Err(Error::Length);
-        }
+        ensure_cdk!(s.len() == 16, Error::Length);
 
         Ok(Self {
             version: KeySetVersion::from_byte(&hex::decode(&s[..2])?[0])?,
@@ -230,9 +230,7 @@ impl KeySet {
     pub fn verify_id(&self) -> Result<(), Error> {
         let keys_id: Id = (&self.keys).into();
 
-        if keys_id != self.id {
-            return Err(Error::IncorrectKeysetId);
-        }
+        ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId);
 
         Ok(())
     }
@@ -262,10 +260,22 @@ pub struct KeySetInfo {
     /// Mint will only sign from an active keyset
     pub active: bool,
     /// Input Fee PPK
-    #[serde(default = "default_input_fee_ppk")]
+    #[serde(
+        deserialize_with = "deserialize_input_fee_ppk",
+        default = "default_input_fee_ppk"
+    )]
     pub input_fee_ppk: u64,
 }
 
+fn deserialize_input_fee_ppk<'de, D>(deserializer: D) -> Result<u64, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    // This will either give us a u64 or null (which becomes None)
+    let opt = Option::<u64>::deserialize(deserializer)?;
+    Ok(opt.unwrap_or_else(default_input_fee_ppk))
+}
+
 fn default_input_fee_ppk() -> u64 {
     0
 }
@@ -488,6 +498,10 @@ mod test {
         let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true}"#;
 
         let _keyset_response: KeySetInfo = serde_json::from_str(h).unwrap();
+
+        let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true, "input_fee_ppk":null}"#;
+
+        let _keyset_response: KeySetInfo = serde_json::from_str(h).unwrap();
     }
 
     #[test]

+ 26 - 7
crates/cashu/src/nuts/nut03.rs

@@ -8,6 +8,7 @@ use thiserror::Error;
 #[cfg(feature = "wallet")]
 use super::nut00::PreMintSecrets;
 use super::nut00::{BlindSignature, BlindedMessage, Proofs};
+use super::ProofsMethods;
 use crate::Amount;
 
 /// NUT03 Error
@@ -40,16 +41,34 @@ pub struct PreSwap {
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct SwapRequest {
     /// Proofs that are to be spent in a `Swap`
-    #[cfg_attr(feature = "swagger", schema(value_type = Vec<Proof>))]
-    pub inputs: Proofs,
+    #[cfg_attr(feature = "swagger", schema(value_type = Vec<crate::Proof>))]
+    inputs: Proofs,
     /// Blinded Messages for Mint to sign
-    pub outputs: Vec<BlindedMessage>,
+    outputs: Vec<BlindedMessage>,
 }
 
 impl SwapRequest {
     /// Create new [`SwapRequest`]
     pub fn new(inputs: Proofs, outputs: Vec<BlindedMessage>) -> Self {
-        Self { inputs, outputs }
+        Self {
+            inputs: inputs.without_dleqs(),
+            outputs,
+        }
+    }
+
+    /// Get inputs (proofs)
+    pub fn inputs(&self) -> &Proofs {
+        &self.inputs
+    }
+
+    /// Get outputs (blinded messages)
+    pub fn outputs(&self) -> &Vec<BlindedMessage> {
+        &self.outputs
+    }
+
+    /// Get mutable reference to outputs (blinded messages)
+    pub fn outputs_mut(&mut self) -> &mut Vec<BlindedMessage> {
+        &mut self.outputs
     }
 
     /// Total value of proofs in [`SwapRequest`]
@@ -76,9 +95,9 @@ pub struct SwapResponse {
 }
 
 impl SwapResponse {
-    /// Create new [`SwapRequest`]
-    pub fn new(promises: Vec<BlindSignature>) -> SwapResponse {
-        SwapResponse {
+    /// Create new [`SwapResponse`]
+    pub fn new(promises: Vec<BlindSignature>) -> Self {
+        Self {
             signatures: promises,
         }
     }

+ 10 - 13
crates/cashu/src/nuts/nut04.rs

@@ -94,6 +94,12 @@ pub struct MintQuoteBolt11Response<Q> {
     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
@@ -112,6 +118,8 @@ impl<Q: ToString> MintQuoteBolt11Response<Q> {
             state: self.state,
             expiry: self.expiry,
             pubkey: self.pubkey,
+            amount: self.amount,
+            unit: self.unit.clone(),
         }
     }
 }
@@ -125,19 +133,8 @@ impl From<MintQuoteBolt11Response<Uuid>> for MintQuoteBolt11Response<String> {
             state: value.state,
             expiry: value.expiry,
             pubkey: value.pubkey,
-        }
-    }
-}
-
-#[cfg(feature = "mint")]
-impl From<crate::mint::MintQuote> for MintQuoteBolt11Response<Uuid> {
-    fn from(mint_quote: crate::mint::MintQuote) -> MintQuoteBolt11Response<Uuid> {
-        MintQuoteBolt11Response {
-            quote: mint_quote.id,
-            request: mint_quote.request,
-            state: mint_quote.state,
-            expiry: Some(mint_quote.expiry),
-            pubkey: mint_quote.pubkey,
+            amount: value.amount,
+            unit: value.unit.clone(),
         }
     }
 }

+ 93 - 40
crates/cashu/src/nuts/nut05.rs

@@ -14,8 +14,7 @@ use uuid::Uuid;
 
 use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proofs};
 use super::nut15::Mpp;
-#[cfg(feature = "mint")]
-use crate::mint::{self, MeltQuote};
+use super::ProofsMethods;
 use crate::nuts::MeltQuoteState;
 use crate::{Amount, Bolt11Invoice};
 
@@ -59,10 +58,15 @@ pub enum MeltOptions {
         /// MPP
         mpp: Mpp,
     },
+    /// Amountless options
+    Amountless {
+        /// Amountless
+        amountless: Amountless,
+    },
 }
 
 impl MeltOptions {
-    /// Create new [`Options::Mpp`]
+    /// Create new [`MeltOptions::Mpp`]
     pub fn new_mpp<A>(amount: A) -> Self
     where
         A: Into<Amount>,
@@ -74,14 +78,35 @@ impl MeltOptions {
         }
     }
 
+    /// 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`]
     ///
@@ -101,6 +126,15 @@ impl MeltQuoteBolt11Request {
                 .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)
+            }
         }
     }
 }
@@ -175,6 +209,14 @@ pub struct MeltQuoteBolt11Response<Q> {
     /// 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> {
@@ -190,6 +232,8 @@ impl<Q: ToString> MeltQuoteBolt11Response<Q> {
             expiry: self.expiry,
             payment_preimage: self.payment_preimage,
             change: self.change,
+            request: self.request,
+            unit: self.unit,
         }
     }
 }
@@ -206,22 +250,8 @@ impl From<MeltQuoteBolt11Response<Uuid>> for MeltQuoteBolt11Response<String> {
             expiry: value.expiry,
             payment_preimage: value.payment_preimage,
             change: value.change,
-        }
-    }
-}
-
-#[cfg(feature = "mint")]
-impl From<&MeltQuote> for MeltQuoteBolt11Response<Uuid> {
-    fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response<Uuid> {
-        MeltQuoteBolt11Response {
-            quote: melt_quote.id,
-            payment_preimage: None,
-            change: None,
-            state: melt_quote.state,
-            paid: Some(melt_quote.state == MeltQuoteState::Paid),
-            expiry: melt_quote.expiry,
-            amount: melt_quote.amount,
-            fee_reserve: melt_quote.fee_reserve,
+            request: value.request,
+            unit: value.unit,
         }
     }
 }
@@ -297,6 +327,14 @@ impl<'de, Q: DeserializeOwned> Deserialize<'de> for MeltQuoteBolt11Response<Q> {
             .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,
@@ -306,40 +344,25 @@ impl<'de, Q: DeserializeOwned> Deserialize<'de> for MeltQuoteBolt11Response<Q> {
             expiry,
             payment_preimage,
             change,
+            request,
+            unit,
         })
     }
 }
 
-#[cfg(feature = "mint")]
-impl From<mint::MeltQuote> for MeltQuoteBolt11Response<Uuid> {
-    fn from(melt_quote: mint::MeltQuote) -> MeltQuoteBolt11Response<Uuid> {
-        let paid = melt_quote.state == QuoteState::Paid;
-        MeltQuoteBolt11Response {
-            quote: melt_quote.id,
-            amount: melt_quote.amount,
-            fee_reserve: melt_quote.fee_reserve,
-            paid: Some(paid),
-            state: melt_quote.state,
-            expiry: melt_quote.expiry,
-            payment_preimage: melt_quote.payment_preimage,
-            change: None,
-        }
-    }
-}
-
 /// 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> {
     /// Quote ID
-    pub quote: Q,
+    quote: Q,
     /// Proofs
-    #[cfg_attr(feature = "swagger", schema(value_type = Vec<Proof>))]
-    pub inputs: Proofs,
+    #[cfg_attr(feature = "swagger", schema(value_type = Vec<crate::Proof>))]
+    inputs: Proofs,
     /// Blinded Message that can be used to return change [NUT-08]
     /// Amount field of BlindedMessages `SHOULD` be set to zero
-    pub outputs: Option<Vec<BlindedMessage>>,
+    outputs: Option<Vec<BlindedMessage>>,
 }
 
 #[cfg(feature = "mint")]
@@ -355,7 +378,34 @@ impl TryFrom<MeltBolt11Request<String>> for MeltBolt11Request<Uuid> {
     }
 }
 
+// Basic implementation without trait bounds
+impl<Q> MeltBolt11Request<Q> {
+    /// Get inputs (proofs)
+    pub fn inputs(&self) -> &Proofs {
+        &self.inputs
+    }
+
+    /// Get outputs (blinded messages for change)
+    pub fn outputs(&self) -> &Option<Vec<BlindedMessage>> {
+        &self.outputs
+    }
+}
+
 impl<Q: Serialize + DeserializeOwned> MeltBolt11Request<Q> {
+    /// Create new [`MeltBolt11Request`]
+    pub fn new(quote: Q, inputs: Proofs, outputs: Option<Vec<BlindedMessage>>) -> Self {
+        Self {
+            quote,
+            inputs: inputs.without_dleqs(),
+            outputs,
+        }
+    }
+
+    /// Get quote
+    pub fn quote(&self) -> &Q {
+        &self.quote
+    }
+
     /// Total [`Amount`] of [`Proofs`]
     pub fn proofs_amount(&self) -> Result<Amount, Error> {
         Amount::try_sum(self.inputs.iter().map(|proof| proof.amount))
@@ -377,6 +427,9 @@ pub struct MeltMethodSettings {
     /// Max Amount
     #[serde(skip_serializing_if = "Option::is_none")]
     pub max_amount: Option<Amount>,
+    /// Amountless
+    #[serde(default)]
+    pub amountless: bool,
 }
 
 impl Settings {

+ 126 - 52
crates/cashu/src/nuts/nut06.rs

@@ -2,12 +2,17 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/06.md>
 
+#[cfg(feature = "auth")]
+use std::collections::HashMap;
+
 use serde::{Deserialize, Deserializer, Serialize, Serializer};
 
 use super::nut01::PublicKey;
 use super::nut17::SupportedMethods;
 use super::nut19::CachedEndpoint;
 use super::{nut04, nut05, nut15, nut19, MppMethodSettings};
+#[cfg(feature = "auth")]
+use super::{AuthRequired, BlindAuthSettings, ClearAuthSettings, ProtectedEndpoint};
 
 /// Mint Version
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -59,7 +64,7 @@ impl<'de> Deserialize<'de> for MintVersion {
     }
 }
 
-/// Mint Info [NIP-06]
+/// Mint Info [NUT-06]
 #[derive(Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct MintInfo {
@@ -95,6 +100,9 @@ pub struct MintInfo {
     /// server unix timestamp
     #[serde(skip_serializing_if = "Option::is_none")]
     pub time: Option<u64>,
+    /// terms of url service of the mint
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub tos_url: Option<String>,
 }
 
 impl MintInfo {
@@ -197,6 +205,57 @@ impl MintInfo {
             ..self
         }
     }
+
+    /// Set tos_url
+    pub fn tos_url<S>(self, tos_url: S) -> Self
+    where
+        S: Into<String>,
+    {
+        Self {
+            tos_url: Some(tos_url.into()),
+            ..self
+        }
+    }
+
+    /// Get protected endpoints
+    #[cfg(feature = "auth")]
+    pub fn protected_endpoints(&self) -> HashMap<ProtectedEndpoint, AuthRequired> {
+        let mut protected_endpoints = HashMap::new();
+
+        if let Some(nut21_settings) = &self.nuts.nut21 {
+            for endpoint in nut21_settings.protected_endpoints.iter() {
+                protected_endpoints.insert(*endpoint, AuthRequired::Clear);
+            }
+        }
+
+        if let Some(nut22_settings) = &self.nuts.nut22 {
+            for endpoint in nut22_settings.protected_endpoints.iter() {
+                protected_endpoints.insert(*endpoint, AuthRequired::Blind);
+            }
+        }
+        protected_endpoints
+    }
+
+    /// Get Openid discovery of the mint if it is set
+    #[cfg(feature = "auth")]
+    pub fn openid_discovery(&self) -> Option<String> {
+        self.nuts
+            .nut21
+            .as_ref()
+            .map(|s| s.openid_discovery.to_string())
+    }
+
+    /// Get Openid discovery of the mint if it is set
+    #[cfg(feature = "auth")]
+    pub fn client_id(&self) -> Option<String> {
+        self.nuts.nut21.as_ref().map(|s| s.client_id.clone())
+    }
+
+    /// Max bat mint
+    #[cfg(feature = "auth")]
+    pub fn bat_max_mint(&self) -> Option<u64> {
+        self.nuts.nut22.as_ref().map(|s| s.bat_max_mint)
+    }
 }
 
 /// Supported nuts and settings
@@ -255,6 +314,16 @@ pub struct Nuts {
     #[serde(default)]
     #[serde(rename = "20")]
     pub nut20: SupportedSettings,
+    /// NUT21 Settings
+    #[serde(rename = "21")]
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[cfg(feature = "auth")]
+    pub nut21: Option<ClearAuthSettings>,
+    /// NUT22 Settings
+    #[serde(rename = "22")]
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[cfg(feature = "auth")]
+    pub nut22: Option<BlindAuthSettings>,
 }
 
 impl Nuts {
@@ -405,12 +474,12 @@ mod tests {
     #[test]
     fn test_des_mint_into() {
         let mint_info_str = r#"{
-    "name": "Cashu mint",
-    "pubkey": "0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679",
-    "version": "Nutshell/0.15.3",
-    "contact": [
-        ["", ""],
-        ["", ""]
+"name": "Cashu mint",
+"pubkey": "0296d0aa13b6a31cf0cd974249f28c7b7176d7274712c95a41c7d8066d3f29d679",
+"version": "Nutshell/0.15.3",
+"contact": [
+    ["", ""],
+    ["", ""]
     ],
     "nuts": {
         "4": {
@@ -432,7 +501,8 @@ mod tests {
         "9": {"supported": true},
         "10": {"supported": true},
         "11": {"supported": true}
-    }
+    },
+"tos_url": "https://cashu.mint/tos"
 }"#;
 
         let _mint_info: MintInfo = serde_json::from_str(mint_info_str).unwrap();
@@ -455,7 +525,8 @@ mod tests {
 
                 println!("{}", mint_info);
         */
-        let mint_info_str = r#"{
+        let mint_info_str = r#"
+{
   "name": "Bob's Cashu mint",
   "pubkey": "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99",
   "version": "Nutshell/0.15.0",
@@ -502,51 +573,54 @@ mod tests {
     "9": {"supported": true},
     "10": {"supported": true},
     "12": {"supported": true}
-  }
+  },
+  "tos_url": "https://cashu.mint/tos"
 }"#;
         let info: MintInfo = serde_json::from_str(mint_info_str).unwrap();
-        let mint_info_str = r#"{
-  "name": "Bob's Cashu mint",
-  "pubkey": "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99",
-  "version": "Nutshell/0.15.0",
-  "description": "The short mint description",
-  "description_long": "A description that can be a long piece of text.",
-  "contact": [
-        ["nostr", "xxxxx"],
-        ["email", "contact@me.com"]
-  ],
-  "motd": "Message to display to users.",
-  "icon_url": "https://this-is-a-mint-icon-url.com/icon.png",
-  "nuts": {
-    "4": {
-      "methods": [
-        {
-        "method": "bolt11",
-        "unit": "sat",
-        "min_amount": 0,
-        "max_amount": 10000,
-        "description": true
-        }
-      ],
-      "disabled": false
-    },
-    "5": {
-      "methods": [
-        {
-        "method": "bolt11",
-        "unit": "sat",
-        "min_amount": 0,
-        "max_amount": 10000
-        }
-      ],
-      "disabled": false
-    },
-    "7": {"supported": true},
-    "8": {"supported": true},
-    "9": {"supported": true},
-    "10": {"supported": true},
-    "12": {"supported": true}
-  }
+        let mint_info_str = r#"
+{
+    "name": "Bob's Cashu mint",
+    "pubkey": "0283bf290884eed3a7ca2663fc0260de2e2064d6b355ea13f98dec004b7a7ead99",
+    "version": "Nutshell/0.15.0",
+    "description": "The short mint description",
+    "description_long": "A description that can be a long piece of text.",
+    "contact": [
+    ["nostr", "xxxxx"],
+    ["email", "contact@me.com"]
+        ],
+        "motd": "Message to display to users.",
+        "icon_url": "https://this-is-a-mint-icon-url.com/icon.png",
+        "nuts": {
+            "4": {
+            "methods": [
+                {
+                "method": "bolt11",
+                "unit": "sat",
+                "min_amount": 0,
+                "max_amount": 10000,
+                "description": true
+                }
+            ],
+            "disabled": false
+            },
+            "5": {
+            "methods": [
+                {
+                "method": "bolt11",
+                "unit": "sat",
+                "min_amount": 0,
+                "max_amount": 10000
+                }
+            ],
+            "disabled": false
+            },
+            "7": {"supported": true},
+            "8": {"supported": true},
+            "9": {"supported": true},
+            "10": {"supported": true},
+            "12": {"supported": true}
+        },
+        "tos_url": "https://cashu.mint/tos"
 }"#;
         let mint_info: MintInfo = serde_json::from_str(mint_info_str).unwrap();
 

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

@@ -31,10 +31,12 @@ pub enum State {
     ///
     /// Currently being used in a transaction i.e. melt in progress
     Pending,
-    /// Proof is reserved
+    /// Reserved
     ///
-    /// i.e. used to create a token
+    /// Proof is reserved for future token creation
     Reserved,
+    /// Pending spent (i.e., spent but not yet swapped by receiver)
+    PendingSpent,
 }
 
 impl fmt::Display for State {
@@ -44,6 +46,7 @@ impl fmt::Display for State {
             Self::Unspent => "UNSPENT",
             Self::Pending => "PENDING",
             Self::Reserved => "RESERVED",
+            Self::PendingSpent => "PENDING_SPENT",
         };
 
         write!(f, "{}", s)
@@ -59,6 +62,7 @@ impl FromStr for State {
             "UNSPENT" => Ok(Self::Unspent),
             "PENDING" => Ok(Self::Pending),
             "RESERVED" => Ok(Self::Reserved),
+            "PENDING_SPENT" => Ok(Self::PendingSpent),
             _ => Err(Error::UnknownState),
         }
     }

+ 1 - 1
crates/cashu/src/nuts/nut08.rs

@@ -8,7 +8,7 @@ use crate::Amount;
 impl<Q> MeltBolt11Request<Q> {
     /// Total output [`Amount`]
     pub fn output_amount(&self) -> Option<Amount> {
-        self.outputs
+        self.outputs()
             .as_ref()
             .and_then(|o| Amount::try_sum(o.iter().map(|proof| proof.amount)).ok())
     }

+ 3 - 7
crates/cashu/src/nuts/nut11/mod.rs

@@ -17,6 +17,7 @@ use thiserror::Error;
 use super::nut00::Witness;
 use super::nut01::PublicKey;
 use super::{Kind, Nut10Secret, Proof, Proofs, SecretKey};
+use crate::ensure_cdk;
 use crate::nuts::nut00::BlindedMessage;
 use crate::secret::Secret;
 use crate::util::{hex, unix_time};
@@ -422,9 +423,7 @@ impl Conditions {
         sig_flag: Option<SigFlag>,
     ) -> Result<Self, Error> {
         if let Some(locktime) = locktime {
-            if locktime.lt(&unix_time()) {
-                return Err(Error::LocktimeInPast);
-            }
+            ensure_cdk!(locktime.ge(&unix_time()), Error::LocktimeInPast);
         }
 
         Ok(Self {
@@ -704,10 +703,7 @@ where
     type Error = Error;
 
     fn try_from(tag: Vec<S>) -> Result<Self, Self::Error> {
-        let tag_kind: TagKind = match tag.first() {
-            Some(kind) => TagKind::from(kind),
-            None => return Err(Error::KindNotFound),
-        };
+        let tag_kind = tag.first().map(TagKind::from).ok_or(Error::KindNotFound)?;
 
         match tag_kind {
             TagKind::SigFlag => Ok(Tag::SigFlag(SigFlag::from_str(tag[1].as_ref())?)),

+ 3 - 3
crates/cashu/src/nuts/nut14/mod.rs

@@ -14,6 +14,7 @@ use super::nut00::Witness;
 use super::nut10::Secret;
 use super::nut11::valid_signatures;
 use super::{Conditions, Proof};
+use crate::ensure_cdk;
 use crate::util::unix_time;
 
 pub mod serde_htlc_witness;
@@ -112,9 +113,8 @@ impl Proof {
                     .map(|s| Signature::from_str(s))
                     .collect::<Result<Vec<Signature>, _>>()?;
 
-                if valid_signatures(self.secret.as_bytes(), &pubkey, &signatures).lt(&req_sigs) {
-                    return Err(Error::IncorrectSecretKind);
-                }
+                let valid_sigs = valid_signatures(self.secret.as_bytes(), &pubkey, &signatures);
+                ensure_cdk!(valid_sigs >= req_sigs, Error::IncorrectSecretKind);
             }
         }
 

+ 2 - 0
crates/cashu/src/nuts/nut17/mod.rs

@@ -27,6 +27,7 @@ pub struct Params<I> {
 
 /// Check state Settings
 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct SupportedSettings {
     /// Supported methods
     pub supported: Vec<SupportedMethods>,
@@ -34,6 +35,7 @@ pub struct SupportedSettings {
 
 /// Supported WS Methods
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct SupportedMethods {
     /// Payment Method
     pub method: PaymentMethod,

+ 238 - 2
crates/cashu/src/nuts/nut18.rs

@@ -49,11 +49,27 @@ impl fmt::Display for TransportType {
     }
 }
 
+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 = serde_json::Error;
+    type Err = Error;
 
     fn from_str(s: &str) -> Result<Self, Self::Err> {
-        serde_json::from_str(s)
+        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[..])?)
     }
 }
 
@@ -71,6 +87,65 @@ pub struct Transport {
     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 {
@@ -97,6 +172,106 @@ pub struct PaymentRequest {
     pub transports: Vec<Transport>,
 }
 
+impl PaymentRequest {
+    /// Create a new PaymentRequestBuilder
+    pub fn builder() -> PaymentRequestBuilder {
+        PaymentRequestBuilder::default()
+    }
+}
+
+/// Builder for PaymentRequest
+#[derive(Debug, Default, Clone)]
+pub struct PaymentRequestBuilder {
+    payment_id: Option<String>,
+    amount: Option<Amount>,
+    unit: Option<CurrencyUnit>,
+    single_use: Option<bool>,
+    mints: Option<Vec<MintUrl>>,
+    description: Option<String>,
+    transports: Vec<Transport>,
+}
+
+impl PaymentRequestBuilder {
+    /// Set payment ID
+    pub fn payment_id<S>(mut self, payment_id: S) -> Self
+    where
+        S: Into<String>,
+    {
+        self.payment_id = Some(payment_id.into());
+        self
+    }
+
+    /// Set amount
+    pub fn amount<A>(mut self, amount: A) -> Self
+    where
+        A: Into<Amount>,
+    {
+        self.amount = Some(amount.into());
+        self
+    }
+
+    /// Set unit
+    pub fn unit(mut self, unit: CurrencyUnit) -> Self {
+        self.unit = Some(unit);
+        self
+    }
+
+    /// Set single use flag
+    pub fn single_use(mut self, single_use: bool) -> Self {
+        self.single_use = Some(single_use);
+        self
+    }
+
+    /// Add a mint URL
+    pub fn add_mint(mut self, mint_url: MintUrl) -> Self {
+        self.mints.get_or_insert_with(Vec::new).push(mint_url);
+        self
+    }
+
+    /// Set mints
+    pub fn mints(mut self, mints: Vec<MintUrl>) -> Self {
+        self.mints = Some(mints);
+        self
+    }
+
+    /// Set description
+    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
+        self.description = Some(description.into());
+        self
+    }
+
+    /// Add a transport
+    pub fn add_transport(mut self, transport: Transport) -> Self {
+        self.transports.push(transport);
+        self
+    }
+
+    /// Set transports
+    pub fn transports(mut self, transports: Vec<Transport>) -> Self {
+        self.transports = transports;
+        self
+    }
+
+    /// Build the PaymentRequest
+    pub fn build(self) -> PaymentRequest {
+        PaymentRequest {
+            payment_id: self.payment_id,
+            amount: self.amount,
+            unit: self.unit,
+            single_use: self.single_use,
+            mints: self.mints,
+            description: self.description,
+            transports: self.transports,
+        }
+    }
+}
+
+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;
@@ -198,4 +373,65 @@ mod tests {
         let t = req.transports.first().unwrap();
         assert_eq!(&transport, t);
     }
+
+    #[test]
+    fn test_payment_request_builder() {
+        let transport = Transport {
+            _type: TransportType::Nostr,
+            target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), 
+            tags: Some(vec![vec!["n".to_string(), "17".to_string()]])
+        };
+
+        let mint_url =
+            MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url");
+
+        // Build a payment request using the builder pattern
+        let request = PaymentRequest::builder()
+            .payment_id("b7a90176")
+            .amount(Amount::from(10))
+            .unit(CurrencyUnit::Sat)
+            .add_mint(mint_url.clone())
+            .add_transport(transport.clone())
+            .build();
+
+        // Verify the built request
+        assert_eq!(&request.payment_id.clone().unwrap(), "b7a90176");
+        assert_eq!(request.amount.unwrap(), 10.into());
+        assert_eq!(request.unit.clone().unwrap(), CurrencyUnit::Sat);
+        assert_eq!(request.mints.clone().unwrap(), vec![mint_url]);
+
+        let t = request.transports.first().unwrap();
+        assert_eq!(&transport, t);
+
+        // Test serialization and deserialization
+        let request_str = request.to_string();
+        let req = PaymentRequest::from_str(&request_str).expect("valid payment request");
+
+        assert_eq!(req.payment_id, request.payment_id);
+        assert_eq!(req.amount, request.amount);
+        assert_eq!(req.unit, request.unit);
+    }
+
+    #[test]
+    fn test_transport_builder() {
+        // Build a transport using the builder pattern
+        let transport = Transport::builder()
+            .transport_type(TransportType::Nostr)
+            .target("nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5")
+            .add_tag(vec!["n".to_string(), "17".to_string()])
+            .build()
+            .expect("Valid transport");
+
+        // Verify the built transport
+        assert_eq!(transport._type, TransportType::Nostr);
+        assert_eq!(transport.target, "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5");
+        assert_eq!(
+            transport.tags,
+            Some(vec![vec!["n".to_string(), "17".to_string()]])
+        );
+
+        // Test error case - missing required fields
+        let result = TransportBuilder::default().build();
+        assert!(result.is_err());
+    }
 }

+ 3 - 0
crates/cashu/src/nuts/nut19.rs

@@ -16,6 +16,7 @@ pub struct Settings {
 
 /// List of the methods and paths for which caching is enabled
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub struct CachedEndpoint {
     /// HTTP Method
     pub method: Method,
@@ -33,6 +34,7 @@ impl CachedEndpoint {
 /// HTTP method
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
 #[serde(rename_all = "UPPERCASE")]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub enum Method {
     /// Get
     Get,
@@ -42,6 +44,7 @@ pub enum Method {
 
 /// Route path
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
 pub enum Path {
     /// Bolt11 Mint
     #[serde(rename = "/v1/mint/bolt11")]

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

@@ -5,6 +5,8 @@
 
 use core::fmt;
 
+use crate::ensure_cdk;
+
 /// Hex error
 #[derive(Debug, PartialEq, Eq)]
 pub enum Error {
@@ -76,9 +78,7 @@ where
     let hex = hex.as_ref();
     let len = hex.len();
 
-    if len % 2 != 0 {
-        return Err(Error::OddLength);
-    }
+    ensure_cdk!(len % 2 == 0, Error::OddLength);
 
     let mut bytes: Vec<u8> = Vec::with_capacity(len / 2);
 

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

@@ -1,3 +1,5 @@
+//! Cashu utils
+
 #[cfg(not(target_arch = "wasm32"))]
 use std::time::{SystemTime, UNIX_EPOCH};
 

+ 0 - 87
crates/cashu/src/wallet.rs

@@ -1,87 +0,0 @@
-//! Wallet Types
-
-use std::fmt;
-
-use serde::{Deserialize, Serialize};
-
-use crate::mint_url::MintUrl;
-use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, SecretKey};
-use crate::Amount;
-
-/// Wallet Key
-#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
-pub struct WalletKey {
-    /// Mint Url
-    pub mint_url: MintUrl,
-    /// Currency Unit
-    pub unit: CurrencyUnit,
-}
-
-impl fmt::Display for WalletKey {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        write!(f, "mint_url: {}, unit: {}", self.mint_url, self.unit,)
-    }
-}
-
-impl WalletKey {
-    /// Create new [`WalletKey`]
-    pub fn new(mint_url: MintUrl, unit: CurrencyUnit) -> Self {
-        Self { mint_url, unit }
-    }
-}
-
-/// Mint Quote Info
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct MintQuote {
-    /// Quote id
-    pub id: String,
-    /// Mint Url
-    pub mint_url: MintUrl,
-    /// Amount of quote
-    pub amount: Amount,
-    /// Unit of quote
-    pub unit: CurrencyUnit,
-    /// Quote payment request e.g. bolt11
-    pub request: String,
-    /// Quote state
-    pub state: MintQuoteState,
-    /// Expiration time of quote
-    pub expiry: u64,
-    /// Secretkey for signing mint quotes [NUT-20]
-    pub secret_key: Option<SecretKey>,
-}
-
-/// Melt Quote Info
-#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
-pub struct MeltQuote {
-    /// Quote id
-    pub id: String,
-    /// Quote unit
-    pub unit: CurrencyUnit,
-    /// Quote amount
-    pub amount: Amount,
-    /// Quote Payment request e.g. bolt11
-    pub request: String,
-    /// Quote fee reserve
-    pub fee_reserve: Amount,
-    /// Quote state
-    pub state: MeltQuoteState,
-    /// Expiration time of quote
-    pub expiry: u64,
-    /// Payment preimage
-    pub payment_preimage: Option<String>,
-}
-
-/// Send Kind
-#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
-pub enum SendKind {
-    #[default]
-    /// Allow online swap before send if wallet does not have exact amount
-    OnlineExact,
-    /// Prefer offline send if difference is less then tolerance
-    OnlineTolerance(Amount),
-    /// Wallet cannot do an online swap and selected proof must be exactly send amount
-    OfflineExact,
-    /// Wallet must remain offline but can over pay if below tolerance
-    OfflineTolerance(Amount),
-}

+ 29 - 25
crates/cdk-axum/Cargo.toml

@@ -1,40 +1,44 @@
 [package]
 name = "cdk-axum"
-version = "0.7.1"
-edition = "2021"
-license = "MIT"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-rust-version = "1.63.0"                            # MSRV
+rust-version.workspace = true                            # MSRV
 description = "Cashu CDK axum webserver"
+readme = "README.md"
+
+
+[features]
+default = ["auth"]
+redis = ["dep:redis"]
+swagger = ["cdk/swagger", "dep:utoipa"]
+auth = ["cdk/auth"]
 
 [dependencies]
-anyhow = "1"
-async-trait = "0.1.83"
-axum = { version = "0.6.20", features = ["ws"] }
-cdk = { path = "../cdk", version = "0.7.1", default-features = false, features = [
+anyhow.workspace = true
+async-trait.workspace = true
+axum = { workspace = true, features = ["ws"] }
+cdk = { workspace = true, features = [
     "mint",
-] }
-tokio = { version = "1", default-features = false, features = ["io-util"] }
-tracing = { version = "0.1", default-features = false, features = [
-    "attributes",
-    "log",
-] }
-utoipa = { version = "4", features = [
-    "preserve_order",
-    "preserve_path_order",
-], optional = true }
-futures = { version = "0.3.28", default-features = false }
+]}
+tokio.workspace = true
+tracing.workspace = true
+utoipa = { workspace = true, optional = true }
+futures.workspace = true
 moka = { version = "0.11.1", features = ["future"] }
-serde_json = "1"
+serde_json.workspace = true
 paste = "1.0.15"
-serde = { version = "1", features = ["derive"] }
-uuid = { version = "1", features = ["v4", "serde"] }
+serde.workspace = true
+uuid.workspace = true
 sha2 = "0.10.8"
 redis = { version = "0.23.3", features = [
     "tokio-rustls-comp",
 ], optional = true }
 
-[features]
-redis = ["dep:redis"]
-swagger = ["cdk/swagger", "dep:utoipa"]
+
+[build-dependencies]
+# Dep of utopia 2.5.0 breaks so keeping here for now
+time = "=0.3.39"
+

+ 31 - 0
crates/cdk-axum/README.md

@@ -0,0 +1,31 @@
+# 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)
+
+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.
+
+## Overview
+
+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
+
+Add this to your `Cargo.toml`:
+
+```toml
+[dependencies]
+cdk-axum = "*"
+```
+
+## License
+
+This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/LICENSE).

+ 194 - 0
crates/cdk-axum/src/auth.rs

@@ -0,0 +1,194 @@
+use std::str::FromStr;
+
+use axum::extract::{FromRequestParts, State};
+use axum::http::request::Parts;
+use axum::http::StatusCode;
+use axum::response::Response;
+use axum::routing::{get, post};
+use axum::{Json, Router};
+#[cfg(feature = "swagger")]
+use cdk::error::ErrorResponse;
+use cdk::nuts::{
+    AuthToken, BlindAuthToken, KeysResponse, KeysetResponse, MintAuthRequest, MintBolt11Response,
+};
+use serde::{Deserialize, Serialize};
+
+#[cfg(feature = "auth")]
+use crate::{get_keyset_pubkeys, into_response, MintState};
+
+const CLEAR_AUTH_KEY: &str = "Clear-auth";
+const BLIND_AUTH_KEY: &str = "Blind-auth";
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum AuthHeader {
+    /// Clear Auth token
+    Clear(String),
+    /// Blind Auth token
+    Blind(BlindAuthToken),
+    /// No auth
+    None,
+}
+
+impl From<AuthHeader> for Option<AuthToken> {
+    fn from(value: AuthHeader) -> Option<AuthToken> {
+        match value {
+            AuthHeader::Clear(token) => Some(AuthToken::ClearAuth(token)),
+            AuthHeader::Blind(token) => Some(AuthToken::BlindAuth(token)),
+            AuthHeader::None => None,
+        }
+    }
+}
+
+impl<S> FromRequestParts<S> for AuthHeader
+where
+    S: Send + Sync,
+{
+    type Rejection = (StatusCode, String);
+
+    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
+        // Check for Blind-auth header
+        if let Some(bat) = parts.headers.get(BLIND_AUTH_KEY) {
+            let token = bat
+                .to_str()
+                .map_err(|_| {
+                    (
+                        StatusCode::BAD_REQUEST,
+                        "Invalid Blind-auth header value".to_string(),
+                    )
+                })?
+                .to_string();
+
+            let token = BlindAuthToken::from_str(&token).map_err(|_| {
+                (
+                    StatusCode::BAD_REQUEST,
+                    "Invalid Blind-auth header value".to_string(),
+                )
+            })?;
+
+            return Ok(AuthHeader::Blind(token));
+        }
+
+        // Check for Clear-auth header
+        if let Some(cat) = parts.headers.get(CLEAR_AUTH_KEY) {
+            let token = cat
+                .to_str()
+                .map_err(|_| {
+                    (
+                        StatusCode::BAD_REQUEST,
+                        "Invalid Clear-auth header value".to_string(),
+                    )
+                })?
+                .to_string();
+            return Ok(AuthHeader::Clear(token));
+        }
+
+        // No authentication headers found - this is now valid
+        Ok(AuthHeader::None)
+    }
+}
+
+#[cfg_attr(feature = "swagger", utoipa::path(
+    get,
+    context_path = "/v1/auth/blind",
+    path = "/keysets",
+    responses(
+        (status = 200, description = "Successful response", body = KeysetResponse, content_type = "application/json"),
+        (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
+    )
+))]
+/// Get all active keyset IDs of the mint
+///
+/// This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.
+#[cfg(feature = "auth")]
+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))
+}
+
+#[cfg_attr(feature = "swagger", utoipa::path(
+    get,
+    context_path = "/v1/auth/blind",
+    path = "/keys",
+    responses(
+        (status = 200, description = "Successful response", body = KeysResponse, content_type = "application/json")
+    )
+))]
+/// Get the public keys of the newest blind auth mint keyset
+///
+/// This endpoint returns a dictionary of all supported token values of the mint and their associated public key.
+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| {
+        tracing::error!("Could not get keys: {}", err);
+        into_response(err)
+    })?;
+
+    Ok(Json(pubkeys))
+}
+
+/// Mint tokens by paying a BOLT11 Lightning invoice.
+///
+/// Requests the minting of tokens belonging to a paid payment request.
+///
+/// Call this endpoint after `POST /v1/mint/quote`.
+#[cfg_attr(feature = "swagger", utoipa::path(
+    post,
+    context_path = "/v1/auth",
+    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 = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
+    )
+))]
+pub async fn post_mint_auth(
+    auth: AuthHeader,
+    State(state): State<MintState>,
+    Json(payload): Json<MintAuthRequest>,
+) -> Result<Json<MintBolt11Response>, Response> {
+    let auth_token = match auth {
+        AuthHeader::Clear(cat) => {
+            if cat.is_empty() {
+                tracing::debug!("Received blind auth mint request without cat");
+                return Err(into_response(cdk::Error::ClearAuthRequired));
+            }
+
+            AuthToken::ClearAuth(cat)
+        }
+        _ => {
+            tracing::debug!("Received blind auth mint request without cat");
+            return Err(into_response(cdk::Error::ClearAuthRequired));
+        }
+    };
+
+    let res = state
+        .mint
+        .mint_blind_auth(auth_token, payload)
+        .await
+        .map_err(|err| {
+            tracing::error!("Could not process blind auth mint: {}", err);
+            into_response(err)
+        })?;
+
+    Ok(Json(res))
+}
+
+pub fn create_auth_router(state: MintState) -> Router<MintState> {
+    Router::new()
+        .nest(
+            "/auth/blind",
+            Router::new()
+                .route("/keys", get(get_blind_auth_keys))
+                .route("/keysets", get(get_auth_keysets))
+                .route("/keys/{keyset_id}", get(get_keyset_pubkeys))
+                .route("/mint", post(post_mint_auth)),
+        )
+        .with_state(state)
+}

+ 2 - 2
crates/cdk-axum/src/cache/mod.rs

@@ -69,7 +69,7 @@ const DEFAULT_TTI_SECS: u64 = 60;
 
 /// Http cache key.
 ///
-/// This type ensures no Vec<u8> is used as a key, which is error-prone.
+/// This type ensures no `Vec<u8>` is used as a key, which is error-prone.
 #[derive(Clone, Debug, PartialEq, Eq, Hash)]
 pub struct HttpCacheKey([u8; 32]);
 
@@ -117,7 +117,7 @@ impl HttpCache {
         tti: Duration,
         storage: Option<Box<dyn HttpCacheStorage + Send + Sync + 'static>>,
     ) -> Self {
-        let mut storage = storage.unwrap_or_else(|| Box::new(InMemoryHttpCache::default()));
+        let mut storage = storage.unwrap_or_else(|| Box::<InMemoryHttpCache>::default());
         storage.set_expiration_times(ttl, tti);
 
         Self {

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

@@ -1,17 +1,24 @@
 //! Axum server for Mint
 
+#![doc = include_str!("../README.md")]
 #![warn(missing_docs)]
 #![warn(rustdoc::bare_urls)]
 
 use std::sync::Arc;
 
 use anyhow::Result;
+#[cfg(feature = "auth")]
+use auth::create_auth_router;
+use axum::middleware::from_fn;
+use axum::response::Response;
 use axum::routing::{get, post};
 use axum::Router;
 use cache::HttpCache;
 use cdk::mint::Mint;
 use router_handlers::*;
 
+#[cfg(feature = "auth")]
+mod auth;
 pub mod cache;
 mod router_handlers;
 mod ws;
@@ -45,8 +52,6 @@ mod swagger_imports {
 
 #[cfg(feature = "swagger")]
 use swagger_imports::*;
-#[cfg(feature = "swagger")]
-use uuid::Uuid;
 
 /// CDK Mint State
 #[derive(Clone)]
@@ -75,16 +80,16 @@ pub struct MintState {
         KeysetResponse,
         KeySet,
         KeySetInfo,
-        MeltBolt11Request<Uuid>,
+        MeltBolt11Request<String>,
         MeltQuoteBolt11Request,
-        MeltQuoteBolt11Response<Uuid>,
+        MeltQuoteBolt11Response<String>,
         MeltQuoteState,
         MeltMethodSettings,
-        MintBolt11Request<Uuid>,
+        MintBolt11Request<String>,
         MintBolt11Response,
         MintInfo,
         MintQuoteBolt11Request,
-        MintQuoteBolt11Response<Uuid>,
+        MintQuoteBolt11Response<String>,
         MintQuoteState,
         MintMethodSettings,
         MintVersion,
@@ -134,6 +139,45 @@ pub async fn create_mint_router(mint: Arc<Mint>) -> Result<Router> {
     create_mint_router_with_custom_cache(mint, Default::default()).await
 }
 
+async fn cors_middleware(
+    req: axum::http::Request<axum::body::Body>,
+    next: axum::middleware::Next,
+) -> Response {
+    // Handle preflight requests
+    if req.method() == axum::http::Method::OPTIONS {
+        let mut response = Response::new("".into());
+        response
+            .headers_mut()
+            .insert("Access-Control-Allow-Origin", "*".parse().unwrap());
+        response.headers_mut().insert(
+            "Access-Control-Allow-Methods",
+            "GET, POST, OPTIONS".parse().unwrap(),
+        );
+        response.headers_mut().insert(
+            "Access-Control-Allow-Headers",
+            "Content-Type".parse().unwrap(),
+        );
+        return response;
+    }
+
+    // Call the next handler
+    let mut response = next.run(req).await;
+
+    response
+        .headers_mut()
+        .insert("Access-Control-Allow-Origin", "*".parse().unwrap());
+    response.headers_mut().insert(
+        "Access-Control-Allow-Methods",
+        "GET, POST, OPTIONS".parse().unwrap(),
+    );
+    response.headers_mut().insert(
+        "Access-Control-Allow-Headers",
+        "Content-Type".parse().unwrap(),
+    );
+
+    response
+}
+
 /// Create mint [`Router`] with required endpoints for cashu mint with a custom
 /// backend for cache
 pub async fn create_mint_router_with_custom_cache(
@@ -148,18 +192,18 @@ pub async fn create_mint_router_with_custom_cache(
     let v1_router = Router::new()
         .route("/keys", get(get_keys))
         .route("/keysets", get(get_keysets))
-        .route("/keys/:keyset_id", get(get_keyset_pubkeys))
+        .route("/keys/{keyset_id}", get(get_keyset_pubkeys))
         .route("/swap", post(cache_post_swap))
         .route("/mint/quote/bolt11", post(post_mint_bolt11_quote))
         .route(
-            "/mint/quote/bolt11/:quote_id",
+            "/mint/quote/bolt11/{quote_id}",
             get(get_check_mint_bolt11_quote),
         )
         .route("/mint/bolt11", post(cache_post_mint_bolt11))
         .route("/melt/quote/bolt11", post(post_melt_bolt11_quote))
         .route("/ws", get(ws_handler))
         .route(
-            "/melt/quote/bolt11/:quote_id",
+            "/melt/quote/bolt11/{quote_id}",
             get(get_check_melt_bolt11_quote),
         )
         .route("/melt/bolt11", post(cache_post_melt_bolt11))
@@ -167,7 +211,17 @@ pub async fn create_mint_router_with_custom_cache(
         .route("/info", get(get_mint_info))
         .route("/restore", post(post_restore));
 
-    let mint_router = Router::new().nest("/v1", v1_router).with_state(state);
+    let mint_router = Router::new()
+        .nest("/v1", v1_router)
+        .layer(from_fn(cors_middleware));
+
+    #[cfg(feature = "auth")]
+    let mint_router = {
+        let auth_router = create_auth_router(state.clone());
+        mint_router.nest("/v1", auth_router)
+    };
+
+    let mint_router = mint_router.with_state(state);
 
     Ok(mint_router)
 }

+ 172 - 27
crates/cdk-axum/src/router_handlers.rs

@@ -4,6 +4,8 @@ use axum::extract::{Json, Path, State};
 use axum::http::StatusCode;
 use axum::response::{IntoResponse, Response};
 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,
@@ -15,6 +17,8 @@ use paste::paste;
 use tracing::instrument;
 use uuid::Uuid;
 
+#[cfg(feature = "auth")]
+use crate::auth::AuthHeader;
 use crate::ws::main_websocket;
 use crate::MintState;
 
@@ -24,25 +28,29 @@ macro_rules! post_cache_wrapper {
             /// Cache wrapper function for $handler:
             /// Wrap $handler into a function that caches responses using the request as key
             pub async fn [<cache_ $handler>](
+                #[cfg(feature = "auth")] auth: AuthHeader,
                 state: State<MintState>,
                 payload: Json<$request_type>
             ) -> Result<Json<$response_type>, Response> {
                 use std::ops::Deref;
-
                 let json_extracted_payload = payload.deref();
                 let State(mint_state) = state.clone();
                 let cache_key = match mint_state.cache.calculate_key(&json_extracted_payload) {
                     Some(key) => key,
                     None => {
                         // Could not calculate key, just return the handler result
-                        return $handler(state, payload).await;
+                        #[cfg(feature = "auth")]
+                        return $handler(auth, state, payload).await;
+                        #[cfg(not(feature = "auth"))]
+                        return $handler( state, payload).await;
                     }
                 };
-
                 if let Some(cached_response) = mint_state.cache.get::<$response_type>(&cache_key).await {
                     return Ok(Json(cached_response));
                 }
-
+                #[cfg(feature = "auth")]
+                let response = $handler(auth, state, payload).await?;
+                #[cfg(not(feature = "auth"))]
                 let response = $handler(state, payload).await?;
                 mint_state.cache.set(cache_key, &response.deref()).await;
                 Ok(response)
@@ -74,7 +82,10 @@ post_cache_wrapper!(
 /// Get the public keys of the newest mint keyset
 ///
 /// This endpoint returns a dictionary of all supported token values of the mint and their associated public key.
-pub async fn get_keys(State(state): State<MintState>) -> Result<Json<KeysResponse>, Response> {
+#[instrument(skip_all)]
+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)
@@ -98,7 +109,8 @@ pub async fn get_keys(State(state): State<MintState>) -> Result<Json<KeysRespons
 /// Get the public keys of a specific keyset
 ///
 /// Get the public keys of the mint from a specific keyset ID.
-pub async fn get_keyset_pubkeys(
+#[instrument(skip_all, fields(keyset_id = ?keyset_id))]
+pub(crate) async fn get_keyset_pubkeys(
     State(state): State<MintState>,
     Path(keyset_id): Path<Id>,
 ) -> Result<Json<KeysResponse>, Response> {
@@ -122,7 +134,10 @@ pub async fn get_keyset_pubkeys(
 /// Get all active keyset IDs of the mint
 ///
 /// This endpoint returns a list of keysets that the mint currently supports and will accept tokens from.
-pub async fn get_keysets(State(state): State<MintState>) -> Result<Json<KeysetResponse>, Response> {
+#[instrument(skip_all)]
+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)
@@ -137,17 +152,29 @@ pub async fn get_keysets(State(state): State<MintState>) -> Result<Json<KeysetRe
     path = "/mint/quote/bolt11",
     request_body(content = MintQuoteBolt11Request, description = "Request params", content_type = "application/json"),
     responses(
-        (status = 200, description = "Successful response", body = MintQuoteBolt11Response, content_type = "application/json"),
+        (status = 200, description = "Successful response", body = MintQuoteBolt11Response<String>, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
     )
 ))]
 /// Request a quote for minting of new tokens
 ///
 /// Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow.
-pub async fn post_mint_bolt11_quote(
+#[instrument(skip_all, fields(amount = ?payload.amount))]
+pub(crate) async fn post_mint_bolt11_quote(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<MintQuoteBolt11Request>,
 ) -> Result<Json<MintQuoteBolt11Response<Uuid>>, Response> {
+    #[cfg(feature = "auth")]
+    state
+        .mint
+        .verify_auth(
+            auth.into(),
+            &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt11),
+        )
+        .await
+        .map_err(into_response)?;
+
     let quote = state
         .mint
         .get_mint_bolt11_quote(payload)
@@ -165,17 +192,31 @@ pub async fn post_mint_bolt11_quote(
         ("quote_id" = String, description = "The quote ID"),
     ),
     responses(
-        (status = 200, description = "Successful response", body = MintQuoteBolt11Response, content_type = "application/json"),
+        (status = 200, description = "Successful response", body = MintQuoteBolt11Response<String>, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
     )
 ))]
 /// Get mint quote by ID
 ///
 /// Get mint quote state.
-pub async fn get_check_mint_bolt11_quote(
+#[instrument(skip_all, fields(quote_id = ?quote_id))]
+pub(crate) async fn get_check_mint_bolt11_quote(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Path(quote_id): Path<Uuid>,
 ) -> Result<Json<MintQuoteBolt11Response<Uuid>>, Response> {
+    #[cfg(feature = "auth")]
+    {
+        state
+            .mint
+            .verify_auth(
+                auth.into(),
+                &ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
+            )
+            .await
+            .map_err(into_response)?;
+    }
+
     let quote = state
         .mint
         .check_mint_quote(&quote_id)
@@ -188,7 +229,11 @@ pub async fn get_check_mint_bolt11_quote(
     Ok(Json(quote))
 }
 
-pub async fn ws_handler(State(state): State<MintState>, ws: WebSocketUpgrade) -> impl IntoResponse {
+#[instrument(skip_all)]
+pub(crate) async fn ws_handler(
+    State(state): State<MintState>,
+    ws: WebSocketUpgrade,
+) -> impl IntoResponse {
     ws.on_upgrade(|ws| main_websocket(ws, state))
 }
 
@@ -201,16 +246,30 @@ pub async fn ws_handler(State(state): State<MintState>, ws: WebSocketUpgrade) ->
     post,
     context_path = "/v1",
     path = "/mint/bolt11",
-    request_body(content = MintBolt11Request, description = "Request params", content_type = "application/json"),
+    request_body(content = MintBolt11Request<String>, description = "Request params", content_type = "application/json"),
     responses(
         (status = 200, description = "Successful response", body = MintBolt11Response, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
     )
 ))]
-pub async fn post_mint_bolt11(
+#[instrument(skip_all, fields(quote_id = ?payload.quote))]
+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> {
+    #[cfg(feature = "auth")]
+    {
+        state
+            .mint
+            .verify_auth(
+                auth.into(),
+                &ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt11),
+            )
+            .await
+            .map_err(into_response)?;
+    }
+
     let res = state
         .mint
         .process_mint_request(payload)
@@ -229,16 +288,29 @@ pub async fn post_mint_bolt11(
     path = "/melt/quote/bolt11",
     request_body(content = MeltQuoteBolt11Request, description = "Quote params", content_type = "application/json"),
     responses(
-        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"),
+        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response<String>, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
     )
 ))]
 #[instrument(skip_all)]
 /// Request a quote for melting tokens
-pub async fn post_melt_bolt11_quote(
+pub(crate) async fn post_melt_bolt11_quote(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<MeltQuoteBolt11Request>,
 ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> {
+    #[cfg(feature = "auth")]
+    {
+        state
+            .mint
+            .verify_auth(
+                auth.into(),
+                &ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt11),
+            )
+            .await
+            .map_err(into_response)?;
+    }
+
     let quote = state
         .mint
         .get_melt_bolt11_quote(&payload)
@@ -256,18 +328,31 @@ pub async fn post_melt_bolt11_quote(
         ("quote_id" = String, description = "The quote ID"),
     ),
     responses(
-        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"),
+        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response<String>, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
     )
 ))]
 /// Get melt quote by ID
 ///
 /// Get melt quote state.
-#[instrument(skip_all)]
-pub async fn get_check_melt_bolt11_quote(
+#[instrument(skip_all, fields(quote_id = ?quote_id))]
+pub(crate) async fn get_check_melt_bolt11_quote(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Path(quote_id): Path<Uuid>,
 ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> {
+    #[cfg(feature = "auth")]
+    {
+        state
+            .mint
+            .verify_auth(
+                auth.into(),
+                &ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuoteBolt11),
+            )
+            .await
+            .map_err(into_response)?;
+    }
+
     let quote = state
         .mint
         .check_melt_quote(&quote_id)
@@ -284,9 +369,9 @@ pub async fn get_check_melt_bolt11_quote(
     post,
     context_path = "/v1",
     path = "/melt/bolt11",
-    request_body(content = MeltBolt11Request, description = "Melt params", content_type = "application/json"),
+    request_body(content = MeltBolt11Request<String>, description = "Melt params", content_type = "application/json"),
     responses(
-        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"),
+        (status = 200, description = "Successful response", body = MeltQuoteBolt11Response<String>, content_type = "application/json"),
         (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json")
     )
 ))]
@@ -294,10 +379,23 @@ pub async fn get_check_melt_bolt11_quote(
 ///
 /// Requests tokens to be destroyed and sent out via Lightning.
 #[instrument(skip_all)]
-pub async fn post_melt_bolt11(
+pub(crate) async fn post_melt_bolt11(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<MeltBolt11Request<Uuid>>,
 ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, Response> {
+    #[cfg(feature = "auth")]
+    {
+        state
+            .mint
+            .verify_auth(
+                auth.into(),
+                &ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt11),
+            )
+            .await
+            .map_err(into_response)?;
+    }
+
     let res = state
         .mint
         .melt_bolt11(&payload)
@@ -320,10 +418,24 @@ pub async fn post_melt_bolt11(
 /// Check whether a proof is spent already or is pending in a transaction
 ///
 /// Check whether a secret has been spent already or not.
-pub async fn post_check(
+#[instrument(skip_all, fields(y_count = ?payload.ys.len()))]
+pub(crate) async fn post_check(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<CheckStateRequest>,
 ) -> Result<Json<CheckStateResponse>, Response> {
+    #[cfg(feature = "auth")]
+    {
+        state
+            .mint
+            .verify_auth(
+                auth.into(),
+                &ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate),
+            )
+            .await
+            .map_err(into_response)?;
+    }
+
     let state = state.mint.check_state(&payload).await.map_err(|err| {
         tracing::error!("Could not check state of proofs");
         into_response(err)
@@ -341,7 +453,10 @@ pub async fn post_check(
     )
 ))]
 /// Mint information, operator contact information, and other info
-pub async fn get_mint_info(State(state): State<MintState>) -> Result<Json<MintInfo>, Response> {
+#[instrument(skip_all)]
+pub(crate) async fn get_mint_info(
+    State(state): State<MintState>,
+) -> Result<Json<MintInfo>, Response> {
     Ok(Json(
         state
             .mint
@@ -371,10 +486,24 @@ pub async fn get_mint_info(State(state): State<MintState>) -> Result<Json<MintIn
 /// Requests a set of Proofs to be swapped for another set of BlindSignatures.
 ///
 /// This endpoint can be used by Alice to swap a set of proofs before making a payment to Carol. It can then used by Carol to redeem the tokens for new proofs.
-pub async fn post_swap(
+#[instrument(skip_all, fields(inputs_count = ?payload.inputs().len()))]
+pub(crate) async fn post_swap(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<SwapRequest>,
 ) -> Result<Json<SwapResponse>, Response> {
+    #[cfg(feature = "auth")]
+    {
+        state
+            .mint
+            .verify_auth(
+                auth.into(),
+                &ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
+            )
+            .await
+            .map_err(into_response)?;
+    }
+
     let swap_response = state
         .mint
         .process_swap_request(payload)
@@ -383,6 +512,7 @@ pub async fn post_swap(
             tracing::error!("Could not process swap request: {}", err);
             into_response(err)
         })?;
+
     Ok(Json(swap_response))
 }
 
@@ -397,10 +527,24 @@ pub async fn post_swap(
     )
 ))]
 /// Restores blind signature for a set of outputs.
-pub async fn post_restore(
+#[instrument(skip_all, fields(outputs_count = ?payload.outputs.len()))]
+pub(crate) async fn post_restore(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<RestoreRequest>,
 ) -> Result<Json<RestoreResponse>, Response> {
+    #[cfg(feature = "auth")]
+    {
+        state
+            .mint
+            .verify_auth(
+                auth.into(),
+                &ProtectedEndpoint::new(Method::Post, RoutePath::Restore),
+            )
+            .await
+            .map_err(into_response)?;
+    }
+
     let restore_response = state.mint.restore(payload).await.map_err(|err| {
         tracing::error!("Could not process restore: {}", err);
         into_response(err)
@@ -409,7 +553,8 @@ pub async fn post_restore(
     Ok(Json(restore_response))
 }
 
-pub fn into_response<T>(error: T) -> Response
+#[instrument(skip_all)]
+pub(crate) fn into_response<T>(error: T) -> Response
 where
     T: Into<ErrorResponse>,
 {

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

@@ -46,7 +46,6 @@ pub struct WsContext {
 ///
 /// For simplicity sake this function will spawn tasks for each subscription and
 /// keep them in a hashmap, and will have a single subscriber for all of them.
-#[allow(clippy::incompatible_msrv)]
 pub async fn main_websocket(mut socket: WebSocket, state: MintState) {
     let (publisher, mut subscriber) = mpsc::channel(100);
     let mut context = WsContext {
@@ -75,7 +74,7 @@ pub async fn main_websocket(mut socket: WebSocket, state: MintState) {
                     }
                 };
 
-          if let Err(err)= socket.send(Message::Text(message)).await {
+          if let Err(err)= socket.send(Message::Text(message.into())).await {
                 tracing::error!("Could not send websocket message: {}", err);
                 break;
           }
@@ -92,7 +91,7 @@ pub async fn main_websocket(mut socket: WebSocket, state: MintState) {
                 match process(&mut context, request).await {
                     Ok(result) => {
                         if let Err(err) = socket
-                            .send(Message::Text(result.to_string()))
+                            .send(Message::Text(result.to_string().into()))
                             .await
                         {
                             tracing::error!("Could not send request: {}", err);

+ 26 - 29
crates/cdk-cli/Cargo.toml

@@ -1,42 +1,39 @@
 [package]
 name = "cdk-cli"
-version = "0.7.1"
-edition = "2021"
+version.workspace = true
 authors = ["CDK Developers"]
 description = "Cashu cli wallet built on CDK"
-license = "MIT"
-homepage = "https://github.com/cashubtc/cdk"
-repository = "https://github.com/cashubtc/cdk.git"
-rust-version = "1.63.0" # MSRV
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+readme = "README.md"
 
-# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+[features]
+sqlcipher = ["cdk-sqlite/sqlcipher"]
+# MSRV is not tracked with redb enabled
+redb = ["dep:cdk-redb"]
 
 [dependencies]
-anyhow = "1"
-bip39 = { version = "2.0", features = ["rand"] }
-cdk = { path = "../cdk", version = "0.7.1", default-features = false, features = ["wallet"]}
-cdk-redb = { path = "../cdk-redb", version = "0.7.1", default-features = false, features = ["wallet"] }
-cdk-sqlite = { path = "../cdk-sqlite", version = "0.7.1", default-features = false, features = ["wallet"] }
-clap = { version = "~4.0.32", features = ["derive"] }
-serde = { version = "1", default-features = false, features = ["derive"] }
-serde_json = "1"
-tokio = { version = "1", default-features = false }
-tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] }
-tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
-home = "0.5.5"
+anyhow.workspace = true
+bip39.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"] }
+clap.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+tokio.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
+home.workspace = true
 nostr-sdk = { version = "0.35.0", default-features = false, features = [
     "nip04",
     "nip44",
     "nip59"
 ]}
-reqwest = { version = "0.12", default-features = false, features = [
-    "json",
-    "rustls-tls",
-    "rustls-tls-native-roots",
-    "socks",
-]}
-url = "2.3"
-
-# Indirect dep
-base64ct = "=1.6.0"
+reqwest.workspace = true
+url.workspace = true
+serde_with.workspace = true
 

+ 67 - 37
crates/cdk-cli/src/main.rs

@@ -8,8 +8,8 @@ use bip39::rand::{thread_rng, Rng};
 use bip39::Mnemonic;
 use cdk::cdk_database;
 use cdk::cdk_database::WalletDatabase;
-use cdk::wallet::client::HttpClient;
-use cdk::wallet::{MultiMintWallet, Wallet};
+use cdk::wallet::{HttpClient, MultiMintWallet, Wallet, WalletBuilder};
+#[cfg(feature = "redb")]
 use cdk_redb::WalletRedbDatabase;
 use cdk_sqlite::WalletSqliteDatabase;
 use clap::{Parser, Subcommand};
@@ -17,7 +17,9 @@ use tracing::Level;
 use tracing_subscriber::EnvFilter;
 use url::Url;
 
+mod nostr_storage;
 mod sub_commands;
+mod token_storage;
 
 const DEFAULT_WORK_DIR: &str = ".cdk-cli";
 
@@ -31,6 +33,10 @@ struct Cli {
     /// Database engine to use (sqlite/redb)
     #[arg(short, long, default_value = "sqlite")]
     engine: String,
+    /// Database password for sqlcipher
+    #[cfg(feature = "sqlcipher")]
+    #[arg(long)]
+    password: Option<String>,
     /// Path to working dir
     #[arg(short, long)]
     work_dir: Option<PathBuf>,
@@ -78,6 +84,12 @@ enum Commands {
     PayRequest(sub_commands::pay_request::PayRequestSubCommand),
     /// Create Payment request
     CreateRequest(sub_commands::create_request::CreateRequestSubCommand),
+    /// Mint blind auth proofs
+    MintBlindAuth(sub_commands::mint_blind_auth::MintBlindAuthSubCommand),
+    /// Cat login with username/password
+    CatLogin(sub_commands::cat_login::CatLoginSubCommand),
+    /// Cat login with device code flow
+    CatDeviceLogin(sub_commands::cat_device_login::CatDeviceLoginSubCommand),
 }
 
 #[tokio::main]
@@ -106,16 +118,28 @@ async fn main() -> Result<()> {
         match args.engine.as_str() {
             "sqlite" => {
                 let sql_path = work_dir.join("cdk-cli.sqlite");
+                #[cfg(not(feature = "sqlcipher"))]
                 let sql = WalletSqliteDatabase::new(&sql_path).await?;
-
-                sql.migrate().await;
+                #[cfg(feature = "sqlcipher")]
+                let sql = {
+                    match args.password {
+                        Some(pass) => WalletSqliteDatabase::new(&sql_path, pass).await?,
+                        None => bail!("Missing database password"),
+                    }
+                };
 
                 Arc::new(sql)
             }
             "redb" => {
-                let redb_path = work_dir.join("cdk-cli.redb");
-
-                Arc::new(WalletRedbDatabase::new(&redb_path)?)
+                #[cfg(feature = "redb")]
+                {
+                    let redb_path = work_dir.join("cdk-cli.redb");
+                    Arc::new(WalletRedbDatabase::new(&redb_path)?)
+                }
+                #[cfg(not(feature = "redb"))]
+                {
+                    bail!("redb feature not enabled");
+                }
             }
             _ => bail!("Unknown DB engine"),
         };
@@ -139,28 +163,32 @@ async fn main() -> Result<()> {
             mnemonic
         }
     };
+    let seed = mnemonic.to_seed_normalized("");
 
     let mut wallets: Vec<Wallet> = Vec::new();
 
     let mints = localstore.get_mints().await?;
 
     for (mint_url, _) in mints {
-        let mut wallet = Wallet::new(
-            &mint_url.to_string(),
-            cdk::nuts::CurrencyUnit::Sat,
-            localstore.clone(),
-            &mnemonic.to_seed_normalized(""),
-            None,
-        )?;
+        let mut builder = WalletBuilder::new()
+            .mint_url(mint_url.clone())
+            .unit(cdk::nuts::CurrencyUnit::Sat)
+            .localstore(localstore.clone())
+            .seed(&mnemonic.to_seed_normalized(""));
+
         if let Some(proxy_url) = args.proxy.as_ref() {
             let http_client = HttpClient::with_proxy(mint_url, proxy_url.clone(), None, true)?;
-            wallet.set_client(http_client);
+            builder = builder.client(http_client);
         }
 
+        let wallet = builder.build()?;
+
+        wallet.get_mint_info().await?;
+
         wallets.push(wallet);
     }
 
-    let multi_mint_wallet = MultiMintWallet::new(wallets);
+    let multi_mint_wallet = MultiMintWallet::new(localstore, Arc::new(seed), wallets);
 
     match &args.command {
         Commands::DecodeToken(sub_command_args) => {
@@ -171,13 +199,7 @@ async fn main() -> Result<()> {
             sub_commands::melt::pay(&multi_mint_wallet, sub_command_args).await
         }
         Commands::Receive(sub_command_args) => {
-            sub_commands::receive::receive(
-                &multi_mint_wallet,
-                localstore,
-                &mnemonic.to_seed_normalized(""),
-                sub_command_args,
-            )
-            .await
+            sub_commands::receive::receive(&multi_mint_wallet, sub_command_args, &work_dir).await
         }
         Commands::Send(sub_command_args) => {
             sub_commands::send::send(&multi_mint_wallet, sub_command_args).await
@@ -189,13 +211,7 @@ async fn main() -> Result<()> {
             sub_commands::mint_info::mint_info(args.proxy, sub_command_args).await
         }
         Commands::Mint(sub_command_args) => {
-            sub_commands::mint::mint(
-                &multi_mint_wallet,
-                &mnemonic.to_seed_normalized(""),
-                localstore,
-                sub_command_args,
-            )
-            .await
+            sub_commands::mint::mint(&multi_mint_wallet, sub_command_args).await
         }
         Commands::MintPending => {
             sub_commands::pending_mints::mint_pending(&multi_mint_wallet).await
@@ -204,13 +220,7 @@ async fn main() -> Result<()> {
             sub_commands::burn::burn(&multi_mint_wallet, sub_command_args).await
         }
         Commands::Restore(sub_command_args) => {
-            sub_commands::restore::restore(
-                &multi_mint_wallet,
-                &mnemonic.to_seed_normalized(""),
-                localstore,
-                sub_command_args,
-            )
-            .await
+            sub_commands::restore::restore(&multi_mint_wallet, sub_command_args).await
         }
         Commands::UpdateMintUrl(sub_command_args) => {
             sub_commands::update_mint_url::update_mint_url(&multi_mint_wallet, sub_command_args)
@@ -228,5 +238,25 @@ async fn main() -> Result<()> {
         Commands::CreateRequest(sub_command_args) => {
             sub_commands::create_request::create_request(&multi_mint_wallet, sub_command_args).await
         }
+        Commands::MintBlindAuth(sub_command_args) => {
+            sub_commands::mint_blind_auth::mint_blind_auth(
+                &multi_mint_wallet,
+                sub_command_args,
+                &work_dir,
+            )
+            .await
+        }
+        Commands::CatLogin(sub_command_args) => {
+            sub_commands::cat_login::cat_login(&multi_mint_wallet, sub_command_args, &work_dir)
+                .await
+        }
+        Commands::CatDeviceLogin(sub_command_args) => {
+            sub_commands::cat_device_login::cat_device_login(
+                &multi_mint_wallet,
+                sub_command_args,
+                &work_dir,
+            )
+            .await
+        }
     }
 }

+ 37 - 0
crates/cdk-cli/src/nostr_storage.rs

@@ -0,0 +1,37 @@
+use std::fs;
+use std::path::Path;
+
+use anyhow::Result;
+use cdk::nuts::PublicKey;
+use cdk::util::hex;
+
+/// Stores the last checked time for a nostr key in a file
+pub async fn store_nostr_last_checked(
+    work_dir: &Path,
+    verifying_key: &PublicKey,
+    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));
+
+    fs::write(file_path, last_checked.to_string())?;
+
+    Ok(())
+}
+
+/// Gets the last checked time for a nostr key from a file
+pub async fn get_nostr_last_checked(
+    work_dir: &Path,
+    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));
+
+    match fs::read_to_string(file_path) {
+        Ok(content) => {
+            let timestamp = content.trim().parse::<u32>()?;
+            Ok(Some(timestamp))
+        }
+        Err(_) => Ok(None),
+    }
+}

+ 194 - 0
crates/cdk-cli/src/sub_commands/cat_device_login.rs

@@ -0,0 +1,194 @@
+use std::path::Path;
+use std::str::FromStr;
+use std::time::Duration;
+
+use anyhow::{anyhow, Result};
+use cdk::mint_url::MintUrl;
+use cdk::nuts::{CurrencyUnit, MintInfo};
+use cdk::wallet::types::WalletKey;
+use cdk::wallet::MultiMintWallet;
+use cdk::OidcClient;
+use clap::Args;
+use serde::{Deserialize, Serialize};
+use tokio::time::sleep;
+
+use crate::token_storage;
+
+#[derive(Args, Serialize, Deserialize)]
+pub struct CatDeviceLoginSubCommand {
+    /// Mint url
+    mint_url: MintUrl,
+    /// Currency unit e.g. sat
+    #[arg(default_value = "sat")]
+    #[arg(short, long)]
+    unit: String,
+    /// Client ID for OIDC authentication
+    #[arg(default_value = "cashu-client")]
+    #[arg(long)]
+    client_id: String,
+}
+
+pub async fn cat_device_login(
+    multi_mint_wallet: &MultiMintWallet,
+    sub_command_args: &CatDeviceLoginSubCommand,
+    work_dir: &Path,
+) -> Result<()> {
+    let mint_url = sub_command_args.mint_url.clone();
+    let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
+
+    let wallet = match multi_mint_wallet
+        .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
+        .await
+    {
+        Some(wallet) => wallet.clone(),
+        None => {
+            multi_mint_wallet
+                .create_and_add_wallet(&mint_url.to_string(), unit, None)
+                .await?
+        }
+    };
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await?
+        .ok_or(anyhow!("Mint info not found"))?;
+
+    let (access_token, refresh_token) =
+        get_device_code_token(&mint_info, &sub_command_args.client_id).await;
+
+    // Save tokens to file in work directory
+    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);
+    } else {
+        println!("Tokens saved to work directory");
+    }
+
+    // Print a cute ASCII cat
+    println!("\nAuthentication successful! 🎉\n");
+    println!("\nYour tokens:");
+    println!("access_token: {}", access_token);
+    println!("refresh_token: {}", refresh_token);
+
+    Ok(())
+}
+
+async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String, String) {
+    let openid_discovery = mint_info
+        .nuts
+        .nut21
+        .clone()
+        .expect("Nut21 defined")
+        .openid_discovery;
+
+    let oidc_client = OidcClient::new(openid_discovery);
+
+    // Get the OIDC configuration
+    let oidc_config = oidc_client
+        .get_oidc_config()
+        .await
+        .expect("Failed to get OIDC config");
+
+    // Get the device authorization endpoint
+    let device_auth_url = oidc_config.device_authorization_endpoint;
+
+    // Make the device code request
+    let client = reqwest::Client::new();
+    let device_code_response = client
+        .post(device_auth_url)
+        .form(&[("client_id", client_id)])
+        .send()
+        .await
+        .expect("Failed to send device code request");
+
+    let device_code_data: serde_json::Value = device_code_response
+        .json()
+        .await
+        .expect("Failed to parse device code response");
+
+    let device_code = device_code_data["device_code"]
+        .as_str()
+        .expect("No device code in response");
+
+    let user_code = device_code_data["user_code"]
+        .as_str()
+        .expect("No user code in response");
+
+    let verification_uri = device_code_data["verification_uri"]
+        .as_str()
+        .expect("No verification URI in response");
+
+    let verification_uri_complete = device_code_data["verification_uri_complete"]
+        .as_str()
+        .unwrap_or(verification_uri);
+
+    let interval = device_code_data["interval"].as_u64().unwrap_or(5);
+
+    println!("\nTo login, visit: {}", verification_uri);
+    println!("And enter code: {}\n", user_code);
+
+    if verification_uri_complete != verification_uri {
+        println!(
+            "Or visit this URL directly: {}\n",
+            verification_uri_complete
+        );
+    }
+
+    // Poll for the token
+    let token_url = oidc_config.token_endpoint;
+
+    loop {
+        sleep(Duration::from_secs(interval)).await;
+
+        let token_response = client
+            .post(&token_url)
+            .form(&[
+                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
+                ("device_code", device_code),
+                ("client_id", client_id),
+            ])
+            .send()
+            .await
+            .expect("Failed to send token request");
+
+        if token_response.status().is_success() {
+            let token_data: serde_json::Value = token_response
+                .json()
+                .await
+                .expect("Failed to parse token response");
+
+            let access_token = token_data["access_token"]
+                .as_str()
+                .expect("No access token in response")
+                .to_string();
+
+            let refresh_token = token_data["refresh_token"]
+                .as_str()
+                .expect("No refresh token in response")
+                .to_string();
+
+            return (access_token, refresh_token);
+        } else {
+            let error_data: serde_json::Value = token_response
+                .json()
+                .await
+                .expect("Failed to parse error response");
+
+            let error = error_data["error"].as_str().unwrap_or("unknown_error");
+
+            // If the user hasn't completed the flow yet, continue polling
+            if error == "authorization_pending" || error == "slow_down" {
+                if error == "slow_down" {
+                    // If we're polling too fast, slow down
+                    sleep(Duration::from_secs(interval + 5)).await;
+                }
+                println!("Waiting for user to complete authentication...");
+                continue;
+            } else {
+                // For other errors, exit with an error message
+                panic!("Authentication failed: {}", error);
+            }
+        }
+    }
+}

+ 138 - 0
crates/cdk-cli/src/sub_commands/cat_login.rs

@@ -0,0 +1,138 @@
+use std::path::Path;
+use std::str::FromStr;
+
+use anyhow::{anyhow, Result};
+use cdk::mint_url::MintUrl;
+use cdk::nuts::{CurrencyUnit, MintInfo};
+use cdk::wallet::types::WalletKey;
+use cdk::wallet::MultiMintWallet;
+use cdk::OidcClient;
+use clap::Args;
+use serde::{Deserialize, Serialize};
+
+use crate::token_storage;
+
+#[derive(Args, Serialize, Deserialize)]
+pub struct CatLoginSubCommand {
+    /// Mint url
+    mint_url: MintUrl,
+    /// Username
+    username: String,
+    /// Password
+    password: String,
+    /// Currency unit e.g. sat
+    #[arg(default_value = "sat")]
+    #[arg(short, long)]
+    unit: String,
+    /// Client ID for OIDC authentication
+    #[arg(default_value = "cashu-client")]
+    #[arg(long)]
+    client_id: String,
+}
+
+pub async fn cat_login(
+    multi_mint_wallet: &MultiMintWallet,
+    sub_command_args: &CatLoginSubCommand,
+    work_dir: &Path,
+) -> Result<()> {
+    let mint_url = sub_command_args.mint_url.clone();
+    let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
+
+    let wallet = match multi_mint_wallet
+        .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
+        .await
+    {
+        Some(wallet) => wallet.clone(),
+        None => {
+            multi_mint_wallet
+                .create_and_add_wallet(&mint_url.to_string(), unit, None)
+                .await?
+        }
+    };
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await?
+        .ok_or(anyhow!("Mint info not found"))?;
+
+    let (access_token, refresh_token) = get_access_token(
+        &mint_info,
+        &sub_command_args.client_id,
+        &sub_command_args.username,
+        &sub_command_args.password,
+    )
+    .await;
+
+    // Save tokens to file in work directory
+    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);
+    } else {
+        println!("Tokens saved to work directory");
+    }
+
+    println!("\nAuthentication successful! 🎉\n");
+    println!("\nYour tokens:");
+    println!("access_token: {}", access_token);
+    println!("refresh_token: {}", refresh_token);
+
+    Ok(())
+}
+
+async fn get_access_token(
+    mint_info: &MintInfo,
+    client_id: &str,
+    user: &str,
+    password: &str,
+) -> (String, String) {
+    let openid_discovery = mint_info
+        .nuts
+        .nut21
+        .clone()
+        .expect("Nut21 defined")
+        .openid_discovery;
+
+    let oidc_client = OidcClient::new(openid_discovery);
+
+    // Get the token endpoint from the OIDC configuration
+    let token_url = oidc_client
+        .get_oidc_config()
+        .await
+        .expect("Failed to get OIDC config")
+        .token_endpoint;
+
+    // Create the request parameters
+    let params = [
+        ("grant_type", "password"),
+        ("client_id", client_id),
+        ("username", user),
+        ("password", password),
+    ];
+
+    // Make the token request directly
+    let client = reqwest::Client::new();
+    let response = client
+        .post(token_url)
+        .form(&params)
+        .send()
+        .await
+        .expect("Failed to send token request");
+
+    let token_response: serde_json::Value = response
+        .json()
+        .await
+        .expect("Failed to parse token response");
+
+    let access_token = token_response["access_token"]
+        .as_str()
+        .expect("No access token in response")
+        .to_string();
+
+    let refresh_token = token_response["refresh_token"]
+        .as_str()
+        .expect("No refresh token in response")
+        .to_string();
+
+    (access_token, refresh_token)
+}

+ 2 - 2
crates/cdk-cli/src/sub_commands/create_request.rs

@@ -1,7 +1,7 @@
 use anyhow::Result;
 use cdk::nuts::nut18::TransportType;
 use cdk::nuts::{CurrencyUnit, PaymentRequest, PaymentRequestPayload, Token, Transport};
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::{MultiMintWallet, ReceiveOptions};
 use clap::Args;
 use nostr_sdk::nips::nip19::Nip19Profile;
 use nostr_sdk::prelude::*;
@@ -83,7 +83,7 @@ pub async fn create_request(
                 let token = Token::new(payload.mint, payload.proofs, payload.memo, payload.unit);
 
                 let amount = multi_mint_wallet
-                    .receive(&token.to_string(), &[], &[])
+                    .receive(&token.to_string(), ReceiveOptions::default())
                     .await?;
 
                 println!("Received {}", amount);

+ 33 - 20
crates/cdk-cli/src/sub_commands/melt.rs

@@ -57,39 +57,52 @@ pub async fn pay(
     stdin.read_line(&mut user_input)?;
     let bolt11 = Bolt11Invoice::from_str(user_input.trim())?;
 
-    let mut options: Option<MeltOptions> = None;
+    let available_funds =
+        <cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT;
+
+    // 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"
+            } else {
+                "amountless invoice"
+            }
+        );
 
-    if sub_command_args.mpp {
-        println!("Enter the amount you would like to pay in sats, for a mpp payment.");
         let mut user_input = String::new();
-        let stdin = io::stdin();
-        io::stdout().flush().unwrap();
-        stdin.read_line(&mut user_input)?;
+        io::stdout().flush()?;
+        io::stdin().read_line(&mut user_input)?;
 
-        let user_amount = user_input.trim_end().parse::<u64>()?;
+        let user_amount = user_input.trim_end().parse::<u64>()? * MSAT_IN_SAT;
 
-        if user_amount
-            .gt(&(<cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT))
-        {
+        if user_amount > available_funds {
             bail!("Not enough funds");
         }
 
-        options = Some(MeltOptions::new_mpp(user_amount * MSAT_IN_SAT));
-    } else if bolt11
-        .amount_milli_satoshis()
-        .unwrap()
-        .gt(&(<cdk::Amount as Into<u64>>::into(mints_amounts[mint_number].1) * MSAT_IN_SAT))
-    {
-        bail!("Not enough funds");
-    }
+        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");
+        }
+        None
+    };
 
+    // Process payment
     let quote = wallet.melt_quote(bolt11.to_string(), options).await?;
-
     println!("{:?}", quote);
 
     let melt = wallet.melt(&quote.id).await?;
-
     println!("Paid invoice: {}", melt.state);
+
     if let Some(preimage) = melt.preimage {
         println!("Payment preimage: {}", preimage);
     }

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

@@ -1,14 +1,12 @@
 use std::str::FromStr;
-use std::sync::Arc;
 
 use anyhow::{anyhow, Result};
 use cdk::amount::SplitTarget;
-use cdk::cdk_database::{Error, WalletDatabase};
 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, Wallet, WalletSubscription};
+use cdk::wallet::{MultiMintWallet, WalletSubscription};
 use cdk::Amount;
 use clap::Args;
 use serde::{Deserialize, Serialize};
@@ -32,8 +30,6 @@ pub struct MintSubCommand {
 
 pub async fn mint(
     multi_mint_wallet: &MultiMintWallet,
-    seed: &[u8],
-    localstore: Arc<dyn WalletDatabase<Err = Error> + Sync + Send>,
     sub_command_args: &MintSubCommand,
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
@@ -46,10 +42,10 @@ pub async fn mint(
     {
         Some(wallet) => wallet.clone(),
         None => {
-            let wallet = Wallet::new(&mint_url.to_string(), unit, localstore, seed, None)?;
-
-            multi_mint_wallet.add_wallet(wallet.clone()).await;
-            wallet
+            tracing::debug!("Wallet does not exist creating..");
+            multi_mint_wallet
+                .create_and_add_wallet(&mint_url.to_string(), unit, None)
+                .await?
         }
     };
 

+ 204 - 0
crates/cdk-cli/src/sub_commands/mint_blind_auth.rs

@@ -0,0 +1,204 @@
+use std::path::Path;
+use std::str::FromStr;
+
+use anyhow::{anyhow, Result};
+use cdk::mint_url::MintUrl;
+use cdk::nuts::{CurrencyUnit, MintInfo};
+use cdk::wallet::types::WalletKey;
+use cdk::wallet::MultiMintWallet;
+use cdk::{Amount, OidcClient};
+use clap::Args;
+use serde::{Deserialize, Serialize};
+
+use crate::token_storage;
+
+#[derive(Args, Serialize, Deserialize)]
+pub struct MintBlindAuthSubCommand {
+    /// Mint url
+    mint_url: MintUrl,
+    /// Amount
+    amount: Option<u64>,
+    /// Cat (access token)
+    #[arg(long)]
+    cat: Option<String>,
+    /// Currency unit e.g. sat
+    #[arg(default_value = "sat")]
+    #[arg(short, long)]
+    unit: String,
+}
+
+pub async fn mint_blind_auth(
+    multi_mint_wallet: &MultiMintWallet,
+    sub_command_args: &MintBlindAuthSubCommand,
+    work_dir: &Path,
+) -> Result<()> {
+    let mint_url = sub_command_args.mint_url.clone();
+    let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
+
+    let wallet = match multi_mint_wallet
+        .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone()))
+        .await
+    {
+        Some(wallet) => wallet.clone(),
+        None => {
+            multi_mint_wallet
+                .create_and_add_wallet(&mint_url.to_string(), unit, None)
+                .await?
+        }
+    };
+
+    wallet.get_mint_info().await?;
+
+    // Try to get the token from the provided argument or from the stored file
+    let cat = match &sub_command_args.cat {
+        Some(token) => token.clone(),
+        None => {
+            // Try to load from file
+            match token_storage::get_token_for_mint(work_dir, &mint_url).await {
+                Ok(Some(token_data)) => {
+                    println!("Using access token from cashu_tokens.json");
+                    token_data.access_token
+                }
+                Ok(None) => {
+                    return Err(anyhow::anyhow!(
+                        "No access token provided and no token found in cashu_tokens.json for this mint"
+                    ));
+                }
+                Err(e) => {
+                    return Err(anyhow::anyhow!(
+                        "Failed to read token from cashu_tokens.json: {}",
+                        e
+                    ));
+                }
+            }
+        }
+    };
+
+    // Try to set the access token
+    if let Err(err) = wallet.set_cat(cat.clone()).await {
+        tracing::error!("Could not set cat: {}", err);
+
+        // Try to refresh the token if we have a refresh token
+        if let Ok(Some(token_data)) = token_storage::get_token_for_mint(work_dir, &mint_url).await {
+            println!("Attempting to refresh the access token...");
+
+            // Get the mint info to access OIDC configuration
+            if let Some(mint_info) = wallet.get_mint_info().await? {
+                match refresh_access_token(&mint_info, &token_data.refresh_token).await {
+                    Ok((new_access_token, new_refresh_token)) => {
+                        println!("Successfully refreshed access token");
+
+                        // Save the new tokens
+                        if let Err(e) = token_storage::save_tokens(
+                            work_dir,
+                            &mint_url,
+                            &new_access_token,
+                            &new_refresh_token,
+                        )
+                        .await
+                        {
+                            println!("Warning: Failed to save refreshed tokens: {}", e);
+                        }
+
+                        // Try setting the new access token
+                        if let Err(err) = wallet.set_cat(new_access_token).await {
+                            tracing::error!("Could not set refreshed cat: {}", err);
+                            return Err(anyhow::anyhow!(
+                                "Authentication failed even after token refresh"
+                            ));
+                        }
+
+                        // Set the refresh token
+                        wallet.set_refresh_token(new_refresh_token).await?;
+                    }
+                    Err(e) => {
+                        tracing::error!("Failed to refresh token: {}", e);
+                        return Err(anyhow::anyhow!("Failed to refresh access token: {}", e));
+                    }
+                }
+            }
+        } else {
+            return Err(anyhow::anyhow!(
+                "Authentication failed and no refresh token available"
+            ));
+        }
+    } else {
+        // If we have a refresh token, set it
+        if let Ok(Some(token_data)) = token_storage::get_token_for_mint(work_dir, &mint_url).await {
+            tracing::info!("Attempting to use refresh access token to refresh auth token");
+            wallet.set_refresh_token(token_data.refresh_token).await?;
+            wallet.refresh_access_token().await?;
+        }
+    }
+
+    println!("Attempting to mint blind auth");
+
+    let amount = match sub_command_args.amount {
+        Some(amount) => amount,
+        None => {
+            let mint_info = wallet
+                .get_mint_info()
+                .await?
+                .ok_or(anyhow!("Unknown mint info"))?;
+            mint_info
+                .bat_max_mint()
+                .ok_or(anyhow!("Unknown max bat mint"))?
+        }
+    };
+
+    let proofs = wallet.mint_blind_auth(Amount::from(amount)).await?;
+
+    println!("Received {} auth proofs for mint {mint_url}", proofs.len());
+
+    Ok(())
+}
+
+async fn refresh_access_token(
+    mint_info: &MintInfo,
+    refresh_token: &str,
+) -> Result<(String, String)> {
+    let openid_discovery = mint_info
+        .nuts
+        .nut21
+        .clone()
+        .ok_or_else(|| anyhow::anyhow!("OIDC discovery information not available"))?
+        .openid_discovery;
+
+    let oidc_client = OidcClient::new(openid_discovery);
+
+    // Get the token endpoint from the OIDC configuration
+    let token_url = oidc_client.get_oidc_config().await?.token_endpoint;
+
+    // Create the request parameters for token refresh
+    let params = [
+        ("grant_type", "refresh_token"),
+        ("refresh_token", refresh_token),
+        ("client_id", "cashu-client"), // Using default client ID
+    ];
+
+    // Make the token refresh request
+    let client = reqwest::Client::new();
+    let response = client.post(token_url).form(&params).send().await?;
+
+    if !response.status().is_success() {
+        return Err(anyhow::anyhow!(
+            "Token refresh failed with status: {}",
+            response.status()
+        ));
+    }
+
+    let token_response: serde_json::Value = response.json().await?;
+
+    let access_token = token_response["access_token"]
+        .as_str()
+        .ok_or_else(|| anyhow::anyhow!("No access token in refresh response"))?
+        .to_string();
+
+    // Get the new refresh token or use the old one if not provided
+    let new_refresh_token = token_response["refresh_token"]
+        .as_str()
+        .unwrap_or(refresh_token)
+        .to_string();
+
+    Ok((access_token, new_refresh_token))
+}

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

@@ -1,6 +1,6 @@
 use anyhow::Result;
 use cdk::mint_url::MintUrl;
-use cdk::wallet::client::MintConnector;
+use cdk::wallet::MintConnector;
 use cdk::HttpClient;
 use clap::Args;
 use url::Url;
@@ -14,7 +14,7 @@ pub async fn mint_info(proxy: Option<Url>, sub_command_args: &MintInfoSubcommand
     let mint_url = sub_command_args.mint_url.clone();
     let client = match proxy {
         Some(proxy) => HttpClient::with_proxy(mint_url, proxy, None, true)?,
-        None => HttpClient::new(mint_url),
+        None => HttpClient::new(mint_url, None),
     };
 
     let info = client.get_mint_info().await?;

+ 3 - 0
crates/cdk-cli/src/sub_commands/mod.rs

@@ -1,5 +1,7 @@
 pub mod balance;
 pub mod burn;
+pub mod cat_device_login;
+pub mod cat_login;
 pub mod check_spent;
 pub mod create_request;
 pub mod decode_request;
@@ -7,6 +9,7 @@ pub mod decode_token;
 pub mod list_mint_proofs;
 pub mod melt;
 pub mod mint;
+pub mod mint_blind_auth;
 pub mod mint_info;
 pub mod pay_request;
 pub mod pending_mints;

+ 9 - 11
crates/cdk-cli/src/sub_commands/pay_request.rs

@@ -1,10 +1,9 @@
 use std::io::{self, Write};
 
 use anyhow::{anyhow, Result};
-use cdk::amount::SplitTarget;
 use cdk::nuts::nut18::TransportType;
 use cdk::nuts::{PaymentRequest, PaymentRequestPayload};
-use cdk::wallet::{MultiMintWallet, SendKind};
+use cdk::wallet::{MultiMintWallet, SendOptions};
 use clap::Args;
 use nostr_sdk::nips::nip19::Nip19Profile;
 use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys};
@@ -81,17 +80,16 @@ pub async fn pay_request(
         })
         .ok_or(anyhow!("No supported transport method found"))?;
 
-    let proofs = matching_wallet
-        .send(
+    let prepared_send = matching_wallet
+        .prepare_send(
             amount,
-            None,
-            None,
-            &SplitTarget::default(),
-            &SendKind::default(),
-            true,
+            SendOptions {
+                include_fee: true,
+                ..Default::default()
+            },
         )
-        .await?
-        .proofs();
+        .await?;
+    let proofs = matching_wallet.send(prepared_send, None).await?.proofs();
 
     let payload = PaymentRequestPayload {
         id: payment_request.payment_id.clone(),

+ 31 - 27
crates/cdk-cli/src/sub_commands/receive.rs

@@ -1,19 +1,20 @@
 use std::collections::HashSet;
+use std::path::Path;
 use std::str::FromStr;
-use std::sync::Arc;
 
 use anyhow::{anyhow, Result};
-use cdk::cdk_database::{self, WalletDatabase};
 use cdk::nuts::{SecretKey, Token};
 use cdk::util::unix_time;
 use cdk::wallet::multi_mint_wallet::MultiMintWallet;
 use cdk::wallet::types::WalletKey;
-use cdk::wallet::Wallet;
+use cdk::wallet::ReceiveOptions;
 use cdk::Amount;
 use clap::Args;
 use nostr_sdk::nips::nip04;
 use nostr_sdk::{Filter, Keys, Kind, Timestamp};
 
+use crate::nostr_storage;
+
 #[derive(Args)]
 pub struct ReceiveSubCommand {
     /// Cashu Token
@@ -27,7 +28,7 @@ pub struct ReceiveSubCommand {
     /// Nostr relay
     #[arg(short, long, action = clap::ArgAction::Append)]
     relay: Vec<String>,
-    /// Unix time to to query nostr from
+    /// Unix time to query nostr from
     #[arg(long)]
     since: Option<u64>,
     /// Preimage
@@ -37,9 +38,8 @@ pub struct ReceiveSubCommand {
 
 pub async fn receive(
     multi_mint_wallet: &MultiMintWallet,
-    localstore: Arc<dyn WalletDatabase<Err = cdk_database::Error> + Send + Sync>,
-    seed: &[u8],
     sub_command_args: &ReceiveSubCommand,
+    work_dir: &Path,
 ) -> Result<()> {
     let mut signing_keys = Vec::new();
 
@@ -64,8 +64,6 @@ pub async fn receive(
         Some(token_str) => {
             receive_token(
                 multi_mint_wallet,
-                localstore,
-                seed,
                 token_str,
                 &signing_keys,
                 &sub_command_args.preimage,
@@ -89,18 +87,23 @@ pub async fn receive(
             signing_keys.push(nostr_key.clone());
 
             let relays = sub_command_args.relay.clone();
-            let since = localstore
-                .get_nostr_last_checked(&nostr_key.public_key())
-                .await?;
+            let since =
+                nostr_storage::get_nostr_last_checked(work_dir, &nostr_key.public_key()).await?;
 
             let tokens = nostr_receive(relays, nostr_key.clone(), since).await?;
 
+            // Store the current time as last checked
+            nostr_storage::store_nostr_last_checked(
+                work_dir,
+                &nostr_key.public_key(),
+                unix_time() as u32,
+            )
+            .await?;
+
             let mut total_amount = Amount::ZERO;
             for token_str in &tokens {
                 match receive_token(
                     multi_mint_wallet,
-                    localstore.clone(),
-                    seed,
                     token_str,
                     &signing_keys,
                     &sub_command_args.preimage,
@@ -116,9 +119,6 @@ pub async fn receive(
                 }
             }
 
-            localstore
-                .add_nostr_last_checked(nostr_key.public_key(), unix_time() as u32)
-                .await?;
             total_amount
         }
     };
@@ -130,8 +130,6 @@ pub async fn receive(
 
 async fn receive_token(
     multi_mint_wallet: &MultiMintWallet,
-    localstore: Arc<dyn WalletDatabase<Err = cdk_database::Error> + Send + Sync>,
-    seed: &[u8],
     token_str: &str,
     signing_keys: &[SecretKey],
     preimage: &[String],
@@ -143,18 +141,24 @@ async fn receive_token(
     let wallet_key = WalletKey::new(mint_url.clone(), token.unit().unwrap_or_default());
 
     if multi_mint_wallet.get_wallet(&wallet_key).await.is_none() {
-        let wallet = Wallet::new(
-            &mint_url.to_string(),
-            token.unit().unwrap_or_default(),
-            localstore,
-            seed,
-            None,
-        )?;
-        multi_mint_wallet.add_wallet(wallet).await;
+        multi_mint_wallet
+            .create_and_add_wallet(
+                &mint_url.to_string(),
+                token.unit().unwrap_or_default(),
+                None,
+            )
+            .await?;
     }
 
     let amount = multi_mint_wallet
-        .receive(token_str, signing_keys, preimage)
+        .receive(
+            token_str,
+            ReceiveOptions {
+                p2pk_signing_keys: signing_keys.to_vec(),
+                preimages: preimage.to_vec(),
+                ..Default::default()
+            },
+        )
         .await?;
     Ok(amount)
 }

+ 4 - 9
crates/cdk-cli/src/sub_commands/restore.rs

@@ -1,12 +1,10 @@
 use std::str::FromStr;
-use std::sync::Arc;
 
 use anyhow::Result;
-use cdk::cdk_database::{Error, WalletDatabase};
 use cdk::mint_url::MintUrl;
 use cdk::nuts::CurrencyUnit;
 use cdk::wallet::types::WalletKey;
-use cdk::wallet::{MultiMintWallet, Wallet};
+use cdk::wallet::MultiMintWallet;
 use clap::Args;
 
 #[derive(Args)]
@@ -20,8 +18,6 @@ pub struct RestoreSubCommand {
 
 pub async fn restore(
     multi_mint_wallet: &MultiMintWallet,
-    seed: &[u8],
-    localstore: Arc<dyn WalletDatabase<Err = Error> + Sync + Send>,
     sub_command_args: &RestoreSubCommand,
 ) -> Result<()> {
     let unit = CurrencyUnit::from_str(&sub_command_args.unit)?;
@@ -33,10 +29,9 @@ pub async fn restore(
     {
         Some(wallet) => wallet.clone(),
         None => {
-            let wallet = Wallet::new(&mint_url.to_string(), unit, localstore, seed, None)?;
-
-            multi_mint_wallet.add_wallet(wallet.clone()).await;
-            wallet
+            multi_mint_wallet
+                .create_and_add_wallet(&mint_url.to_string(), unit, None)
+                .await?
         }
     };
 

+ 14 - 9
crates/cdk-cli/src/sub_commands/send.rs

@@ -3,10 +3,9 @@ use std::io::Write;
 use std::str::FromStr;
 
 use anyhow::{bail, Result};
-use cdk::amount::SplitTarget;
 use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions};
 use cdk::wallet::types::{SendKind, WalletKey};
-use cdk::wallet::MultiMintWallet;
+use cdk::wallet::{MultiMintWallet, SendMemo, SendOptions};
 use cdk::Amount;
 use clap::Args;
 
@@ -170,16 +169,22 @@ pub async fn send(
         (false, None) => SendKind::OnlineExact,
     };
 
-    let token = wallet
-        .send(
+    let prepared_send = wallet
+        .prepare_send(
             token_amount,
-            sub_command_args.memo.clone(),
-            conditions,
-            &SplitTarget::default(),
-            &send_kind,
-            sub_command_args.include_fee,
+            SendOptions {
+                memo: sub_command_args.memo.clone().map(|memo| SendMemo {
+                    memo,
+                    include_memo: true,
+                }),
+                send_kind,
+                include_fee: sub_command_args.include_fee,
+                conditions,
+                ..Default::default()
+            },
         )
         .await?;
+    let token = wallet.send(prepared_send, None).await?;
 
     match sub_command_args.v3 {
         true => {

+ 62 - 0
crates/cdk-cli/src/token_storage.rs

@@ -0,0 +1,62 @@
+use std::fs::File;
+use std::io::{Read, Write};
+use std::path::Path;
+
+use anyhow::Result;
+use cdk::mint_url::MintUrl;
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TokenData {
+    pub mint_url: String,
+    pub access_token: String,
+    pub refresh_token: String,
+}
+
+/// Stores authentication tokens in the work directory
+pub async fn save_tokens(
+    work_dir: &Path,
+    mint_url: &MintUrl,
+    access_token: &str,
+    refresh_token: &str,
+) -> Result<()> {
+    let token_data = TokenData {
+        mint_url: mint_url.to_string(),
+        access_token: access_token.to_string(),
+        refresh_token: refresh_token.to_string(),
+    };
+
+    let json = serde_json::to_string_pretty(&token_data)?;
+    let file_path = work_dir.join(format!(
+        "auth_tokens_{}",
+        mint_url.to_string().replace("/", "_")
+    ));
+    let mut file = File::create(file_path)?;
+    file.write_all(json.as_bytes())?;
+
+    Ok(())
+}
+
+/// Gets authentication tokens from the work directory
+pub async fn get_token_for_mint(work_dir: &Path, mint_url: &MintUrl) -> Result<Option<TokenData>> {
+    let file_path = work_dir.join(format!(
+        "auth_tokens_{}",
+        mint_url.to_string().replace("/", "_")
+    ));
+
+    if !file_path.exists() {
+        return Ok(None);
+    }
+
+    let mut file = File::open(file_path)?;
+    let mut contents = String::new();
+    file.read_to_string(&mut contents)?;
+
+    let token_data: TokenData = serde_json::from_str(&contents)?;
+
+    if token_data.mint_url == mint_url.to_string() {
+        Ok(Some(token_data))
+    } else {
+        Ok(None)
+    }
+}

+ 15 - 13
crates/cdk-cln/Cargo.toml

@@ -1,22 +1,24 @@
 [package]
 name = "cdk-cln"
-version = "0.7.1"
-edition = "2021"
+version.workspace = true
+edition.workspace = true
 authors = ["CDK Developers"]
-license = "MIT"
+license.workspace = true
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-rust-version = "1.63.0" # MSRV
+rust-version.workspace = true # MSRV
 description = "CDK ln backend for cln"
+readme = "README.md"
 
 [dependencies]
-async-trait = "0.1"
-bitcoin = { version = "0.32.2", default-features = false }
-cdk = { path = "../cdk", version = "0.7.1", default-features = false, features = ["mint"] }
+async-trait.workspace = true
+bitcoin.workspace = true
+cdk = { workspace = true, features = ["mint"] }
 cln-rpc = "0.3.0"
-futures = { version = "0.3.28", default-features = false }
-tokio = { version = "1", default-features = false }
-tokio-util = { version = "0.7.11", default-features = false }
-tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] }
-thiserror = "1"
-uuid = { version = "1", features = ["v4"] }
+futures.workspace = true
+tokio.workspace = true
+tokio-util.workspace = true
+tracing.workspace = true
+thiserror.workspace = true
+uuid.workspace = true
+serde_json.workspace = true

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

@@ -28,7 +28,7 @@ pub enum Error {
     Amount(#[from] cdk::amount::Error),
 }
 
-impl From<Error> for cdk::cdk_lightning::Error {
+impl From<Error> for cdk::cdk_payment::Error {
     fn from(e: Error) -> Self {
         Self::Lightning(Box::new(e))
     }

+ 47 - 38
crates/cdk-cln/src/lib.rs

@@ -1,8 +1,10 @@
 //! CDK lightning backend for CLN
 
+#![doc = include_str!("../README.md")]
 #![warn(missing_docs)]
 #![warn(rustdoc::bare_urls)]
 
+use std::cmp::max;
 use std::path::PathBuf;
 use std::pin::Pin;
 use std::str::FromStr;
@@ -10,12 +12,13 @@ use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 
 use async_trait::async_trait;
-use cdk::amount::{to_unit, Amount, MSAT_IN_SAT};
-use cdk::cdk_lightning::{
-    self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings,
+use cdk::amount::{to_unit, Amount};
+use cdk::cdk_payment::{
+    self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment,
+    PaymentQuoteResponse,
 };
-use cdk::mint::FeeReserve;
-use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState};
+use cdk::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState};
+use cdk::types::FeeReserve;
 use cdk::util::{hex, unix_time};
 use cdk::{mint, Bolt11Invoice};
 use cln_rpc::model::requests::{
@@ -28,6 +31,7 @@ use cln_rpc::model::responses::{
 use cln_rpc::primitives::{Amount as CLN_Amount, AmountOrAny};
 use error::Error;
 use futures::{Stream, StreamExt};
+use serde_json::Value;
 use tokio::sync::Mutex;
 use tokio_util::sync::CancellationToken;
 use uuid::Uuid;
@@ -60,15 +64,16 @@ impl Cln {
 }
 
 #[async_trait]
-impl MintLightning for Cln {
-    type Err = cdk_lightning::Error;
+impl MintPayment for Cln {
+    type Err = cdk_payment::Error;
 
-    fn get_settings(&self) -> Settings {
-        Settings {
+    async fn get_settings(&self) -> Result<Value, Self::Err> {
+        Ok(serde_json::to_value(Bolt11Settings {
             mpp: true,
             unit: CurrencyUnit::Msat,
             invoice_description: true,
-        }
+            amountless: true,
+        })?)
     }
 
     /// Is wait invoice active
@@ -81,9 +86,7 @@ impl MintLightning for Cln {
         self.wait_invoice_cancel_token.cancel()
     }
 
-    #[allow(clippy::incompatible_msrv)]
-    // Clippy thinks select is not stable but it compiles fine on MSRV (1.63.0)
-    async fn wait_any_invoice(
+    async fn wait_any_incoming_payment(
         &self,
     ) -> Result<Pin<Box<dyn Stream<Item = String> + Send>>, Self::Err> {
         let last_pay_index = self.get_last_pay_index().await?;
@@ -177,36 +180,43 @@ impl MintLightning for Cln {
 
     async fn get_payment_quote(
         &self,
-        melt_quote_request: &MeltQuoteBolt11Request,
+        request: &str,
+        unit: &CurrencyUnit,
+        options: Option<MeltOptions>,
     ) -> Result<PaymentQuoteResponse, Self::Err> {
-        let amount = melt_quote_request.amount_msat()?;
+        let bolt11 = Bolt11Invoice::from_str(request)?;
+
+        let amount_msat = match options {
+            Some(amount) => amount.amount_msat(),
+            None => bolt11
+                .amount_milli_satoshis()
+                .ok_or(Error::UnknownInvoiceAmount)?
+                .into(),
+        };
 
-        let amount = amount / MSAT_IN_SAT.into();
+        let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?;
 
         let relative_fee_reserve =
             (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64;
 
         let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
 
-        let fee = match relative_fee_reserve > absolute_fee_reserve {
-            true => relative_fee_reserve,
-            false => absolute_fee_reserve,
-        };
+        let fee = max(relative_fee_reserve, absolute_fee_reserve);
 
         Ok(PaymentQuoteResponse {
-            request_lookup_id: melt_quote_request.request.payment_hash().to_string(),
+            request_lookup_id: bolt11.payment_hash().to_string(),
             amount,
             fee: fee.into(),
             state: MeltQuoteState::Unpaid,
         })
     }
 
-    async fn pay_invoice(
+    async fn make_payment(
         &self,
         melt_quote: mint::MeltQuote,
         partial_amount: Option<Amount>,
         max_fee: Option<Amount>,
-    ) -> Result<PayInvoiceResponse, Self::Err> {
+    ) -> Result<MakePaymentResponse, Self::Err> {
         let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?;
         let pay_state = self
             .check_outgoing_payment(&bolt11.payment_hash().to_string())
@@ -273,8 +283,8 @@ impl MintLightning for Cln {
                     PayStatus::FAILED => MeltQuoteState::Failed,
                 };
 
-                PayInvoiceResponse {
-                    payment_preimage: Some(hex::encode(pay_response.payment_preimage.to_vec())),
+                MakePaymentResponse {
+                    payment_proof: Some(hex::encode(pay_response.payment_preimage.to_vec())),
                     payment_lookup_id: pay_response.payment_hash.to_string(),
                     status,
                     total_spent: to_unit(
@@ -294,15 +304,14 @@ impl MintLightning for Cln {
         Ok(response)
     }
 
-    async fn create_invoice(
+    async fn create_incoming_payment_request(
         &self,
         amount: Amount,
         unit: &CurrencyUnit,
         description: String,
-        unix_expiry: u64,
-    ) -> Result<CreateInvoiceResponse, Self::Err> {
+        unix_expiry: Option<u64>,
+    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
         let time_now = unix_time();
-        assert!(unix_expiry > time_now);
 
         let mut cln_client = self.cln_client.lock().await;
 
@@ -316,7 +325,7 @@ impl MintLightning for Cln {
                 amount_msat,
                 description,
                 label: label.clone(),
-                expiry: Some(unix_expiry - time_now),
+                expiry: unix_expiry.map(|t| t - time_now),
                 fallbacks: None,
                 preimage: None,
                 cltv: None,
@@ -330,14 +339,14 @@ impl MintLightning for Cln {
         let expiry = request.expires_at().map(|t| t.as_secs());
         let payment_hash = request.payment_hash();
 
-        Ok(CreateInvoiceResponse {
+        Ok(CreateIncomingPaymentResponse {
             request_lookup_id: payment_hash.to_string(),
-            request,
+            request: request.to_string(),
             expiry,
         })
     }
 
-    async fn check_incoming_invoice_status(
+    async fn check_incoming_payment_status(
         &self,
         payment_hash: &str,
     ) -> Result<MintQuoteState, Self::Err> {
@@ -373,7 +382,7 @@ impl MintLightning for Cln {
     async fn check_outgoing_payment(
         &self,
         payment_hash: &str,
-    ) -> Result<PayInvoiceResponse, Self::Err> {
+    ) -> Result<MakePaymentResponse, Self::Err> {
         let mut cln_client = self.cln_client.lock().await;
 
         let listpays_response = cln_client
@@ -392,9 +401,9 @@ impl MintLightning for Cln {
             Some(pays_response) => {
                 let status = cln_pays_status_to_mint_state(pays_response.status);
 
-                Ok(PayInvoiceResponse {
+                Ok(MakePaymentResponse {
                     payment_lookup_id: pays_response.payment_hash.to_string(),
-                    payment_preimage: pays_response.preimage.map(|p| hex::encode(p.to_vec())),
+                    payment_proof: pays_response.preimage.map(|p| hex::encode(p.to_vec())),
                     status,
                     total_spent: pays_response
                         .amount_sent_msat
@@ -402,9 +411,9 @@ impl MintLightning for Cln {
                     unit: CurrencyUnit::Msat,
                 })
             }
-            None => Ok(PayInvoiceResponse {
+            None => Ok(MakePaymentResponse {
                 payment_lookup_id: payment_hash.to_string(),
-                payment_preimage: None,
+                payment_proof: None,
                 status: MeltQuoteState::Unknown,
                 total_spent: Amount::ZERO,
                 unit: CurrencyUnit::Msat,

+ 25 - 28
crates/cdk-common/Cargo.toml

@@ -1,13 +1,14 @@
 [package]
 name = "cdk-common"
-version = "0.7.1"
-edition = "2021"
+version.workspace = true
 authors = ["CDK Developers"]
 description = "CDK common types and traits"
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-rust-version = "1.63.0"                     # MSRV
-license = "MIT"
+edition.workspace = true
+rust-version.workspace = true # MSRV
+license.workspace = true
+readme = "README.md"
 
 [features]
 default = ["mint", "wallet"]
@@ -15,33 +16,29 @@ swagger = ["dep:utoipa", "cashu/swagger"]
 bench = []
 wallet = ["cashu/wallet"]
 mint = ["cashu/mint", "dep:uuid"]
+auth = ["cashu/auth"]
 
 [dependencies]
-async-trait = "0.1"
-bitcoin = { version = "0.32.2", features = [
-    "base64",
-    "serde",
-    "rand",
-    "rand-std",
-] }
-cashu = { path = "../cashu", default-features = false, version = "0.7.1" }
-cbor-diag = "0.1.12"
-ciborium = { version = "0.2.2", default-features = false, features = ["std"] }
-serde = { version = "1", features = ["derive"] }
-lightning-invoice = { version = "0.32.0", features = ["serde", "std"] }
-thiserror = "2"
-tracing = "0.1"
-url = "2.3"
-uuid = { version = "=1.12.1", features = ["v4", "serde"], optional = true }
-utoipa = { version = "4", optional = true }
-futures = "0.3.31"
-anyhow = "1.0"
-serde_json = "1"
-serde_with = "3"
+async-trait.workspace = true
+bitcoin.workspace = true
+cashu.workspace = true
+cbor-diag.workspace = true
+ciborium.workspace = true
+serde.workspace = true
+lightning-invoice.workspace = true
+thiserror.workspace = true
+tracing.workspace = true
+url.workspace = true
+uuid = { workspace = true, optional = true }
+utoipa = { workspace = true, optional = true }
+futures.workspace = true
+anyhow.workspace = true
+serde_json.workspace = true
+serde_with.workspace = true
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
-instant = { version = "0.1", features = ["wasm-bindgen", "inaccurate"] }
+instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] }
 
 [dev-dependencies]
-rand = "0.8.5"
-bip39 = "2.0"
+rand.workspace = true
+bip39.workspace = true

+ 41 - 0
crates/cdk-common/README.md

@@ -0,0 +1,41 @@
+# CDK Common
+
+[![crates.io](https://img.shields.io/crates/v/cdk-common.svg)](https://crates.io/crates/cdk-common)
+[![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).
+
+## Overview
+
+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
+
+Add this to your `Cargo.toml`:
+
+```toml
+[dependencies]
+cdk-common = "*"
+```
+
+## Components
+
+The crate includes several key modules:
+
+- **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
+
+## License
+
+This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/LICENSE).

+ 108 - 7
crates/cdk-common/src/common.rs

@@ -43,7 +43,8 @@ impl Melted {
 
         let fee_paid = proofs_amount
             .checked_sub(amount + change_amount)
-            .ok_or(Error::AmountOverflow)?;
+            .ok_or(Error::AmountOverflow)
+            .unwrap();
 
         Ok(Self {
             state,
@@ -127,7 +128,11 @@ impl ProofInfo {
 
         if let Some(spending_conditions) = spending_conditions {
             match &self.spending_condition {
-                None => return false,
+                None => {
+                    if !spending_conditions.is_empty() {
+                        return false;
+                    }
+                }
                 Some(s) => {
                     if !spending_conditions.contains(s) {
                         return false;
@@ -143,15 +148,15 @@ impl ProofInfo {
 /// Key used in hashmap of ln backends to identify what unit and payment method
 /// it is for
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
-pub struct LnKey {
+pub struct PaymentProcessorKey {
     /// Unit of Payment backend
     pub unit: CurrencyUnit,
     /// Method of payment backend
     pub method: PaymentMethod,
 }
 
-impl LnKey {
-    /// Create new [`LnKey`]
+impl PaymentProcessorKey {
+    /// Create new [`PaymentProcessorKey`]
     pub fn new(unit: CurrencyUnit, method: PaymentMethod) -> Self {
         Self { unit, method }
     }
@@ -177,8 +182,11 @@ impl QuoteTTL {
 mod tests {
     use std::str::FromStr;
 
-    use super::Melted;
-    use crate::nuts::{Id, Proof, PublicKey};
+    use cashu::SecretKey;
+
+    use super::{Melted, ProofInfo};
+    use crate::mint_url::MintUrl;
+    use crate::nuts::{CurrencyUnit, Id, Proof, PublicKey, SpendingConditions, State};
     use crate::secret::Secret;
     use crate::Amount;
 
@@ -240,4 +248,97 @@ mod tests {
         assert_eq!(melted.fee_paid, Amount::from(1));
         assert_eq!(melted.total_amount(), Amount::from(32));
     }
+
+    #[test]
+    fn test_matches_conditions() {
+        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
+        let proof = Proof::new(
+            Amount::from(64),
+            keyset_id,
+            Secret::new("test_secret"),
+            PublicKey::from_hex(
+                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
+            )
+            .unwrap(),
+        );
+
+        let mint_url = MintUrl::from_str("https://example.com").unwrap();
+        let proof_info =
+            ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap();
+
+        // Test matching mint_url
+        assert!(proof_info.matches_conditions(&Some(mint_url.clone()), &None, &None, &None));
+        assert!(!proof_info.matches_conditions(
+            &Some(MintUrl::from_str("https://different.com").unwrap()),
+            &None,
+            &None,
+            &None
+        ));
+
+        // Test matching unit
+        assert!(proof_info.matches_conditions(&None, &Some(CurrencyUnit::Sat), &None, &None));
+        assert!(!proof_info.matches_conditions(&None, &Some(CurrencyUnit::Msat), &None, &None));
+
+        // Test matching state
+        assert!(proof_info.matches_conditions(&None, &None, &Some(vec![State::Unspent]), &None));
+        assert!(proof_info.matches_conditions(
+            &None,
+            &None,
+            &Some(vec![State::Unspent, State::Spent]),
+            &None
+        ));
+        assert!(!proof_info.matches_conditions(&None, &None, &Some(vec![State::Spent]), &None));
+
+        // Test with no conditions (should match)
+        assert!(proof_info.matches_conditions(&None, &None, &None, &None));
+
+        // Test with multiple conditions
+        assert!(proof_info.matches_conditions(
+            &Some(mint_url),
+            &Some(CurrencyUnit::Sat),
+            &Some(vec![State::Unspent]),
+            &None
+        ));
+    }
+
+    #[test]
+    fn test_matches_conditions_with_spending_conditions() {
+        // This test would need to be expanded with actual SpendingConditions
+        // implementation, but we can test the basic case where no spending
+        // conditions are present
+
+        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
+        let proof = Proof::new(
+            Amount::from(64),
+            keyset_id,
+            Secret::new("test_secret"),
+            PublicKey::from_hex(
+                "02deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
+            )
+            .unwrap(),
+        );
+
+        let mint_url = MintUrl::from_str("https://example.com").unwrap();
+        let proof_info =
+            ProofInfo::new(proof, mint_url, State::Unspent, CurrencyUnit::Sat).unwrap();
+
+        // Test with empty spending conditions (should match when proof has none)
+        assert!(proof_info.matches_conditions(&None, &None, &None, &Some(vec![])));
+
+        // Test with non-empty spending conditions (should not match when proof has none)
+        let dummy_condition = SpendingConditions::P2PKConditions {
+            data: SecretKey::generate().public_key(),
+            conditions: None,
+        };
+        assert!(!proof_info.matches_conditions(&None, &None, &None, &Some(vec![dummy_condition])));
+    }
+}
+
+/// Mint Fee Reserve
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct FeeReserve {
+    /// Absolute expected min fee
+    pub min_fee_reserve: Amount,
+    /// Percentage expected fee
+    pub percent_fee_reserve: f32,
 }

+ 72 - 0
crates/cdk-common/src/database/mint/auth/mod.rs

@@ -0,0 +1,72 @@
+//! Mint in memory database use std::collections::HashMap;
+
+use std::collections::HashMap;
+
+use async_trait::async_trait;
+use cashu::{AuthRequired, ProtectedEndpoint};
+
+use crate::database::Error;
+use crate::mint::MintKeySetInfo;
+use crate::nuts::nut07::State;
+use crate::nuts::{AuthProof, BlindSignature, Id, PublicKey};
+
+/// Mint Database trait
+#[async_trait]
+pub trait MintAuthDatabase {
+    /// Mint Database Error
+    type Err: Into<Error> + From<Error>;
+    /// Add Active Keyset
+    async fn set_active_keyset(&self, id: Id) -> Result<(), Self::Err>;
+    /// Get Active Keyset
+    async fn get_active_keyset_id(&self) -> Result<Option<Id>, Self::Err>;
+
+    /// Add [`MintKeySetInfo`]
+    async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err>;
+    /// Get [`MintKeySetInfo`]
+    async fn get_keyset_info(&self, id: &Id) -> Result<Option<MintKeySetInfo>, Self::Err>;
+    /// Get [`MintKeySetInfo`]s
+    async fn get_keyset_infos(&self) -> Result<Vec<MintKeySetInfo>, Self::Err>;
+
+    /// Add spent [`AuthProof`]
+    async fn add_proof(&self, proof: AuthProof) -> Result<(), Self::Err>;
+    /// Get [`AuthProof`] state
+    async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err>;
+    /// Update [`AuthProof`]s state
+    async fn update_proof_state(
+        &self,
+        y: &PublicKey,
+        proofs_state: State,
+    ) -> Result<Option<State>, Self::Err>;
+
+    /// Add [`BlindSignature`]
+    async fn add_blind_signatures(
+        &self,
+        blinded_messages: &[PublicKey],
+        blind_signatures: &[BlindSignature],
+    ) -> Result<(), Self::Err>;
+    /// Get [`BlindSignature`]s
+    async fn get_blind_signatures(
+        &self,
+        blinded_messages: &[PublicKey],
+    ) -> Result<Vec<Option<BlindSignature>>, Self::Err>;
+
+    /// Add protected endpoints
+    async fn add_protected_endpoints(
+        &self,
+        protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
+    ) -> Result<(), Self::Err>;
+    /// Removed Protected endpoints
+    async fn remove_protected_endpoints(
+        &self,
+        protected_endpoints: Vec<ProtectedEndpoint>,
+    ) -> Result<(), Self::Err>;
+    /// Get auth for protected_endpoint
+    async fn get_auth_for_endpoint(
+        &self,
+        protected_endpoint: ProtectedEndpoint,
+    ) -> Result<Option<AuthRequired>, Self::Err>;
+    /// Get protected endpoints
+    async fn get_auth_for_endpoints(
+        &self,
+    ) -> Result<HashMap<ProtectedEndpoint, Option<AuthRequired>>, Self::Err>;
+}

+ 50 - 16
crates/cdk-common/src/database/mint.rs → crates/cdk-common/src/database/mint/mod.rs

@@ -7,17 +7,23 @@ use cashu::MintInfo;
 use uuid::Uuid;
 
 use super::Error;
-use crate::common::{LnKey, QuoteTTL};
+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,
 };
 
-/// Mint Database trait
+#[cfg(feature = "auth")]
+mod auth;
+
+#[cfg(feature = "auth")]
+pub use auth::MintAuthDatabase;
+
+/// Mint Keys Database trait
 #[async_trait]
-pub trait Database {
-    /// Mint Database Error
+pub trait KeysDatabase {
+    /// Mint Keys Database Error
     type Err: Into<Error> + From<Error>;
 
     /// Add Active Keyset
@@ -26,6 +32,18 @@ pub trait Database {
     async fn get_active_keyset_id(&self, unit: &CurrencyUnit) -> Result<Option<Id>, Self::Err>;
     /// Get all Active Keyset
     async fn get_active_keysets(&self) -> Result<HashMap<CurrencyUnit, Id>, Self::Err>;
+    /// Add [`MintKeySetInfo`]
+    async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err>;
+    /// Get [`MintKeySetInfo`]
+    async fn get_keyset_info(&self, id: &Id) -> Result<Option<MintKeySetInfo>, Self::Err>;
+    /// Get [`MintKeySetInfo`]s
+    async fn get_keyset_infos(&self) -> Result<Vec<MintKeySetInfo>, Self::Err>;
+}
+/// Mint Quote Database trait
+#[async_trait]
+pub trait QuotesDatabase {
+    /// Mint Quotes Database Error
+    type Err: Into<Error> + From<Error>;
 
     /// Add [`MintMintQuote`]
     async fn add_mint_quote(&self, quote: MintMintQuote) -> Result<(), Self::Err>;
@@ -76,20 +94,20 @@ pub trait Database {
     async fn add_melt_request(
         &self,
         melt_request: MeltBolt11Request<Uuid>,
-        ln_key: LnKey,
+        ln_key: PaymentProcessorKey,
     ) -> Result<(), Self::Err>;
     /// Get melt request
     async fn get_melt_request(
         &self,
         quote_id: &Uuid,
-    ) -> Result<Option<(MeltBolt11Request<Uuid>, LnKey)>, Self::Err>;
+    ) -> Result<Option<(MeltBolt11Request<Uuid>, PaymentProcessorKey)>, Self::Err>;
+}
 
-    /// Add [`MintKeySetInfo`]
-    async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err>;
-    /// Get [`MintKeySetInfo`]
-    async fn get_keyset_info(&self, id: &Id) -> Result<Option<MintKeySetInfo>, Self::Err>;
-    /// Get [`MintKeySetInfo`]s
-    async fn get_keyset_infos(&self) -> Result<Vec<MintKeySetInfo>, Self::Err>;
+/// Mint Proof Database trait
+#[async_trait]
+pub trait ProofsDatabase {
+    /// Mint Proof Database Error
+    type Err: Into<Error> + From<Error>;
 
     /// Add  [`Proofs`]
     async fn add_proofs(&self, proof: Proofs, quote_id: Option<Uuid>) -> Result<(), Self::Err>;
@@ -116,6 +134,13 @@ pub trait Database {
         &self,
         keyset_id: &Id,
     ) -> Result<(Proofs, Vec<Option<State>>), Self::Err>;
+}
+
+#[async_trait]
+/// Mint Signatures Database trait
+pub trait SignaturesDatabase {
+    /// Mint Signature Database Error
+    type Err: Into<Error> + From<Error>;
 
     /// Add [`BlindSignature`]
     async fn add_blind_signatures(
@@ -139,14 +164,23 @@ pub trait Database {
         &self,
         quote_id: &Uuid,
     ) -> Result<Vec<BlindSignature>, Self::Err>;
+}
 
+/// Mint Database trait
+#[async_trait]
+pub trait Database<Error>:
+    KeysDatabase<Err = Error>
+    + QuotesDatabase<Err = Error>
+    + ProofsDatabase<Err = Error>
+    + SignaturesDatabase<Err = Error>
+{
     /// Set [`MintInfo`]
-    async fn set_mint_info(&self, mint_info: MintInfo) -> Result<(), Self::Err>;
+    async fn set_mint_info(&self, mint_info: MintInfo) -> Result<(), Error>;
     /// Get [`MintInfo`]
-    async fn get_mint_info(&self) -> Result<MintInfo, Self::Err>;
+    async fn get_mint_info(&self) -> Result<MintInfo, Error>;
 
     /// Set [`QuoteTTL`]
-    async fn set_quote_ttl(&self, quote_ttl: QuoteTTL) -> Result<(), Self::Err>;
+    async fn set_quote_ttl(&self, quote_ttl: QuoteTTL) -> Result<(), Error>;
     /// Get [`QuoteTTL`]
-    async fn get_quote_ttl(&self) -> Result<QuoteTTL, Self::Err>;
+    async fn get_quote_ttl(&self) -> Result<QuoteTTL, Error>;
 }

+ 23 - 1
crates/cdk-common/src/database/mod.rs

@@ -5,8 +5,14 @@ mod mint;
 #[cfg(feature = "wallet")]
 mod wallet;
 
+#[cfg(all(feature = "mint", feature = "auth"))]
+pub use mint::MintAuthDatabase;
 #[cfg(feature = "mint")]
-pub use mint::Database as MintDatabase;
+pub use mint::{
+    Database as MintDatabase, KeysDatabase as MintKeysDatabase,
+    ProofsDatabase as MintProofsDatabase, QuotesDatabase as MintQuotesDatabase,
+    SignaturesDatabase as MintSignaturesDatabase,
+};
 #[cfg(feature = "wallet")]
 pub use wallet::Database as WalletDatabase;
 
@@ -25,10 +31,26 @@ pub enum Error {
     /// NUT02 Error
     #[error(transparent)]
     NUT02(#[from] crate::nuts::nut02::Error),
+    /// NUT22 Error
+    #[error(transparent)]
+    #[cfg(feature = "auth")]
+    NUT22(#[from] crate::nuts::nut22::Error),
     /// Serde Error
     #[error(transparent)]
     Serde(#[from] serde_json::Error),
     /// Unknown Quote
     #[error("Unknown Quote")]
     UnknownQuote,
+    /// Attempt to remove spent proof
+    #[error("Attempt to remove spent proof")]
+    AttemptRemoveSpentProof,
+    /// Attempt to update state of spent proof
+    #[error("Attempt to update state of spent proof")]
+    AttemptUpdateSpentProof,
+    /// Proof not found
+    #[error("Proof not found")]
+    ProofNotFound,
+    /// Invalid keyset
+    #[error("Unknown or invalid keyset")]
+    InvalidKeysetId,
 }

+ 19 - 19
crates/cdk-common/src/database/wallet.rs

@@ -11,8 +11,9 @@ use crate::mint_url::MintUrl;
 use crate::nuts::{
     CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State,
 };
-use crate::wallet;
-use crate::wallet::MintQuote as WalletMintQuote;
+use crate::wallet::{
+    self, MintQuote as WalletMintQuote, Transaction, TransactionDirection, TransactionId,
+};
 
 /// Wallet Database trait
 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
@@ -84,14 +85,6 @@ pub trait Database: Debug {
         added: Vec<ProofInfo>,
         removed_ys: Vec<PublicKey>,
     ) -> Result<(), Self::Err>;
-    /// Set proofs as pending in storage. Proofs are identified by their Y
-    /// value.
-    async fn set_pending_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err>;
-    /// Reserve proofs in storage. Proofs are identified by their Y value.
-    async fn reserve_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err>;
-    /// Set proofs as unspent in storage. Proofs are identified by their Y
-    /// value.
-    async fn set_unspent_proofs(&self, ys: Vec<PublicKey>) -> Result<(), Self::Err>;
     /// Get proofs from storage
     async fn get_proofs(
         &self,
@@ -100,21 +93,28 @@ pub trait Database: Debug {
         state: Option<Vec<State>>,
         spending_conditions: Option<Vec<SpendingConditions>>,
     ) -> Result<Vec<ProofInfo>, Self::Err>;
+    /// Update proofs state in storage
+    async fn update_proofs_state(&self, ys: Vec<PublicKey>, state: State) -> Result<(), Self::Err>;
 
     /// Increment Keyset counter
     async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err>;
     /// Get current Keyset counter
     async fn get_keyset_counter(&self, keyset_id: &Id) -> Result<Option<u32>, Self::Err>;
 
-    /// Get when nostr key was last checked
-    async fn get_nostr_last_checked(
+    /// Add transaction to storage
+    async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err>;
+    /// Get transaction from storage
+    async fn get_transaction(
         &self,
-        verifying_key: &PublicKey,
-    ) -> Result<Option<u32>, Self::Err>;
-    /// Update last checked time
-    async fn add_nostr_last_checked(
+        transaction_id: TransactionId,
+    ) -> Result<Option<Transaction>, Self::Err>;
+    /// List transactions from storage
+    async fn list_transactions(
         &self,
-        verifying_key: PublicKey,
-        last_checked: u32,
-    ) -> Result<(), Self::Err>;
+        mint_url: Option<MintUrl>,
+        direction: Option<TransactionDirection>,
+        unit: Option<CurrencyUnit>,
+    ) -> Result<Vec<Transaction>, Self::Err>;
+    /// Remove transaction from storage
+    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err>;
 }

+ 95 - 3
crates/cdk-common/src/error.rs

@@ -58,6 +58,39 @@ pub enum Error {
     /// Multi-Part Payment not supported for unit and method
     #[error("Multi-Part payment is not supported for unit `{0}` and method `{1}`")]
     MppUnitMethodNotSupported(CurrencyUnit, PaymentMethod),
+    /// Clear Auth Required
+    #[error("Clear Auth Required")]
+    ClearAuthRequired,
+    /// Blind Auth Required
+    #[error("Blind Auth Required")]
+    BlindAuthRequired,
+    /// Clear Auth Failed
+    #[error("Clear Auth Failed")]
+    ClearAuthFailed,
+    /// Blind Auth Failed
+    #[error("Blind Auth Failed")]
+    BlindAuthFailed,
+    /// Auth settings undefined
+    #[error("Auth settings undefined")]
+    AuthSettingsUndefined,
+    /// Mint time outside of tolerance
+    #[error("Mint time outside of tolerance")]
+    MintTimeExceedsTolerance,
+    /// Insufficient blind auth tokens
+    #[error("Insufficient blind auth tokens, must reauth")]
+    InsufficientBlindAuthTokens,
+    /// Auth localstore undefined
+    #[error("Auth localstore undefined")]
+    AuthLocalstoreUndefined,
+    /// Wallet cat not set
+    #[error("Wallet cat not set")]
+    CatNotSet,
+    /// Could not get mint info
+    #[error("Could not get mint info")]
+    CouldNotGetMintInfo,
+    /// Multi-Part Payment not supported for unit and method
+    #[error("Amountless invoices are not supported for unit `{0}` and method `{1}`")]
+    AmountlessInvoiceNotSupported(CurrencyUnit, PaymentMethod),
 
     // Mint Errors
     /// Minting is disabled
@@ -126,6 +159,9 @@ pub enum Error {
     /// Internal Error
     #[error("Internal Error")]
     Internal,
+    /// Oidc config not set
+    #[error("Oidc client not set")]
+    OidcNotSet,
 
     // Wallet Errors
     /// P2PK spending conditions not met
@@ -156,6 +192,9 @@ pub enum Error {
     /// Invalid DLEQ proof
     #[error("Could not verify DLEQ proof")]
     CouldNotVerifyDleq,
+    /// Dleq Proof not provided for signature
+    #[error("Dleq proof not provided for signature")]
+    DleqProofNotProvided,
     /// Incorrect Mint
     /// Token does not match wallet mint
     #[error("Token does not match wallet mint")]
@@ -169,6 +208,9 @@ pub enum Error {
     /// Insufficient Funds
     #[error("Insufficient funds")]
     InsufficientFunds,
+    /// Unexpected proof state
+    #[error("Unexpected proof state")]
+    UnexpectedProofState,
     /// No active keyset
     #[error("No active keyset")]
     NoActiveKeyset,
@@ -178,6 +220,12 @@ pub enum Error {
     /// Invoice Description not supported
     #[error("Invoice Description not supported")]
     InvoiceDescriptionUnsupported,
+    /// Invalid transaction direction
+    #[error("Invalid transaction direction")]
+    InvalidTransactionDirection,
+    /// Invalid transaction id
+    #[error("Invalid transaction id")]
+    InvalidTransactionId,
     /// Custom Error
     #[error("`{0}`")]
     Custom(String),
@@ -210,7 +258,7 @@ pub enum Error {
     /// Http transport error
     #[error("Http transport error: {0}")]
     HttpError(String),
-
+    #[cfg(feature = "wallet")]
     // Crate error conversions
     /// Cashu Url Error
     #[error(transparent)]
@@ -261,13 +309,19 @@ pub enum Error {
     /// NUT20 Error
     #[error(transparent)]
     NUT20(#[from] crate::nuts::nut20::Error),
+    /// NUTXX Error
+    #[error(transparent)]
+    NUT21(#[from] crate::nuts::nut21::Error),
+    /// NUTXX1 Error
+    #[error(transparent)]
+    NUT22(#[from] crate::nuts::nut22::Error),
     /// Database Error
     #[error(transparent)]
     Database(#[from] crate::database::Error),
-    /// Lightning Error
+    /// Payment Error
     #[error(transparent)]
     #[cfg(feature = "mint")]
-    Lightning(#[from] crate::lightning::Error),
+    Payment(#[from] crate::payment::Error),
 }
 
 /// CDK Error Response
@@ -394,6 +448,26 @@ impl From<Error> for ErrorResponse {
                 error: Some(err.to_string()),
                 detail: None,
             },
+            Error::ClearAuthRequired => ErrorResponse {
+                code: ErrorCode::ClearAuthRequired,
+                error: None,
+                detail: None,
+            },
+            Error::ClearAuthFailed => ErrorResponse {
+                code: ErrorCode::ClearAuthFailed,
+                error: None,
+                detail: None,
+            },
+            Error::BlindAuthRequired => ErrorResponse {
+                code: ErrorCode::BlindAuthRequired,
+                error: None,
+                detail: None,
+            },
+            Error::BlindAuthFailed => ErrorResponse {
+                code: ErrorCode::BlindAuthFailed,
+                error: None,
+                detail: None,
+            },
             Error::NUT20(err) => ErrorResponse {
                 code: ErrorCode::WitnessMissingOrInvalid,
                 error: Some(err.to_string()),
@@ -453,6 +527,8 @@ impl From<ErrorResponse> for Error {
             ErrorCode::DuplicateOutputs => Self::DuplicateOutputs,
             ErrorCode::MultipleUnits => Self::MultipleUnits,
             ErrorCode::UnitMismatch => Self::UnitMismatch,
+            ErrorCode::ClearAuthRequired => Self::ClearAuthRequired,
+            ErrorCode::BlindAuthRequired => Self::BlindAuthRequired,
             _ => Self::UnknownErrorResponse(err.to_string()),
         }
     }
@@ -504,6 +580,14 @@ pub enum ErrorCode {
     MultipleUnits,
     /// Input unit does not match output
     UnitMismatch,
+    /// Clear Auth Required
+    ClearAuthRequired,
+    /// Clear Auth Failed
+    ClearAuthFailed,
+    /// Blind Auth Required
+    BlindAuthRequired,
+    /// Blind Auth Failed
+    BlindAuthFailed,
     /// Unknown error code
     Unknown(u16),
 }
@@ -533,6 +617,10 @@ impl ErrorCode {
             20006 => Self::InvoiceAlreadyPaid,
             20007 => Self::QuoteExpired,
             20008 => Self::WitnessMissingOrInvalid,
+            30001 => Self::ClearAuthRequired,
+            30002 => Self::ClearAuthFailed,
+            31001 => Self::BlindAuthRequired,
+            31002 => Self::BlindAuthFailed,
             _ => Self::Unknown(code),
         }
     }
@@ -561,6 +649,10 @@ impl ErrorCode {
             Self::InvoiceAlreadyPaid => 20006,
             Self::QuoteExpired => 20007,
             Self::WitnessMissingOrInvalid => 20008,
+            Self::ClearAuthRequired => 30001,
+            Self::ClearAuthFailed => 30002,
+            Self::BlindAuthRequired => 31001,
+            Self::BlindAuthFailed => 31002,
             Self::Unknown(code) => *code,
         }
     }

+ 11 - 10
crates/cdk-common/src/lib.rs

@@ -1,28 +1,29 @@
-//! Cashu shared types and functions.
-//!
 //! This crate is the base foundation to build things that can interact with the CDK (Cashu
 //! Development Kit) and their internal crates.
 //!
 //! This is meant to contain the shared types, traits and common functions that are used across the
 //! internal crates.
 
+#![doc = include_str!("../README.md")]
+#![warn(missing_docs)]
+#![warn(rustdoc::bare_urls)]
+
 pub mod common;
 pub mod database;
 pub mod error;
 #[cfg(feature = "mint")]
-pub mod lightning;
-pub mod pub_sub;
+pub mod mint;
 #[cfg(feature = "mint")]
+pub mod payment;
+pub mod pub_sub;
 pub mod subscription;
+#[cfg(feature = "wallet")]
+pub mod wallet;
 pub mod ws;
-
 // re-exporting external crates
 pub use bitcoin;
 pub use cashu::amount::{self, Amount};
 pub use cashu::lightning_invoice::{self, Bolt11Invoice};
-#[cfg(feature = "mint")]
-pub use cashu::mint;
 pub use cashu::nuts::{self, *};
-#[cfg(feature = "wallet")]
-pub use cashu::wallet;
-pub use cashu::{dhke, mint_url, secret, util, SECP256K1};
+pub use cashu::{dhke, ensure_cdk, mint_url, secret, util, SECP256K1};
+pub use error::Error;

+ 70 - 2
crates/cashu/src/mint.rs → crates/cdk-common/src/mint.rs

@@ -1,6 +1,8 @@
 //! Mint types
 
 use bitcoin::bip32::DerivationPath;
+use cashu::util::unix_time;
+use cashu::{MeltQuoteBolt11Response, MintQuoteBolt11Response};
 use serde::{Deserialize, Serialize};
 use uuid::Uuid;
 
@@ -26,6 +28,13 @@ pub struct MintQuote {
     pub request_lookup_id: String,
     /// Pubkey
     pub pubkey: Option<PublicKey>,
+    /// Unix time quote was created
+    #[serde(default)]
+    pub created_time: u64,
+    /// Unix time quote was paid
+    pub paid_time: Option<u64>,
+    /// Unix time quote was issued
+    pub issued_time: Option<u64>,
 }
 
 impl MintQuote {
@@ -49,11 +58,14 @@ impl MintQuote {
             expiry,
             request_lookup_id,
             pubkey,
+            created_time: unix_time(),
+            paid_time: None,
+            issued_time: None,
         }
     }
 }
 
-// Melt Quote Info
+/// Melt Quote Info
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
 pub struct MeltQuote {
     /// Quote id
@@ -78,6 +90,11 @@ pub struct MeltQuote {
     ///
     /// Used for an amountless invoice
     pub msat_to_pay: Option<Amount>,
+    /// Unix time quote was created
+    #[serde(default)]
+    pub created_time: u64,
+    /// Unix time quote was paid
+    pub paid_time: Option<u64>,
 }
 
 impl MeltQuote {
@@ -104,6 +121,8 @@ impl MeltQuote {
             payment_preimage: None,
             request_lookup_id,
             msat_to_pay,
+            created_time: unix_time(),
+            paid_time: None,
         }
     }
 }
@@ -116,7 +135,7 @@ pub struct MintKeySetInfo {
     /// Keyset [`CurrencyUnit`]
     pub unit: CurrencyUnit,
     /// Keyset active or inactive
-    /// Mint will only issue new [`BlindSignature`] on active keysets
+    /// Mint will only issue new signatures on active keysets
     pub active: bool,
     /// Starting unix time Keyset is valid from
     pub valid_from: u64,
@@ -149,3 +168,52 @@ impl From<MintKeySetInfo> for KeySetInfo {
         }
     }
 }
+
+impl From<MintQuote> for MintQuoteBolt11Response<Uuid> {
+    fn from(mint_quote: crate::mint::MintQuote) -> MintQuoteBolt11Response<Uuid> {
+        MintQuoteBolt11Response {
+            quote: mint_quote.id,
+            request: mint_quote.request,
+            state: mint_quote.state,
+            expiry: Some(mint_quote.expiry),
+            pubkey: mint_quote.pubkey,
+            amount: Some(mint_quote.amount),
+            unit: Some(mint_quote.unit.clone()),
+        }
+    }
+}
+
+impl From<&MeltQuote> for MeltQuoteBolt11Response<Uuid> {
+    fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response<Uuid> {
+        MeltQuoteBolt11Response {
+            quote: melt_quote.id,
+            payment_preimage: None,
+            change: None,
+            state: melt_quote.state,
+            paid: Some(melt_quote.state == MeltQuoteState::Paid),
+            expiry: melt_quote.expiry,
+            amount: melt_quote.amount,
+            fee_reserve: melt_quote.fee_reserve,
+            request: None,
+            unit: Some(melt_quote.unit.clone()),
+        }
+    }
+}
+
+impl From<MeltQuote> for MeltQuoteBolt11Response<Uuid> {
+    fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response<Uuid> {
+        let paid = melt_quote.state == MeltQuoteState::Paid;
+        MeltQuoteBolt11Response {
+            quote: melt_quote.id,
+            amount: melt_quote.amount,
+            fee_reserve: melt_quote.fee_reserve,
+            paid: Some(paid),
+            state: melt_quote.state,
+            expiry: melt_quote.expiry,
+            payment_preimage: melt_quote.payment_preimage,
+            change: None,
+            request: Some(melt_quote.request.clone()),
+            unit: Some(melt_quote.unit.clone()),
+        }
+    }
+}

+ 57 - 26
crates/cdk-common/src/lightning.rs → crates/cdk-common/src/payment.rs

@@ -3,12 +3,14 @@
 use std::pin::Pin;
 
 use async_trait::async_trait;
+use cashu::MeltOptions;
 use futures::Stream;
-use lightning_invoice::{Bolt11Invoice, ParseOrSemanticError};
+use lightning_invoice::ParseOrSemanticError;
 use serde::{Deserialize, Serialize};
+use serde_json::Value;
 use thiserror::Error;
 
-use crate::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState};
+use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState};
 use crate::{mint, Amount};
 
 /// CDK Lightning Error
@@ -23,6 +25,9 @@ pub enum Error {
     /// Unsupported unit
     #[error("Unsupported unit")]
     UnsupportedUnit,
+    /// Unsupported payment option
+    #[error("Unsupported payment option")]
+    UnsupportedPaymentOption,
     /// Payment state is unknown
     #[error("Payment state is unknown")]
     UnknownPaymentState,
@@ -41,47 +46,55 @@ pub enum Error {
     /// Amount Error
     #[error(transparent)]
     Amount(#[from] crate::amount::Error),
+    /// NUT04 Error
+    #[error(transparent)]
+    NUT04(#[from] crate::nuts::nut04::Error),
     /// NUT05 Error
     #[error(transparent)]
     NUT05(#[from] crate::nuts::nut05::Error),
+    /// Custom
+    #[error("`{0}`")]
+    Custom(String),
 }
 
-/// MintLighting Trait
+/// Mint payment trait
 #[async_trait]
-pub trait MintLightning {
+pub trait MintPayment {
     /// Mint Lightning Error
     type Err: Into<Error> + From<Error>;
 
-    /// Base Unit
-    fn get_settings(&self) -> Settings;
+    /// Base Settings
+    async fn get_settings(&self) -> Result<serde_json::Value, Self::Err>;
 
     /// Create a new invoice
-    async fn create_invoice(
+    async fn create_incoming_payment_request(
         &self,
         amount: Amount,
         unit: &CurrencyUnit,
         description: String,
-        unix_expiry: u64,
-    ) -> Result<CreateInvoiceResponse, Self::Err>;
+        unix_expiry: Option<u64>,
+    ) -> Result<CreateIncomingPaymentResponse, Self::Err>;
 
     /// Get payment quote
     /// Used to get fee and amount required for a payment request
     async fn get_payment_quote(
         &self,
-        melt_quote_request: &MeltQuoteBolt11Request,
+        request: &str,
+        unit: &CurrencyUnit,
+        options: Option<MeltOptions>,
     ) -> Result<PaymentQuoteResponse, Self::Err>;
 
-    /// Pay bolt11 invoice
-    async fn pay_invoice(
+    /// Pay request
+    async fn make_payment(
         &self,
         melt_quote: mint::MeltQuote,
         partial_amount: Option<Amount>,
         max_fee_amount: Option<Amount>,
-    ) -> Result<PayInvoiceResponse, Self::Err>;
+    ) -> Result<MakePaymentResponse, Self::Err>;
 
     /// Listen for invoices to be paid to the mint
     /// Returns a stream of request_lookup_id once invoices are paid
-    async fn wait_any_invoice(
+    async fn wait_any_incoming_payment(
         &self,
     ) -> Result<Pin<Box<dyn Stream<Item = String> + Send>>, Self::Err>;
 
@@ -92,7 +105,7 @@ pub trait MintLightning {
     fn cancel_wait_invoice(&self);
 
     /// Check the status of an incoming payment
-    async fn check_incoming_invoice_status(
+    async fn check_incoming_payment_status(
         &self,
         request_lookup_id: &str,
     ) -> Result<MintQuoteState, Self::Err>;
@@ -101,27 +114,27 @@ pub trait MintLightning {
     async fn check_outgoing_payment(
         &self,
         request_lookup_id: &str,
-    ) -> Result<PayInvoiceResponse, Self::Err>;
+    ) -> Result<MakePaymentResponse, Self::Err>;
 }
 
-/// Create invoice response
+/// Create incoming payment response
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
-pub struct CreateInvoiceResponse {
-    /// Id that is used to look up the invoice from the ln backend
+pub struct CreateIncomingPaymentResponse {
+    /// Id that is used to look up the payment from the ln backend
     pub request_lookup_id: String,
-    /// Bolt11 payment request
-    pub request: Bolt11Invoice,
+    /// Payment request
+    pub request: String,
     /// Unix Expiry of Invoice
     pub expiry: Option<u64>,
 }
 
-/// Pay invoice response
+/// Payment response
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
-pub struct PayInvoiceResponse {
+pub struct MakePaymentResponse {
     /// Payment hash
     pub payment_lookup_id: String,
-    /// Payment Preimage
-    pub payment_preimage: Option<String>,
+    /// Payment proof
+    pub payment_proof: Option<String>,
     /// Status
     pub status: MeltQuoteState,
     /// Total Amount Spent
@@ -145,11 +158,29 @@ pub struct PaymentQuoteResponse {
 
 /// Ln backend settings
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
-pub struct Settings {
+pub struct Bolt11Settings {
     /// MPP supported
     pub mpp: bool,
     /// Base unit of backend
     pub unit: CurrencyUnit,
     /// Invoice Description supported
     pub invoice_description: bool,
+    /// Paying amountless invoices supported
+    pub amountless: bool,
+}
+
+impl TryFrom<Bolt11Settings> for Value {
+    type Error = crate::error::Error;
+
+    fn try_from(value: Bolt11Settings) -> Result<Self, Self::Error> {
+        serde_json::to_value(value).map_err(|err| err.into())
+    }
+}
+
+impl TryFrom<Value> for Bolt11Settings {
+    type Error = crate::error::Error;
+
+    fn try_from(value: Value) -> Result<Self, Self::Error> {
+        serde_json::from_value(value).map_err(|err| err.into())
+    }
 }

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

@@ -1,3 +1,7 @@
+//! WS Index
+//!
+//!
+
 use std::fmt::Debug;
 use std::ops::Deref;
 use std::sync::atomic::{AtomicUsize, Ordering};

+ 1 - 1
crates/cdk-common/src/pub_sub/mod.rs

@@ -1,7 +1,7 @@
 //! Publish–subscribe pattern.
 //!
 //! This is a generic implementation for
-//! [NUT-17(https://github.com/cashubtc/nuts/blob/main/17.md) with a type
+//! [NUT-17(<https://github.com/cashubtc/nuts/blob/main/17.md>) with a type
 //! agnostic Publish-subscribe manager.
 //!
 //! The manager has a method for subscribers to subscribe to events with a

+ 13 - 1
crates/cdk-common/src/subscription.rs

@@ -1,11 +1,18 @@
 //! Subscription types and traits
+#[cfg(feature = "mint")]
 use std::str::FromStr;
 
-use cashu::nut17::{self, Error, Kind, Notification};
+use cashu::nut17::{self};
+#[cfg(feature = "mint")]
+use cashu::nut17::{Error, Kind, Notification};
+#[cfg(feature = "mint")]
 use cashu::{NotificationPayload, PublicKey};
+#[cfg(feature = "mint")]
 use serde::{Deserialize, Serialize};
+#[cfg(feature = "mint")]
 use uuid::Uuid;
 
+#[cfg(feature = "mint")]
 use crate::pub_sub::index::{Index, Indexable, SubscriptionGlobalId};
 use crate::pub_sub::SubId;
 
@@ -15,15 +22,18 @@ use crate::pub_sub::SubId;
 pub type Params = nut17::Params<SubId>;
 
 /// Wrapper around `nut17::Params` to implement `Indexable` for `Notification`.
+#[cfg(feature = "mint")]
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct IndexableParams(Params);
 
+#[cfg(feature = "mint")]
 impl From<Params> for IndexableParams {
     fn from(params: Params) -> Self {
         Self(params)
     }
 }
 
+#[cfg(feature = "mint")]
 impl TryFrom<IndexableParams> for Vec<Index<Notification>> {
     type Error = Error;
     fn try_from(params: IndexableParams) -> Result<Self, Self::Error> {
@@ -49,12 +59,14 @@ impl TryFrom<IndexableParams> for Vec<Index<Notification>> {
     }
 }
 
+#[cfg(feature = "mint")]
 impl AsRef<SubId> for IndexableParams {
     fn as_ref(&self) -> &SubId {
         &self.0.id
     }
 }
 
+#[cfg(feature = "mint")]
 impl Indexable for NotificationPayload<Uuid> {
     type Type = Notification;
 

+ 294 - 0
crates/cdk-common/src/wallet.rs

@@ -0,0 +1,294 @@
+//! Wallet Types
+
+use std::collections::HashMap;
+use std::fmt;
+use std::str::FromStr;
+
+use bitcoin::hashes::{sha256, Hash, HashEngine};
+use cashu::util::hex;
+use cashu::{nut00, Proofs, PublicKey};
+use serde::{Deserialize, Serialize};
+
+use crate::mint_url::MintUrl;
+use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState, SecretKey};
+use crate::{Amount, Error};
+
+/// Wallet Key
+#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+pub struct WalletKey {
+    /// Mint Url
+    pub mint_url: MintUrl,
+    /// Currency Unit
+    pub unit: CurrencyUnit,
+}
+
+impl fmt::Display for WalletKey {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "mint_url: {}, unit: {}", self.mint_url, self.unit,)
+    }
+}
+
+impl WalletKey {
+    /// Create new [`WalletKey`]
+    pub fn new(mint_url: MintUrl, unit: CurrencyUnit) -> Self {
+        Self { mint_url, unit }
+    }
+}
+
+/// Mint Quote Info
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MintQuote {
+    /// Quote id
+    pub id: String,
+    /// Mint Url
+    pub mint_url: MintUrl,
+    /// Amount of quote
+    pub amount: Amount,
+    /// Unit of quote
+    pub unit: CurrencyUnit,
+    /// Quote payment request e.g. bolt11
+    pub request: String,
+    /// Quote state
+    pub state: MintQuoteState,
+    /// Expiration time of quote
+    pub expiry: u64,
+    /// Secretkey for signing mint quotes [NUT-20]
+    pub secret_key: Option<SecretKey>,
+}
+
+/// Melt Quote Info
+#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MeltQuote {
+    /// Quote id
+    pub id: String,
+    /// Quote unit
+    pub unit: CurrencyUnit,
+    /// Quote amount
+    pub amount: Amount,
+    /// Quote Payment request e.g. bolt11
+    pub request: String,
+    /// Quote fee reserve
+    pub fee_reserve: Amount,
+    /// Quote state
+    pub state: MeltQuoteState,
+    /// Expiration time of quote
+    pub expiry: u64,
+    /// Payment preimage
+    pub payment_preimage: Option<String>,
+}
+
+/// Send Kind
+#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub enum SendKind {
+    #[default]
+    /// Allow online swap before send if wallet does not have exact amount
+    OnlineExact,
+    /// Prefer offline send if difference is less then tolerance
+    OnlineTolerance(Amount),
+    /// Wallet cannot do an online swap and selected proof must be exactly send amount
+    OfflineExact,
+    /// Wallet must remain offline but can over pay if below tolerance
+    OfflineTolerance(Amount),
+}
+
+impl SendKind {
+    /// Check if send kind is online
+    pub fn is_online(&self) -> bool {
+        matches!(self, Self::OnlineExact | Self::OnlineTolerance(_))
+    }
+
+    /// Check if send kind is offline
+    pub fn is_offline(&self) -> bool {
+        matches!(self, Self::OfflineExact | Self::OfflineTolerance(_))
+    }
+
+    /// Check if send kind is exact
+    pub fn is_exact(&self) -> bool {
+        matches!(self, Self::OnlineExact | Self::OfflineExact)
+    }
+
+    /// Check if send kind has tolerance
+    pub fn has_tolerance(&self) -> bool {
+        matches!(self, Self::OnlineTolerance(_) | Self::OfflineTolerance(_))
+    }
+}
+
+/// Wallet Transaction
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Transaction {
+    /// Mint Url
+    pub mint_url: MintUrl,
+    /// Transaction direction
+    pub direction: TransactionDirection,
+    /// Amount
+    pub amount: Amount,
+    /// Fee
+    pub fee: Amount,
+    /// Currency Unit
+    pub unit: CurrencyUnit,
+    /// Proof Ys
+    pub ys: Vec<PublicKey>,
+    /// Unix timestamp
+    pub timestamp: u64,
+    /// Memo
+    pub memo: Option<String>,
+    /// User-defined metadata
+    pub metadata: HashMap<String, String>,
+}
+
+impl Transaction {
+    /// Transaction ID
+    pub fn id(&self) -> TransactionId {
+        TransactionId::new(self.ys.clone())
+    }
+
+    /// Check if transaction matches conditions
+    pub fn matches_conditions(
+        &self,
+        mint_url: &Option<MintUrl>,
+        direction: &Option<TransactionDirection>,
+        unit: &Option<CurrencyUnit>,
+    ) -> bool {
+        if let Some(mint_url) = mint_url {
+            if &self.mint_url != mint_url {
+                return false;
+            }
+        }
+        if let Some(direction) = direction {
+            if &self.direction != direction {
+                return false;
+            }
+        }
+        if let Some(unit) = unit {
+            if &self.unit != unit {
+                return false;
+            }
+        }
+        true
+    }
+}
+
+impl PartialOrd for Transaction {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for Transaction {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.timestamp.cmp(&other.timestamp).reverse()
+    }
+}
+
+/// Transaction Direction
+#[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum TransactionDirection {
+    /// Incoming transaction (i.e., receive or mint)
+    Incoming,
+    /// Outgoing transaction (i.e., send or melt)
+    Outgoing,
+}
+
+impl std::fmt::Display for TransactionDirection {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            TransactionDirection::Incoming => write!(f, "Incoming"),
+            TransactionDirection::Outgoing => write!(f, "Outgoing"),
+        }
+    }
+}
+
+impl FromStr for TransactionDirection {
+    type Err = Error;
+
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        match value {
+            "Incoming" => Ok(Self::Incoming),
+            "Outgoing" => Ok(Self::Outgoing),
+            _ => Err(Error::InvalidTransactionDirection),
+        }
+    }
+}
+
+/// Transaction ID
+#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
+#[serde(transparent)]
+pub struct TransactionId([u8; 32]);
+
+impl TransactionId {
+    /// Create new [`TransactionId`]
+    pub fn new(ys: Vec<PublicKey>) -> Self {
+        let mut ys = ys;
+        ys.sort();
+        let mut hasher = sha256::Hash::engine();
+        for y in ys {
+            hasher.input(&y.to_bytes());
+        }
+        let hash = sha256::Hash::from_engine(hasher);
+        Self(hash.to_byte_array())
+    }
+
+    /// From proofs
+    pub fn from_proofs(proofs: Proofs) -> Result<Self, nut00::Error> {
+        let ys = proofs
+            .iter()
+            .map(|proof| proof.y())
+            .collect::<Result<Vec<PublicKey>, nut00::Error>>()?;
+        Ok(Self::new(ys))
+    }
+
+    /// From bytes
+    pub fn from_bytes(bytes: [u8; 32]) -> Self {
+        Self(bytes)
+    }
+
+    /// From hex string
+    pub fn from_hex(value: &str) -> Result<Self, Error> {
+        let bytes = hex::decode(value)?;
+        let mut array = [0u8; 32];
+        array.copy_from_slice(&bytes);
+        Ok(Self(array))
+    }
+
+    /// From slice
+    pub fn from_slice(slice: &[u8]) -> Result<Self, Error> {
+        if slice.len() != 32 {
+            return Err(Error::InvalidTransactionId);
+        }
+        let mut array = [0u8; 32];
+        array.copy_from_slice(slice);
+        Ok(Self(array))
+    }
+
+    /// Get inner value
+    pub fn as_bytes(&self) -> &[u8; 32] {
+        &self.0
+    }
+
+    /// Get inner value as slice
+    pub fn as_slice(&self) -> &[u8] {
+        &self.0
+    }
+}
+
+impl std::fmt::Display for TransactionId {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}", hex::encode(self.0))
+    }
+}
+
+impl FromStr for TransactionId {
+    type Err = Error;
+
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        Self::from_hex(value)
+    }
+}
+
+impl TryFrom<Proofs> for TransactionId {
+    type Error = nut00::Error;
+
+    fn try_from(proofs: Proofs) -> Result<Self, Self::Error> {
+        Self::from_proofs(proofs)
+    }
+}

+ 23 - 0
crates/cdk-common/src/ws.rs

@@ -12,19 +12,41 @@ use uuid::Uuid;
 
 use crate::pub_sub::SubId;
 
+/// Request to unsubscribe from a websocket subscription
 pub type WsUnsubscribeRequest = nut17::ws::WsUnsubscribeRequest<SubId>;
+
+/// Notification message sent over websocket
 pub type WsNotification = nut17::ws::WsNotification<SubId>;
+
+/// Response to a subscription request
 pub type WsSubscribeResponse = nut17::ws::WsSubscribeResponse<SubId>;
+
+/// Result part of a websocket response
 pub type WsResponseResult = nut17::ws::WsResponseResult<SubId>;
+
+/// Response to an unsubscribe request
 pub type WsUnsubscribeResponse = nut17::ws::WsUnsubscribeResponse<SubId>;
+
+/// Generic websocket request
 pub type WsRequest = nut17::ws::WsRequest<SubId>;
+
+/// Generic websocket response
 pub type WsResponse = nut17::ws::WsResponse<SubId>;
+
+/// Method-specific websocket request
 pub type WsMethodRequest = nut17::ws::WsMethodRequest<SubId>;
+
+/// Error body for websocket responses
 pub type WsErrorBody = nut17::ws::WsErrorBody;
+
+/// Either a websocket message or a response
 pub type WsMessageOrResponse = nut17::ws::WsMessageOrResponse<SubId>;
+
+/// Inner content of a notification with generic payload type
 pub type NotificationInner<T> = nut17::ws::NotificationInner<T, SubId>;
 
 #[cfg(feature = "mint")]
+/// Converts a notification with UUID identifiers to a notification with string identifiers
 pub fn notification_uuid_to_notification_string(
     notification: NotificationInner<Uuid>,
 ) -> NotificationInner<String> {
@@ -43,6 +65,7 @@ pub fn notification_uuid_to_notification_string(
 }
 
 #[cfg(feature = "mint")]
+/// Converts a notification to a websocket message that can be sent to clients
 pub fn notification_to_ws_message(notification: NotificationInner<Uuid>) -> WsMessageOrResponse {
     nut17::ws::WsMessageOrResponse::Notification(nut17::ws::WsNotification {
         jsonrpc: JSON_RPC_VERSION.to_owned(),

+ 17 - 16
crates/cdk-fake-wallet/Cargo.toml

@@ -1,24 +1,25 @@
 [package]
 name = "cdk-fake-wallet"
-version = "0.7.1"
-edition = "2021"
+version.workspace = true
+edition.workspace = true
 authors = ["CDK Developers"]
-license = "MIT"
+license.workspace = true
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-rust-version = "1.63.0" # MSRV
+rust-version.workspace = true # MSRV
 description = "CDK fake ln backend"
+readme = "README.md"
 
 [dependencies]
-async-trait = "0.1.74"
-bitcoin = { version = "0.32.2", default-features = false }
-cdk = { path = "../cdk", version = "0.7.1", default-features = false, features = ["mint"] }
-futures = { version = "0.3.28", default-features = false }
-tokio = { version = "1", default-features = false }
-tokio-util = { version = "0.7.11", default-features = false }
-tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] }
-thiserror = "1"
-serde = "1"
-serde_json = "1"
-lightning-invoice = { version = "0.32.0", features = ["serde", "std"] }
-tokio-stream = "0.1.15"
+async-trait.workspace = true
+bitcoin.workspace = true
+cdk = { workspace = true, features = ["mint"] }
+futures.workspace = true
+tokio.workspace = true
+tokio-util.workspace = true
+tracing.workspace = true
+thiserror.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+lightning-invoice.workspace = true
+tokio-stream.workspace = true

+ 35 - 0
crates/cdk-fake-wallet/README.md

@@ -0,0 +1,35 @@
+# 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)
+
+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.
+
+## Overview
+
+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
+
+Add this to your `Cargo.toml`:
+
+```toml
+[dependencies]
+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.
+
+## License
+
+This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/LICENSE).

+ 1 - 1
crates/cdk-fake-wallet/src/error.rs

@@ -16,7 +16,7 @@ pub enum Error {
     NoReceiver,
 }
 
-impl From<Error> for cdk::cdk_lightning::Error {
+impl From<Error> for cdk::cdk_payment::Error {
     fn from(e: Error) -> Self {
         Self::Lightning(Box::new(e))
     }

+ 62 - 44
crates/cdk-fake-wallet/src/lib.rs

@@ -2,9 +2,11 @@
 //!
 //! Used for testing where quotes are auto filled
 
+#![doc = include_str!("../README.md")]
 #![warn(missing_docs)]
 #![warn(rustdoc::bare_urls)]
 
+use std::cmp::max;
 use std::collections::{HashMap, HashSet};
 use std::pin::Pin;
 use std::str::FromStr;
@@ -15,23 +17,25 @@ use async_trait::async_trait;
 use bitcoin::hashes::{sha256, Hash};
 use bitcoin::secp256k1::rand::{thread_rng, Rng};
 use bitcoin::secp256k1::{Secp256k1, SecretKey};
-use cdk::amount::{Amount, MSAT_IN_SAT};
-use cdk::cdk_lightning::{
-    self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings,
+use cdk::amount::{to_unit, Amount};
+use cdk::cdk_payment::{
+    self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment,
+    PaymentQuoteResponse,
 };
-use cdk::mint;
-use cdk::mint::FeeReserve;
-use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState};
-use cdk::util::unix_time;
+use cdk::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState};
+use cdk::types::FeeReserve;
+use cdk::{ensure_cdk, mint};
 use error::Error;
 use futures::stream::StreamExt;
 use futures::Stream;
 use lightning_invoice::{Bolt11Invoice, Currency, InvoiceBuilder, PaymentSecret};
 use serde::{Deserialize, Serialize};
+use serde_json::Value;
 use tokio::sync::Mutex;
 use tokio::time;
 use tokio_stream::wrappers::ReceiverStream;
 use tokio_util::sync::CancellationToken;
+use tracing::instrument;
 
 pub mod error;
 
@@ -49,7 +53,7 @@ pub struct FakeWallet {
 }
 
 impl FakeWallet {
-    /// Creat new [`FakeWallet`]
+    /// Create new [`FakeWallet`]
     pub fn new(
         fee_reserve: FeeReserve,
         payment_states: HashMap<String, MeltQuoteState>,
@@ -96,65 +100,80 @@ impl Default for FakeInvoiceDescription {
 }
 
 #[async_trait]
-impl MintLightning for FakeWallet {
-    type Err = cdk_lightning::Error;
+impl MintPayment for FakeWallet {
+    type Err = cdk_payment::Error;
 
-    fn get_settings(&self) -> Settings {
-        Settings {
+    #[instrument(skip_all)]
+    async fn get_settings(&self) -> Result<Value, Self::Err> {
+        Ok(serde_json::to_value(Bolt11Settings {
             mpp: true,
             unit: CurrencyUnit::Msat,
             invoice_description: true,
-        }
+            amountless: false,
+        })?)
     }
 
+    #[instrument(skip_all)]
     fn is_wait_invoice_active(&self) -> bool {
         self.wait_invoice_is_active.load(Ordering::SeqCst)
     }
 
+    #[instrument(skip_all)]
     fn cancel_wait_invoice(&self) {
         self.wait_invoice_cancel_token.cancel()
     }
 
-    async fn wait_any_invoice(
+    #[instrument(skip_all)]
+    async fn wait_any_incoming_payment(
         &self,
     ) -> Result<Pin<Box<dyn Stream<Item = String> + Send>>, Self::Err> {
+        tracing::info!("Starting stream for fake invoices");
         let receiver = self.receiver.lock().await.take().ok_or(Error::NoReceiver)?;
         let receiver_stream = ReceiverStream::new(receiver);
         Ok(Box::pin(receiver_stream.map(|label| label)))
     }
 
+    #[instrument(skip_all)]
     async fn get_payment_quote(
         &self,
-        melt_quote_request: &MeltQuoteBolt11Request,
+        request: &str,
+        unit: &CurrencyUnit,
+        options: Option<MeltOptions>,
     ) -> Result<PaymentQuoteResponse, Self::Err> {
-        let amount = melt_quote_request.amount_msat()?;
+        let bolt11 = Bolt11Invoice::from_str(request)?;
+
+        let amount_msat = match options {
+            Some(amount) => amount.amount_msat(),
+            None => bolt11
+                .amount_milli_satoshis()
+                .ok_or(Error::UnknownInvoiceAmount)?
+                .into(),
+        };
 
-        let amount = amount / MSAT_IN_SAT.into();
+        let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?;
 
         let relative_fee_reserve =
             (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64;
 
         let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
 
-        let fee = match relative_fee_reserve > absolute_fee_reserve {
-            true => relative_fee_reserve,
-            false => absolute_fee_reserve,
-        };
+        let fee = max(relative_fee_reserve, absolute_fee_reserve);
 
         Ok(PaymentQuoteResponse {
-            request_lookup_id: melt_quote_request.request.payment_hash().to_string(),
+            request_lookup_id: bolt11.payment_hash().to_string(),
             amount,
             fee: fee.into(),
             state: MeltQuoteState::Unpaid,
         })
     }
 
-    async fn pay_invoice(
+    #[instrument(skip_all)]
+    async fn make_payment(
         &self,
         melt_quote: mint::MeltQuote,
         _partial_msats: Option<Amount>,
         _max_fee_msats: Option<Amount>,
-    ) -> Result<PayInvoiceResponse, Self::Err> {
+    ) -> Result<MakePaymentResponse, Self::Err> {
         let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?;
 
         let payment_hash = bolt11.payment_hash().to_string();
@@ -182,30 +201,26 @@ impl MintLightning for FakeWallet {
                 fail.insert(payment_hash.clone());
             }
 
-            if description.pay_err {
-                return Err(Error::UnknownInvoice.into());
-            }
+            ensure_cdk!(!description.pay_err, Error::UnknownInvoice.into());
         }
 
-        Ok(PayInvoiceResponse {
-            payment_preimage: Some("".to_string()),
+        Ok(MakePaymentResponse {
+            payment_proof: Some("".to_string()),
             payment_lookup_id: payment_hash,
             status: payment_status,
-            total_spent: melt_quote.amount,
+            total_spent: melt_quote.amount + 1.into(),
             unit: melt_quote.unit,
         })
     }
 
-    async fn create_invoice(
+    #[instrument(skip_all)]
+    async fn create_incoming_payment_request(
         &self,
         amount: Amount,
         _unit: &CurrencyUnit,
         description: String,
-        unix_expiry: u64,
-    ) -> Result<CreateInvoiceResponse, Self::Err> {
-        let time_now = unix_time();
-        assert!(unix_expiry > time_now);
-
+        _unix_expiry: Option<u64>,
+    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
         // Since this is fake we just use the amount no matter the unit to create an invoice
         let amount_msat = amount;
 
@@ -231,24 +246,26 @@ impl MintLightning for FakeWallet {
 
         let expiry = invoice.expires_at().map(|t| t.as_secs());
 
-        Ok(CreateInvoiceResponse {
+        Ok(CreateIncomingPaymentResponse {
             request_lookup_id: payment_hash.to_string(),
-            request: invoice,
+            request: invoice.to_string(),
             expiry,
         })
     }
 
-    async fn check_incoming_invoice_status(
+    #[instrument(skip_all)]
+    async fn check_incoming_payment_status(
         &self,
         _request_lookup_id: &str,
     ) -> Result<MintQuoteState, Self::Err> {
         Ok(MintQuoteState::Paid)
     }
 
+    #[instrument(skip_all)]
     async fn check_outgoing_payment(
         &self,
         request_lookup_id: &str,
-    ) -> Result<PayInvoiceResponse, Self::Err> {
+    ) -> Result<MakePaymentResponse, Self::Err> {
         // For fake wallet if the state is not explicitly set default to paid
         let states = self.payment_states.lock().await;
         let status = states.get(request_lookup_id).cloned();
@@ -258,20 +275,21 @@ impl MintLightning for FakeWallet {
         let fail_payments = self.failed_payment_check.lock().await;
 
         if fail_payments.contains(request_lookup_id) {
-            return Err(cdk_lightning::Error::InvoicePaymentPending);
+            return Err(cdk_payment::Error::InvoicePaymentPending);
         }
 
-        Ok(PayInvoiceResponse {
-            payment_preimage: Some("".to_string()),
+        Ok(MakePaymentResponse {
+            payment_proof: Some("".to_string()),
             payment_lookup_id: request_lookup_id.to_string(),
             status,
             total_spent: Amount::ZERO,
-            unit: self.get_settings().unit,
+            unit: CurrencyUnit::Msat,
         })
     }
 }
 
 /// Create fake invoice
+#[instrument]
 pub fn create_fake_invoice(amount_msat: u64, description: String) -> Bolt11Invoice {
     let private_key = SecretKey::from_slice(
         &[

+ 39 - 45
crates/cdk-integration-tests/Cargo.toml

@@ -1,68 +1,62 @@
 [package]
 name = "cdk-integration-tests"
-version = "0.7.0"
-edition = "2021"
+version.workspace = true
+edition.workspace = true
 authors = ["CDK Developers"]
 description = "Core Cashu Development Kit library implementing the Cashu protocol"
-license = "MIT"
+license.workspace = true
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-rust-version = "1.63.0"                                                            # MSRV
+rust-version.workspace = true                                                            # MSRV
 
 
 [features]
 http_subscription = ["cdk/http_subscription"]
 
 [dependencies]
-async-trait = "0.1"
-axum = "0.6.20"
-rand = "0.8.5"
-bip39 = { version = "2.0", features = ["rand"] }
-anyhow = "1"
-cashu = { path = "../cashu", features = ["mint", "wallet"] }
-cdk = { path = "../cdk", features = ["mint", "wallet"] }
-cdk-cln = { path = "../cdk-cln" }
-cdk-lnd = { path = "../cdk-lnd" }
-cdk-axum = { path = "../cdk-axum" }
-cdk-sqlite = { path = "../cdk-sqlite" }
-cdk-redb = { path = "../cdk-redb" }
-cdk-fake-wallet = { path = "../cdk-fake-wallet" }
-tower-http = { version = "0.4.4", features = ["cors"] }
-futures = { version = "0.3.28", default-features = false, features = [
+async-trait.workspace = true
+axum.workspace = true
+rand.workspace = true
+bip39 = { workspace = true, features = ["rand"] }
+anyhow.workspace = true
+cashu = { workspace = true, features = ["mint", "wallet"] }
+cdk = { workspace = true, features = ["mint", "wallet", "auth"] }
+cdk-cln = { workspace = true }
+cdk-lnd = { workspace = true }
+cdk-axum = { workspace = true }
+cdk-sqlite = { workspace = true }
+cdk-redb = { workspace = true }
+cdk-fake-wallet = { workspace = true }
+futures = { workspace = true, default-features = false, features = [
     "executor",
 ] }
-once_cell = "1.19.0"
-uuid = { version = "1", features = ["v4"] }
-serde = "1"
-serde_json = "1"
+once_cell.workspace = true
+uuid.workspace = true
+serde.workspace = true
+serde_json.workspace = true
 # ln-regtest-rs = { path = "../../../../ln-regtest-rs" }
-ln-regtest-rs = { git = "https://github.com/thesimplekid/ln-regtest-rs", rev = "bf09ad6" }
-lightning-invoice = { version = "0.32.0", features = ["serde", "std"] }
-tracing = { version = "0.1", default-features = false, features = [
-    "attributes",
-    "log",
-] }
-tracing-subscriber = { version = "0.3.18", features = ["env-filter"] }
+ln-regtest-rs = { git = "https://github.com/thesimplekid/ln-regtest-rs", rev = "ed24716" }
+lightning-invoice.workspace = true
+tracing.workspace = true
+tracing-subscriber.workspace = true
+tokio-tungstenite.workspace = true
+tower-http = { workspace = true, features = ["cors"] }
 tower-service = "0.3.3"
-tokio-tungstenite = "0.24.0"
+reqwest.workspace = true
+bitcoin = "0.32.0"
 
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
-tokio = { version = "1", features = [
-    "rt-multi-thread",
-    "time",
-    "macros",
-    "sync",
-] }
+tokio.workspace = true
 
 [target.'cfg(target_arch = "wasm32")'.dependencies]
-tokio = { version = "1", features = ["rt", "macros", "sync", "time"] }
+tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] }
 getrandom = { version = "0.2", features = ["js"] }
-instant = { version = "0.1", features = ["wasm-bindgen", "inaccurate"] }
+instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] }
 
 [dev-dependencies]
-bip39 = { version = "2.0", features = ["rand"] }
-anyhow = "1"
-cdk = { path = "../cdk", features = ["mint", "wallet"] }
-cdk-axum = { path = "../cdk-axum" }
-cdk-fake-wallet = { path = "../cdk-fake-wallet" }
-tower-http = { version = "0.4.4", features = ["cors"] }
+bip39 = { workspace = true, features = ["rand"] }
+anyhow.workspace = true
+cdk = { workspace = true, features = ["mint", "wallet"] }
+cdk-axum = { workspace = true }
+cdk-fake-wallet = { workspace = true }
+tower-http = { workspace = true, features = ["cors"] }

+ 102 - 0
crates/cdk-integration-tests/src/init_auth_mint.rs

@@ -0,0 +1,102 @@
+use std::collections::{HashMap, HashSet};
+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::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>(
+    _addr: &str,
+    _port: u16,
+    openid_discovery: String,
+    database: D,
+    auth_database: A,
+) -> Result<()>
+where
+    D: MintDatabase<cdk_database::Error> + Send + Sync + 'static,
+    A: MintAuthDatabase<Err = cdk_database::Error> + Send + Sync + 'static,
+{
+    let fee_reserve = FeeReserve {
+        min_fee_reserve: 1.into(),
+        percent_fee_reserve: 1.0,
+    };
+
+    let fake_wallet = FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0);
+
+    let mut mint_builder = MintBuilder::new();
+
+    mint_builder = mint_builder.with_localstore(Arc::new(database));
+
+    mint_builder = mint_builder
+        .add_ln_backend(
+            CurrencyUnit::Sat,
+            PaymentMethod::Bolt11,
+            MintMeltLimits::new(1, 300),
+            Arc::new(fake_wallet),
+        )
+        .await?;
+
+    mint_builder =
+        mint_builder.set_clear_auth_settings(openid_discovery, "cashu-client".to_string());
+
+    mint_builder = mint_builder.set_blind_auth_settings(50);
+
+    let blind_auth_endpoints = vec![
+        ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt11),
+        ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt11),
+        ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11),
+        ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt11),
+        ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuoteBolt11),
+        ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt11),
+        ProtectedEndpoint::new(Method::Post, RoutePath::Swap),
+        ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate),
+        ProtectedEndpoint::new(Method::Post, RoutePath::Restore),
+    ];
+
+    let blind_auth_endpoints =
+        blind_auth_endpoints
+            .into_iter()
+            .fold(HashMap::new(), |mut acc, e| {
+                acc.insert(e, AuthRequired::Blind);
+                acc
+            });
+
+    auth_database
+        .add_protected_endpoints(blind_auth_endpoints)
+        .await?;
+
+    let mut clear_auth_endpoint = HashMap::new();
+    clear_auth_endpoint.insert(
+        ProtectedEndpoint::new(Method::Post, RoutePath::MintBlindAuth),
+        AuthRequired::Clear,
+    );
+
+    auth_database
+        .add_protected_endpoints(clear_auth_endpoint)
+        .await?;
+
+    mint_builder = mint_builder.with_auth_localstore(Arc::new(auth_database));
+
+    let mnemonic = Mnemonic::generate(12)?;
+
+    mint_builder = mint_builder
+        .with_description("fake test mint".to_string())
+        .with_seed(mnemonic.to_seed_normalized("").to_vec());
+
+    let _mint = mint_builder.build().await?;
+
+    todo!("Need to start this a cdk mintd keeping as ref for now");
+}
+
+pub async fn top_up_blind_auth_proofs(auth_wallet: Arc<AuthWallet>, count: u64) {
+    let _proofs = auth_wallet
+        .mint_blind_auth(count.into())
+        .await
+        .expect("could not mint blind auth");
+}

+ 143 - 40
crates/cdk-integration-tests/src/init_pure_tests.rs

@@ -1,13 +1,16 @@
 use std::collections::{HashMap, HashSet};
 use std::fmt::{Debug, Formatter};
+use std::path::PathBuf;
 use std::str::FromStr;
 use std::sync::Arc;
+use std::{env, fs};
 
+use anyhow::{anyhow, bail, Result};
 use async_trait::async_trait;
 use bip39::Mnemonic;
 use cdk::amount::SplitTarget;
-use cdk::cdk_database::MintDatabase;
-use cdk::mint::{FeeReserve, MintBuilder, MintMeltLimits};
+use cdk::cdk_database::{self, MintDatabase, WalletDatabase};
+use cdk::mint::{MintBuilder, MintMeltLimits};
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{
     CheckStateRequest, CheckStateResponse, CurrencyUnit, Id, KeySet, KeysetResponse,
@@ -15,25 +18,28 @@ use cdk::nuts::{
     MintBolt11Response, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, PaymentMethod,
     RestoreRequest, RestoreResponse, SwapRequest, SwapResponse,
 };
-use cdk::types::QuoteTTL;
+use cdk::types::{FeeReserve, QuoteTTL};
 use cdk::util::unix_time;
-use cdk::wallet::client::MintConnector;
-use cdk::wallet::Wallet;
+use cdk::wallet::{AuthWallet, MintConnector, Wallet, WalletBuilder};
 use cdk::{Amount, Error, Mint};
 use cdk_fake_wallet::FakeWallet;
-use tokio::sync::Notify;
+use tokio::sync::{Notify, RwLock};
 use tracing_subscriber::EnvFilter;
 use uuid::Uuid;
 
 use crate::wait_for_mint_to_be_paid;
 
 pub struct DirectMintConnection {
-    pub mint: Arc<Mint>,
+    pub mint: Mint,
+    auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
 }
 
 impl DirectMintConnection {
-    pub fn new(mint: Arc<Mint>) -> Self {
-        Self { mint }
+    pub fn new(mint: Mint) -> Self {
+        Self {
+            mint,
+            auth_wallet: Arc::new(RwLock::new(None)),
+        }
     }
 }
 
@@ -140,9 +146,21 @@ impl MintConnector for DirectMintConnection {
     async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error> {
         self.mint.restore(request).await
     }
+
+    /// Get the auth wallet for the client
+    async fn get_auth_wallet(&self) -> Option<AuthWallet> {
+        self.auth_wallet.read().await.clone()
+    }
+
+    /// Set auth wallet on client
+    async fn set_auth_wallet(&self, wallet: Option<AuthWallet>) {
+        let mut auth_wallet = self.auth_wallet.write().await;
+
+        *auth_wallet = wallet;
+    }
 }
 
-pub async fn create_and_start_test_mint() -> anyhow::Result<Arc<Mint>> {
+pub fn setup_tracing() {
     let default_filter = "debug";
 
     let sqlx_filter = "sqlx=warn";
@@ -153,15 +171,47 @@ pub async fn create_and_start_test_mint() -> anyhow::Result<Arc<Mint>> {
         default_filter, sqlx_filter, hyper_filter
     ));
 
-    tracing_subscriber::fmt().with_env_filter(env_filter).init();
+    // Ok if successful, Err if already initialized
+    // Allows us to setup tracing at the start of several parallel tests
+    let _ = tracing_subscriber::fmt()
+        .with_env_filter(env_filter)
+        .try_init();
+}
 
+pub async fn create_and_start_test_mint() -> Result<Mint> {
     let mut mint_builder = MintBuilder::new();
 
-    let database = cdk_sqlite::mint::memory::empty()
-        .await
-        .expect("valid db instance");
+    // 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)
+                    .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")
+            }
+        };
 
-    let localstore = Arc::new(database);
     mint_builder = mint_builder.with_localstore(localstore.clone());
 
     let fee_reserve = FeeReserve {
@@ -169,25 +219,28 @@ pub async fn create_and_start_test_mint() -> anyhow::Result<Arc<Mint>> {
         percent_fee_reserve: 1.0,
     };
 
-    let ln_fake_backend = Arc::new(FakeWallet::new(
+    let ln_fake_backend = FakeWallet::new(
         fee_reserve.clone(),
         HashMap::default(),
         HashSet::default(),
         0,
-    ));
-
-    mint_builder = mint_builder.add_ln_backend(
-        CurrencyUnit::Sat,
-        PaymentMethod::Bolt11,
-        MintMeltLimits::new(1, 1_000),
-        ln_fake_backend,
     );
 
+    mint_builder = mint_builder
+        .add_ln_backend(
+            CurrencyUnit::Sat,
+            PaymentMethod::Bolt11,
+            MintMeltLimits::new(1, 10_000),
+            Arc::new(ln_fake_backend),
+        )
+        .await?;
+
     let mnemonic = Mnemonic::generate(12)?;
 
     mint_builder = mint_builder
         .with_name("pure test mint".to_string())
         .with_description("pure test mint".to_string())
+        .with_urls(vec!["https://aaa".to_string()])
         .with_seed(mnemonic.to_seed_normalized("").to_vec());
 
     localstore
@@ -198,44 +251,94 @@ pub async fn create_and_start_test_mint() -> anyhow::Result<Arc<Mint>> {
 
     let mint = mint_builder.build().await?;
 
-    let mint_arc = Arc::new(mint);
-
-    let mint_arc_clone = Arc::clone(&mint_arc);
+    let mint_clone = mint.clone();
     let shutdown = Arc::new(Notify::new());
     tokio::spawn({
         let shutdown = Arc::clone(&shutdown);
-        async move { mint_arc_clone.wait_for_paid_invoices(shutdown).await }
+        async move { mint_clone.wait_for_paid_invoices(shutdown).await }
     });
 
-    Ok(mint_arc)
+    Ok(mint)
 }
 
-pub async fn create_test_wallet_for_mint(mint: Arc<Mint>) -> anyhow::Result<Arc<Wallet>> {
-    let connector = DirectMintConnection::new(mint);
+pub async fn create_test_wallet_for_mint(mint: Mint) -> Result<Wallet> {
+    let connector = DirectMintConnection::new(mint.clone());
+
+    let mint_info = mint.mint_info().await?;
+    let mint_url = mint_info
+        .urls
+        .as_ref()
+        .ok_or(anyhow!("Test mint URLs list is unset"))?
+        .first()
+        .ok_or(anyhow!("Test mint has empty URLs list"))?;
 
     let seed = Mnemonic::generate(12)?.to_seed_normalized("");
-    let mint_url = "http://aa".to_string();
     let unit = CurrencyUnit::Sat;
-    let localstore = cdk_sqlite::wallet::memory::empty()
-        .await
-        .expect("valid db instance");
-    let mut wallet = Wallet::new(&mint_url, unit, Arc::new(localstore), &seed, None)?;
-
-    wallet.set_client(connector);
 
-    Ok(Arc::new(wallet))
+    // Read environment variable to determine database type
+    let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set");
+
+    let localstore: Arc<dyn WalletDatabase<Err = 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-wallet")?;
+                let path = temp_dir.join("wallet.db").to_str().unwrap().to_string();
+                let database = cdk_sqlite::WalletSqliteDatabase::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-wallet")?;
+                let path = temp_dir.join("wallet.redb");
+                let database = cdk_redb::WalletRedbDatabase::new(&path)
+                    .expect("Could not create redb mint database");
+                Arc::new(database)
+            }
+            "memory" => {
+                let database = cdk_sqlite::wallet::memory::empty().await?;
+                Arc::new(database)
+            }
+            _ => {
+                bail!("Db type not set")
+            }
+        };
+
+    let wallet = WalletBuilder::new()
+        .mint_url(mint_url.parse().unwrap())
+        .unit(unit)
+        .localstore(localstore)
+        .seed(&seed)
+        .client(connector)
+        .build()?;
+
+    Ok(wallet)
 }
 
 /// Creates a mint quote for the given amount and checks its state in a loop. Returns when
 /// amount is minted.
-pub async fn fund_wallet(wallet: Arc<Wallet>, amount: u64) -> anyhow::Result<Amount> {
+/// Creates a temporary directory with a unique name based on the prefix
+fn create_temp_dir(prefix: &str) -> Result<PathBuf> {
+    let temp_dir = env::temp_dir();
+    let unique_dir = temp_dir.join(format!("{}-{}", prefix, Uuid::new_v4()));
+    fs::create_dir_all(&unique_dir)?;
+    Ok(unique_dir)
+}
+
+pub async fn fund_wallet(
+    wallet: Wallet,
+    amount: u64,
+    split_target: Option<SplitTarget>,
+) -> Result<Amount> {
     let desired_amount = Amount::from(amount);
     let quote = wallet.mint_quote(desired_amount, None).await?;
 
     wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?;
 
     Ok(wallet
-        .mint(&quote.id, SplitTarget::default(), None)
+        .mint(&quote.id, split_target.unwrap_or_default(), None)
         .await?
         .total_amount()?)
 }

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

@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
 use std::sync::Arc;
 
 use anyhow::Result;
-use cdk::mint::FeeReserve;
+use cdk::types::FeeReserve;
 use cdk_cln::Cln as CdkCln;
 use cdk_lnd::Lnd as CdkLnd;
 use ln_regtest_rs::bitcoin_client::BitcoinClient;
@@ -32,11 +32,11 @@ pub const CLN_ADDR: &str = "127.0.0.1:19846";
 pub const CLN_TWO_ADDR: &str = "127.0.0.1:19847";
 
 pub fn get_mint_addr() -> String {
-    env::var("cdk_itests_mint_addr").expect("Temp dir set")
+    env::var("CDK_ITESTS_MINT_ADDR").expect("Mint address not set")
 }
 
 pub fn get_mint_port(which: &str) -> u16 {
-    let dir = env::var(format!("cdk_itests_mint_port_{}", which)).expect("Temp dir set");
+    let dir = env::var(format!("CDK_ITESTS_MINT_PORT_{}", which)).expect("Mint port not set");
     dir.parse().unwrap()
 }
 
@@ -49,7 +49,7 @@ pub fn get_mint_ws_url(which: &str) -> String {
 }
 
 pub fn get_temp_dir() -> PathBuf {
-    let dir = env::var("cdk_itests").expect("Temp dir set");
+    let dir = env::var("CDK_ITESTS_DIR").expect("Temp dir not set");
     std::fs::create_dir_all(&dir).unwrap();
     dir.parse().expect("Valid path buf")
 }

+ 113 - 20
crates/cdk-integration-tests/src/lib.rs

@@ -1,33 +1,35 @@
+use std::env;
 use std::sync::Arc;
 
-use anyhow::{bail, Result};
+use anyhow::{anyhow, bail, Result};
+use cashu::Bolt11Invoice;
 use cdk::amount::{Amount, SplitTarget};
-use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{MintQuoteState, NotificationPayload, State};
 use cdk::wallet::WalletSubscription;
 use cdk::Wallet;
+use cdk_fake_wallet::create_fake_invoice;
+use init_regtest::{get_lnd_dir, get_mint_url, LND_RPC_ADDR};
+use ln_regtest_rs::ln_client::{LightningClient, LndClient};
 use tokio::time::{sleep, timeout, Duration};
 
+pub mod init_auth_mint;
 pub mod init_pure_tests;
 pub mod init_regtest;
 
-pub async fn wallet_mint(
-    wallet: Arc<Wallet>,
-    amount: Amount,
-    split_target: SplitTarget,
-    description: Option<String>,
-) -> Result<()> {
-    let quote = wallet.mint_quote(amount, description).await?;
-
-    wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?;
-
-    let proofs = wallet.mint(&quote.id, split_target, None).await?;
-
-    let receive_amount = proofs.total_amount()?;
+pub async fn fund_wallet(wallet: Arc<Wallet>, amount: Amount) {
+    let quote = wallet
+        .mint_quote(amount, None)
+        .await
+        .expect("Could not get mint quote");
 
-    println!("Minted: {}", receive_amount);
+    wait_for_mint_to_be_paid(&wallet, &quote.id, 60)
+        .await
+        .expect("Waiting for mint failed");
 
-    Ok(())
+    let _proofs = wallet
+        .mint(&quote.id, SplitTarget::default(), None)
+        .await
+        .expect("Could not mint");
 }
 
 // Get all pending from wallet and attempt to swap
@@ -67,7 +69,6 @@ pub async fn attempt_to_swap_pending(wallet: &Wallet) -> Result<()> {
     Ok(())
 }
 
-#[allow(clippy::incompatible_msrv)]
 pub async fn wait_for_mint_to_be_paid(
     wallet: &Wallet,
     mint_quote_id: &str,
@@ -87,7 +88,7 @@ pub async fn wait_for_mint_to_be_paid(
                 }
             }
         }
-        Ok(())
+        Err(anyhow!("Subscription ended without quote being paid"))
     };
 
     let timeout_future = timeout(Duration::from_secs(timeout_secs), wait_future);
@@ -115,7 +116,7 @@ pub async fn wait_for_mint_to_be_paid(
         result = timeout_future => {
             match result {
                 Ok(payment_result) => payment_result,
-                Err(_) => Err(anyhow::anyhow!("Timeout waiting for mint quote to be paid")),
+                Err(_) => Err(anyhow!("Timeout waiting for mint quote to be paid")),
             }
         }
         result = periodic_task => {
@@ -123,3 +124,95 @@ pub async fn wait_for_mint_to_be_paid(
         }
     }
 }
+
+/// Gets the mint URL from environment variable or falls back to default
+///
+/// Checks the CDK_TEST_MINT_URL environment variable:
+/// - If set, returns that URL
+/// - Otherwise falls back to the default URL from get_mint_url("0")
+pub fn get_mint_url_from_env() -> String {
+    match env::var("CDK_TEST_MINT_URL") {
+        Ok(url) => url,
+        Err(_) => get_mint_url("0"),
+    }
+}
+
+/// Gets the second mint URL from environment variable or falls back to default
+///
+/// Checks the CDK_TEST_MINT_URL_2 environment variable:
+/// - If set, returns that URL
+/// - Otherwise falls back to the default URL from get_mint_url("1")
+pub fn get_second_mint_url_from_env() -> String {
+    match env::var("CDK_TEST_MINT_URL_2") {
+        Ok(url) => url,
+        Err(_) => get_mint_url("1"),
+    }
+}
+
+// This is the ln wallet we use to send/receive ln payements as the wallet
+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()
+}
+
+/// Pays a Bolt11Invoice if it's on the regtest network, otherwise returns Ok
+///
+/// This is useful for tests that need to pay invoices in regtest mode but
+/// should be skipped in other environments.
+pub async fn pay_if_regtest(invoice: &Bolt11Invoice) -> Result<()> {
+    // Check if the invoice is for the regtest network
+    if invoice.network() == bitcoin::Network::Regtest {
+        println!("Regtest invoice");
+        let lnd_client = init_lnd_client().await;
+        lnd_client.pay_invoice(invoice.to_string()).await?;
+        Ok(())
+    } else {
+        // Not a regtest invoice, just return Ok
+        Ok(())
+    }
+}
+
+/// Determines if we're running in regtest mode based on environment variable
+///
+/// Checks the CDK_TEST_REGTEST environment variable:
+/// - If set to "1", "true", or "yes" (case insensitive), returns true
+/// - Otherwise returns false
+pub fn is_regtest_env() -> bool {
+    match env::var("CDK_TEST_REGTEST") {
+        Ok(val) => {
+            let val = val.to_lowercase();
+            val == "1" || val == "true" || val == "yes"
+        }
+        Err(_) => false,
+    }
+}
+
+/// Creates a real invoice if in regtest mode, otherwise returns a fake invoice
+///
+/// Uses the is_regtest_env() function to determine whether to
+/// create a real regtest invoice or a fake one for testing.
+pub async fn create_invoice_for_env(amount_sat: Option<u64>) -> Result<String> {
+    if is_regtest_env() {
+        // In regtest mode, create a real invoice
+        let lnd_client = init_lnd_client().await;
+        lnd_client
+            .create_invoice(amount_sat)
+            .await
+            .map_err(|e| anyhow!("Failed to create regtest invoice: {}", e))
+    } else {
+        // Not in regtest mode, create a fake invoice
+        let fake_invoice = create_fake_invoice(
+            amount_sat.expect("Amount must be defined") * 1_000,
+            "".to_string(),
+        );
+        Ok(fake_invoice.to_string())
+    }
+}

+ 863 - 0
crates/cdk-integration-tests/tests/fake_auth.rs

@@ -0,0 +1,863 @@
+use std::env;
+use std::str::FromStr;
+use std::sync::Arc;
+
+use bip39::Mnemonic;
+use cashu::{MintAuthRequest, MintInfo};
+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,
+};
+use cdk::wallet::{AuthHttpClient, AuthMintConnector, HttpClient, MintConnector, WalletBuilder};
+use cdk::{Error, OidcClient};
+use cdk_fake_wallet::create_fake_invoice;
+use cdk_integration_tests::{fund_wallet, wait_for_mint_to_be_paid};
+use cdk_sqlite::wallet::memory;
+
+const MINT_URL: &str = "http://127.0.0.1:8087";
+const ENV_OIDC_USER: &str = "CDK_TEST_OIDC_USER";
+const ENV_OIDC_PASSWORD: &str = "CDK_TEST_OIDC_PASSWORD";
+
+fn get_oidc_credentials() -> (String, String) {
+    let user = env::var(ENV_OIDC_USER).unwrap_or_else(|_| "test".to_string());
+    let password = env::var(ENV_OIDC_PASSWORD).unwrap_or_else(|_| "test".to_string());
+    (user, password)
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_invalid_credentials() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await
+        .expect("mint info")
+        .expect("could not get mint info");
+
+    // Try to get a token with invalid credentials
+    let token_result =
+        get_custom_access_token(&mint_info, "invalid_user", "invalid_password").await;
+
+    // Should fail with an error
+    assert!(
+        token_result.is_err(),
+        "Expected authentication to fail with invalid credentials"
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_quote_status_without_auth() {
+    let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
+
+    // Test mint quote status
+    {
+        let quote_res = client
+            .get_mint_quote_status("123e4567-e89b-12d3-a456-426614174000")
+            .await;
+
+        assert!(
+            matches!(quote_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            quote_res
+        );
+    }
+
+    // Test melt quote status
+    {
+        let quote_res = client
+            .get_melt_quote_status("123e4567-e89b-12d3-a456-426614174000")
+            .await;
+
+        assert!(
+            matches!(quote_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            quote_res
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_without_auth() {
+    let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
+    {
+        let request = MintQuoteBolt11Request {
+            unit: CurrencyUnit::Sat,
+            amount: 10.into(),
+            description: None,
+            pubkey: None,
+        };
+
+        let quote_res = client.post_mint_quote(request).await;
+
+        assert!(
+            matches!(quote_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            quote_res
+        );
+    }
+
+    {
+        let request = MintBolt11Request {
+            quote: "123e4567-e89b-12d3-a456-426614174000".to_string(),
+            outputs: vec![],
+            signature: None,
+        };
+
+        let mint_res = client.post_mint(request).await;
+
+        assert!(
+            matches!(mint_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            mint_res
+        );
+    }
+
+    {
+        let mint_res = client
+            .get_mint_quote_status("123e4567-e89b-12d3-a456-426614174000")
+            .await;
+
+        assert!(
+            matches!(mint_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            mint_res
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_bat_without_cat() {
+    let client = AuthHttpClient::new(MintUrl::from_str(MINT_URL).expect("valid mint url"), None);
+
+    let res = client
+        .post_mint_blind_auth(MintAuthRequest { outputs: vec![] })
+        .await;
+
+    assert!(
+        matches!(res, Err(Error::ClearAuthRequired)),
+        "Expected AuthRequired error, got {:?}",
+        res
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_without_auth() {
+    let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
+
+    let request = SwapRequest::new(vec![], vec![]);
+
+    let quote_res = client.post_swap(request).await;
+
+    assert!(
+        matches!(quote_res, Err(Error::BlindAuthRequired)),
+        "Expected AuthRequired error, got {:?}",
+        quote_res
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_without_auth() {
+    let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
+
+    // Test melt quote request
+    {
+        let request = MeltQuoteBolt11Request {
+            request: create_fake_invoice(100, "".to_string()),
+            unit: CurrencyUnit::Sat,
+            options: None,
+        };
+
+        let quote_res = client.post_melt_quote(request).await;
+
+        assert!(
+            matches!(quote_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            quote_res
+        );
+    }
+
+    // Test melt quote
+    {
+        let request = MeltQuoteBolt11Request {
+            request: create_fake_invoice(100, "".to_string()),
+            unit: CurrencyUnit::Sat,
+            options: None,
+        };
+
+        let quote_res = client.post_melt_quote(request).await;
+
+        assert!(
+            matches!(quote_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            quote_res
+        );
+    }
+
+    // Test melt
+    {
+        let request = MeltBolt11Request::new(
+            "123e4567-e89b-12d3-a456-426614174000".to_string(),
+            vec![],
+            None,
+        );
+
+        let melt_res = client.post_melt(request).await;
+
+        assert!(
+            matches!(melt_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            melt_res
+        );
+    }
+
+    // Check melt quote state
+    {
+        let melt_res = client
+            .get_melt_quote_status("123e4567-e89b-12d3-a456-426614174000")
+            .await;
+
+        assert!(
+            matches!(melt_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            melt_res
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_check_without_auth() {
+    let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
+
+    let request = CheckStateRequest { ys: vec![] };
+
+    let quote_res = client.post_check_state(request).await;
+
+    assert!(
+        matches!(quote_res, Err(Error::BlindAuthRequired)),
+        "Expected AuthRequired error, got {:?}",
+        quote_res
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_restore_without_auth() {
+    let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
+
+    let request = RestoreRequest { outputs: vec![] };
+
+    let restore_res = client.post_restore(request).await;
+
+    assert!(
+        matches!(restore_res, Err(Error::BlindAuthRequired)),
+        "Expected AuthRequired error, got {:?}",
+        restore_res
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_blind_auth() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+    let mint_info = wallet.get_mint_info().await.unwrap().unwrap();
+
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    wallet.set_cat(access_token).await.unwrap();
+
+    wallet
+        .mint_blind_auth(10.into())
+        .await
+        .expect("Could not mint blind auth");
+
+    let proofs = wallet
+        .get_unspent_auth_proofs()
+        .await
+        .expect("Could not get auth proofs");
+
+    assert!(proofs.len() == 10)
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_with_auth() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await
+        .expect("mint info")
+        .expect("could not get mint info");
+
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    println!("st{}", access_token);
+
+    wallet.set_cat(access_token).await.unwrap();
+
+    wallet
+        .mint_blind_auth(10.into())
+        .await
+        .expect("Could not mint blind auth");
+
+    let wallet = Arc::new(wallet);
+
+    let mint_amount: Amount = 100.into();
+
+    let mint_quote = wallet
+        .mint_quote(mint_amount, None)
+        .await
+        .expect("failed to get mint quote");
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60)
+        .await
+        .expect("failed to wait for payment");
+
+    let proofs = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await
+        .expect("could not mint");
+
+    assert!(proofs.total_amount().expect("Could not get proofs amount") == mint_amount);
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_with_auth() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+    let mint_info = wallet.get_mint_info().await.unwrap().unwrap();
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    wallet.set_cat(access_token).await.unwrap();
+
+    let wallet = Arc::new(wallet);
+
+    wallet.mint_blind_auth(10.into()).await.unwrap();
+
+    fund_wallet(wallet.clone(), 100.into()).await;
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let swapped_proofs = wallet
+        .swap(
+            Some(proofs.total_amount().unwrap()),
+            SplitTarget::default(),
+            proofs.clone(),
+            None,
+            false,
+        )
+        .await
+        .expect("Could not swap")
+        .expect("Could not swap");
+
+    let check_spent = wallet
+        .check_proofs_spent(proofs.clone())
+        .await
+        .expect("Could not check proofs");
+
+    for state in check_spent {
+        if state.state != State::Spent {
+            panic!("Input proofs should be spent");
+        }
+    }
+
+    assert!(swapped_proofs.total_amount().unwrap() == proofs.total_amount().unwrap())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_with_auth() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await
+        .expect("Mint info not found")
+        .expect("Mint info not found");
+
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    wallet.set_cat(access_token).await.unwrap();
+
+    let wallet = Arc::new(wallet);
+
+    wallet.mint_blind_auth(10.into()).await.unwrap();
+
+    fund_wallet(wallet.clone(), 100.into()).await;
+
+    let bolt11 = create_fake_invoice(2_000, "".to_string());
+
+    let melt_quote = wallet
+        .melt_quote(bolt11.to_string(), None)
+        .await
+        .expect("Could not get melt quote");
+
+    let after_melt = wallet.melt(&melt_quote.id).await.expect("Could not melt");
+
+    assert!(after_melt.state == MeltQuoteState::Paid);
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_auth_over_max() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+
+    let wallet = Arc::new(wallet);
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await
+        .expect("Mint info not found")
+        .expect("Mint info not found");
+
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    wallet.set_cat(access_token).await.unwrap();
+
+    let auth_proofs = wallet
+        .mint_blind_auth((mint_info.nuts.nut22.expect("Auth enabled").bat_max_mint + 1).into())
+        .await;
+
+    assert!(
+        matches!(
+            auth_proofs,
+            Err(Error::AmountOutofLimitRange(
+                Amount::ZERO,
+                Amount::ZERO,
+                Amount::ZERO,
+            ))
+        ),
+        "Expected amount out of limit error, got {:?}",
+        auth_proofs
+    );
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_reuse_auth_proof() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+    let mint_info = wallet.get_mint_info().await.unwrap().unwrap();
+
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    wallet.set_cat(access_token).await.unwrap();
+
+    wallet.mint_blind_auth(1.into()).await.unwrap();
+
+    let proofs = wallet
+        .localstore
+        .get_proofs(None, Some(CurrencyUnit::Auth), None, None)
+        .await
+        .unwrap();
+
+    assert!(proofs.len() == 1);
+
+    {
+        let quote = wallet
+            .mint_quote(10.into(), None)
+            .await
+            .expect("Quote should be allowed");
+
+        assert!(quote.amount == 10.into());
+    }
+
+    wallet
+        .localstore
+        .update_proofs(proofs, vec![])
+        .await
+        .unwrap();
+
+    {
+        let quote_res = wallet.mint_quote(10.into(), None).await;
+        assert!(
+            matches!(quote_res, Err(Error::TokenAlreadySpent)),
+            "Expected AuthRequired error, got {:?}",
+            quote_res
+        );
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_with_invalid_auth() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+    let mint_info = wallet.get_mint_info().await.unwrap().unwrap();
+
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    wallet.set_cat(access_token).await.unwrap();
+
+    wallet.mint_blind_auth(10.into()).await.unwrap();
+
+    fund_wallet(Arc::new(wallet.clone()), 1.into()).await;
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("wallet has proofs");
+
+    println!("{:#?}", proofs);
+    let proof = proofs.first().expect("wallet has one proof");
+
+    let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None);
+    {
+        let invalid_auth_proof = AuthProof {
+            keyset_id: proof.keyset_id,
+            secret: proof.secret.clone(),
+            c: proof.c,
+            dleq: proof.dleq.clone(),
+        };
+
+        let _auth_token = AuthToken::BlindAuth(BlindAuthToken::new(invalid_auth_proof));
+
+        let request = MintQuoteBolt11Request {
+            unit: CurrencyUnit::Sat,
+            amount: 10.into(),
+            description: None,
+            pubkey: None,
+        };
+
+        let quote_res = client.post_mint_quote(request).await;
+
+        assert!(
+            matches!(quote_res, Err(Error::BlindAuthRequired)),
+            "Expected AuthRequired error, got {:?}",
+            quote_res
+        );
+    }
+
+    {
+        let (access_token, _) = get_access_token(&mint_info).await;
+
+        wallet.set_cat(access_token).await.unwrap();
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_refresh_access_token() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await
+        .expect("mint info")
+        .expect("could not get mint info");
+
+    let (access_token, refresh_token) = get_access_token(&mint_info).await;
+
+    // Set the initial access token and refresh token
+    wallet.set_cat(access_token.clone()).await.unwrap();
+    wallet
+        .set_refresh_token(refresh_token.clone())
+        .await
+        .unwrap();
+
+    // Mint some blind auth tokens with the initial access token
+    wallet.mint_blind_auth(5.into()).await.unwrap();
+
+    // Refresh the access token
+    wallet.refresh_access_token().await.unwrap();
+
+    // Verify we can still perform operations with the refreshed token
+    let mint_amount: Amount = 10.into();
+
+    // Try to mint more blind auth tokens with the refreshed token
+    let auth_proofs = wallet.mint_blind_auth(5.into()).await.unwrap();
+    assert_eq!(auth_proofs.len(), 5);
+
+    let total_auth_proofs = wallet.get_unspent_auth_proofs().await.unwrap();
+    assert_eq!(total_auth_proofs.len(), 10); // 5 from before refresh + 5 after refresh
+
+    // Try to get a mint quote with the refreshed token
+    let mint_quote = wallet
+        .mint_quote(mint_amount, None)
+        .await
+        .expect("failed to get mint quote with refreshed token");
+
+    assert_eq!(mint_quote.amount, mint_amount);
+
+    // Verify the total number of auth tokens
+    let total_auth_proofs = wallet.get_unspent_auth_proofs().await.unwrap();
+    assert_eq!(total_auth_proofs.len(), 9); // 5 from before refresh + 5 after refresh - 1 for the quote
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_invalid_refresh_token() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await
+        .expect("mint info")
+        .expect("could not get mint info");
+
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    // Set the initial access token
+    wallet.set_cat(access_token.clone()).await.unwrap();
+
+    // Set an invalid refresh token
+    wallet
+        .set_refresh_token("invalid_refresh_token".to_string())
+        .await
+        .unwrap();
+
+    // Attempt to refresh the access token with an invalid refresh token
+    let refresh_result = wallet.refresh_access_token().await;
+
+    // Should fail with an error
+    assert!(refresh_result.is_err(), "Expected refresh token error");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_auth_token_spending_order() {
+    let db = Arc::new(memory::empty().await.unwrap());
+
+    let wallet = WalletBuilder::new()
+        .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url"))
+        .unit(CurrencyUnit::Sat)
+        .localstore(db.clone())
+        .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized(""))
+        .build()
+        .expect("Wallet");
+
+    let mint_info = wallet
+        .get_mint_info()
+        .await
+        .expect("mint info")
+        .expect("could not get mint info");
+
+    let (access_token, _) = get_access_token(&mint_info).await;
+
+    wallet.set_cat(access_token).await.unwrap();
+
+    // Mint auth tokens in two batches to test ordering
+    wallet.mint_blind_auth(2.into()).await.unwrap();
+
+    // Get the first batch of auth proofs
+    let first_batch = wallet.get_unspent_auth_proofs().await.unwrap();
+    assert_eq!(first_batch.len(), 2);
+
+    // Mint a second batch
+    wallet.mint_blind_auth(3.into()).await.unwrap();
+
+    // Get all auth proofs
+    let all_proofs = wallet.get_unspent_auth_proofs().await.unwrap();
+    assert_eq!(all_proofs.len(), 5);
+
+    // Use tokens and verify they're used in the expected order (FIFO)
+    for i in 0..3 {
+        let mint_quote = wallet
+            .mint_quote(10.into(), None)
+            .await
+            .expect("failed to get mint quote");
+
+        assert_eq!(mint_quote.amount, 10.into());
+
+        // Check remaining tokens after each operation
+        let remaining = wallet.get_unspent_auth_proofs().await.unwrap();
+        assert_eq!(
+            remaining.len(),
+            5 - (i + 1),
+            "Expected {} remaining auth tokens after {} operations",
+            5 - (i + 1),
+            i + 1
+        );
+    }
+}
+
+async fn get_access_token(mint_info: &MintInfo) -> (String, String) {
+    let openid_discovery = mint_info
+        .nuts
+        .nut21
+        .clone()
+        .expect("Nutxx defined")
+        .openid_discovery;
+
+    let oidc_client = OidcClient::new(openid_discovery);
+
+    // Get the token endpoint from the OIDC configuration
+    let token_url = oidc_client
+        .get_oidc_config()
+        .await
+        .expect("Failed to get OIDC config")
+        .token_endpoint;
+
+    // Create the request parameters
+    let (user, password) = get_oidc_credentials();
+    let params = [
+        ("grant_type", "password"),
+        ("client_id", "cashu-client"),
+        ("username", &user),
+        ("password", &password),
+    ];
+
+    // Make the token request directly
+    let client = reqwest::Client::new();
+    let response = client
+        .post(token_url)
+        .form(&params)
+        .send()
+        .await
+        .expect("Failed to send token request");
+
+    let token_response: serde_json::Value = response
+        .json()
+        .await
+        .expect("Failed to parse token response");
+
+    let access_token = token_response["access_token"]
+        .as_str()
+        .expect("No access token in response")
+        .to_string();
+
+    let refresh_token = token_response["refresh_token"]
+        .as_str()
+        .expect("No access token in response")
+        .to_string();
+
+    (access_token, refresh_token)
+}
+
+/// Get a new access token with custom credentials
+async fn get_custom_access_token(
+    mint_info: &MintInfo,
+    username: &str,
+    password: &str,
+) -> Result<(String, String), Error> {
+    let openid_discovery = mint_info
+        .nuts
+        .nut21
+        .clone()
+        .expect("Nutxx defined")
+        .openid_discovery;
+
+    let oidc_client = OidcClient::new(openid_discovery);
+
+    // Get the token endpoint from the OIDC configuration
+    let token_url = oidc_client
+        .get_oidc_config()
+        .await
+        .map_err(|_| Error::Custom("Failed to get OIDC config".to_string()))?
+        .token_endpoint;
+
+    // Create the request parameters
+    let params = [
+        ("grant_type", "password"),
+        ("client_id", "cashu-client"),
+        ("username", username),
+        ("password", password),
+    ];
+
+    // Make the token request directly
+    let client = reqwest::Client::new();
+    let response = client
+        .post(token_url)
+        .form(&params)
+        .send()
+        .await
+        .map_err(|_| Error::Custom("Failed to send token request".to_string()))?;
+
+    if !response.status().is_success() {
+        return Err(Error::Custom(format!(
+            "Token request failed with status: {}",
+            response.status()
+        )));
+    }
+
+    let token_response: serde_json::Value = response
+        .json()
+        .await
+        .map_err(|_| Error::Custom("Failed to parse token response".to_string()))?;
+
+    let access_token = token_response["access_token"]
+        .as_str()
+        .ok_or_else(|| Error::Custom("No access token in response".to_string()))?
+        .to_string();
+
+    let refresh_token = token_response["refresh_token"]
+        .as_str()
+        .ok_or_else(|| Error::Custom("No refresh token in response".to_string()))?
+        .to_string();
+
+    Ok((access_token, refresh_token))
+}

+ 88 - 124
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -2,21 +2,22 @@ use std::sync::Arc;
 
 use anyhow::{bail, Result};
 use bip39::Mnemonic;
+use cashu::Amount;
 use cdk::amount::SplitTarget;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{
     CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintBolt11Request, PreMintSecrets, Proofs,
     SecretKey, State, SwapRequest,
 };
-use cdk::wallet::client::{HttpClient, MintConnector};
-use cdk::wallet::Wallet;
+use cdk::wallet::types::TransactionDirection;
+use cdk::wallet::{HttpClient, MintConnector, Wallet};
 use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
 use cdk_integration_tests::{attempt_to_swap_pending, wait_for_mint_to_be_paid};
 use cdk_sqlite::wallet::memory;
 
 const MINT_URL: &str = "http://127.0.0.1:8086";
 
-// If both pay and check return pending input proofs should remain pending
+/// Tests that when both pay and check return pending status, input proofs should remain pending
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_tokens_pending() -> Result<()> {
     let wallet = Wallet::new(
@@ -55,8 +56,8 @@ async fn test_fake_tokens_pending() -> Result<()> {
     Ok(())
 }
 
-// If the pay error fails and the check returns unknown or failed
-// The inputs proofs should be unset as spending
+/// Tests that if the pay error fails and the check returns unknown or failed,
+/// the input proofs should be unset as spending (returned to unspent state)
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_melt_payment_fail() -> Result<()> {
     let wallet = Wallet::new(
@@ -118,8 +119,8 @@ async fn test_fake_melt_payment_fail() -> Result<()> {
     Ok(())
 }
 
-// When both the pay_invoice and check_invoice both fail
-// the proofs should remain as pending
+/// Tests that when both the pay_invoice and check_invoice both fail,
+/// the proofs should remain in pending state
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_melt_payment_fail_and_check() -> Result<()> {
     let wallet = Wallet::new(
@@ -163,8 +164,8 @@ async fn test_fake_melt_payment_fail_and_check() -> Result<()> {
     Ok(())
 }
 
-// In the case that the ln backend returns a failed status but does not error
-// The mint should do a second check, then remove proofs from pending
+/// Tests that when the ln backend returns a failed status but does not error,
+/// the mint should do a second check, then remove proofs from pending state
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_melt_payment_return_fail_status() -> Result<()> {
     let wallet = Wallet::new(
@@ -223,8 +224,8 @@ async fn test_fake_melt_payment_return_fail_status() -> Result<()> {
     Ok(())
 }
 
-// In the case that the ln backend returns a failed status but does not error
-// The mint should do a second check, then remove proofs from pending
+/// Tests that when the ln backend returns an error with unknown status,
+/// the mint should do a second check, then remove proofs from pending state
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_melt_payment_error_unknown() -> Result<()> {
     let wallet = Wallet::new(
@@ -283,9 +284,8 @@ async fn test_fake_melt_payment_error_unknown() -> Result<()> {
     Ok(())
 }
 
-// In the case that the ln backend returns an err
-// The mint should do a second check, that returns paid
-// Proofs should remain pending
+/// Tests that when the ln backend returns an error but the second check returns paid,
+/// proofs should remain in pending state
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_melt_payment_err_paid() -> Result<()> {
     let wallet = Wallet::new(
@@ -324,6 +324,7 @@ async fn test_fake_melt_payment_err_paid() -> Result<()> {
     Ok(())
 }
 
+/// Tests that change outputs in a melt quote are correctly handled
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_melt_change_in_quote() -> Result<()> {
     let wallet = Wallet::new(
@@ -342,6 +343,17 @@ async fn test_fake_melt_change_in_quote() -> Result<()> {
         .mint(&mint_quote.id, SplitTarget::default(), None)
         .await?;
 
+    let transaction = wallet
+        .list_transactions(Some(TransactionDirection::Incoming))
+        .await?
+        .pop()
+        .expect("No transaction found");
+    assert_eq!(wallet.mint_url, transaction.mint_url);
+    assert_eq!(TransactionDirection::Incoming, transaction.direction);
+    assert_eq!(Amount::from(100), transaction.amount);
+    assert_eq!(Amount::from(0), transaction.fee);
+    assert_eq!(CurrencyUnit::Sat, transaction.unit);
+
     let fake_description = FakeInvoiceDescription::default();
 
     let invoice = create_fake_invoice(9000, serde_json::to_string(&fake_description).unwrap());
@@ -354,13 +366,13 @@ async fn test_fake_melt_change_in_quote() -> Result<()> {
 
     let premint_secrets = PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default())?;
 
-    let client = HttpClient::new(MINT_URL.parse()?);
+    let client = HttpClient::new(MINT_URL.parse()?, None);
 
-    let melt_request = MeltBolt11Request {
-        quote: melt_quote.id.clone(),
-        inputs: proofs.clone(),
-        outputs: Some(premint_secrets.blinded_messages()),
-    };
+    let melt_request = MeltBolt11Request::new(
+        melt_quote.id.clone(),
+        proofs.clone(),
+        Some(premint_secrets.blinded_messages()),
+    );
 
     let melt_response = client.post_melt(melt_request).await?;
 
@@ -374,13 +386,15 @@ 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(())
 }
 
+/// Tests that the correct database type is used based on environment variables
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_database_type() -> Result<()> {
     // Get the database type and work dir from environment
-    let db_type = std::env::var("MINT_DATABASE").expect("MINT_DATABASE env var should be set");
+    let db_type = std::env::var("CDK_MINTD_DATABASE").expect("MINT_DATABASE env var should be set");
     let work_dir =
         std::env::var("CDK_MINTD_WORK_DIR").expect("CDK_MINTD_WORK_DIR env var should be set");
 
@@ -412,6 +426,7 @@ async fn test_database_type() -> Result<()> {
     Ok(())
 }
 
+/// Tests minting tokens with a valid witness signature
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_with_witness() -> Result<()> {
     let wallet = Wallet::new(
@@ -436,6 +451,7 @@ async fn test_fake_mint_with_witness() -> Result<()> {
     Ok(())
 }
 
+/// Tests that minting without a witness signature fails with the correct error
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_without_witness() -> Result<()> {
     let wallet = Wallet::new(
@@ -450,7 +466,7 @@ async fn test_fake_mint_without_witness() -> Result<()> {
 
     wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
 
     let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
 
@@ -472,6 +488,7 @@ async fn test_fake_mint_without_witness() -> Result<()> {
     }
 }
 
+/// Tests that minting with an incorrect witness signature fails with the correct error
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_with_wrong_witness() -> Result<()> {
     let wallet = Wallet::new(
@@ -486,7 +503,7 @@ async fn test_fake_mint_with_wrong_witness() -> Result<()> {
 
     wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
 
     let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
 
@@ -512,6 +529,7 @@ async fn test_fake_mint_with_wrong_witness() -> Result<()> {
     }
 }
 
+/// Tests that attempting to mint more tokens than allowed by the quote fails
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_inflated() -> Result<()> {
     let wallet = Wallet::new(
@@ -545,7 +563,7 @@ async fn test_fake_mint_inflated() -> Result<()> {
     if let Some(secret_key) = quote_info.secret_key {
         mint_request.sign(secret_key)?;
     }
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
 
     let response = http_client.post_mint(mint_request.clone()).await;
 
@@ -564,6 +582,7 @@ async fn test_fake_mint_inflated() -> Result<()> {
     Ok(())
 }
 
+/// Tests that attempting to mint with multiple currency units in the same request fails
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_multiple_units() -> Result<()> {
     let wallet = Wallet::new(
@@ -615,7 +634,7 @@ async fn test_fake_mint_multiple_units() -> Result<()> {
     if let Some(secret_key) = quote_info.secret_key {
         mint_request.sign(secret_key)?;
     }
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
 
     let response = http_client.post_mint(mint_request.clone()).await;
 
@@ -634,6 +653,7 @@ async fn test_fake_mint_multiple_units() -> Result<()> {
     Ok(())
 }
 
+/// Tests that attempting to swap tokens with multiple currency units fails
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_multiple_unit_swap() -> Result<()> {
     let wallet = Wallet::new(
@@ -677,12 +697,9 @@ async fn test_fake_mint_multiple_unit_swap() -> Result<()> {
         let pre_mint =
             PreMintSecrets::random(active_keyset_id, inputs.total_amount()?, &SplitTarget::None)?;
 
-        let swap_request = SwapRequest {
-            inputs,
-            outputs: pre_mint.blinded_messages(),
-        };
+        let swap_request = SwapRequest::new(inputs, pre_mint.blinded_messages());
 
-        let http_client = HttpClient::new(MINT_URL.parse()?);
+        let http_client = HttpClient::new(MINT_URL.parse()?, None);
         let response = http_client.post_swap(swap_request.clone()).await;
 
         match response {
@@ -714,12 +731,9 @@ async fn test_fake_mint_multiple_unit_swap() -> Result<()> {
 
         usd_outputs.append(&mut sat_outputs);
 
-        let swap_request = SwapRequest {
-            inputs,
-            outputs: usd_outputs,
-        };
+        let swap_request = SwapRequest::new(inputs, usd_outputs);
 
-        let http_client = HttpClient::new(MINT_URL.parse()?);
+        let http_client = HttpClient::new(MINT_URL.parse()?, None);
         let response = http_client.post_swap(swap_request.clone()).await;
 
         match response {
@@ -738,6 +752,7 @@ async fn test_fake_mint_multiple_unit_swap() -> Result<()> {
     Ok(())
 }
 
+/// Tests that attempting to melt tokens with multiple currency units fails
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_multiple_unit_melt() -> Result<()> {
     let wallet = Wallet::new(
@@ -787,13 +802,9 @@ async fn test_fake_mint_multiple_unit_melt() -> Result<()> {
         let invoice = create_fake_invoice((input_amount - 1) * 1000, "".to_string());
         let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
 
-        let melt_request = MeltBolt11Request {
-            quote: melt_quote.id,
-            inputs,
-            outputs: None,
-        };
+        let melt_request = MeltBolt11Request::new(melt_quote.id, inputs, None);
 
-        let http_client = HttpClient::new(MINT_URL.parse()?);
+        let http_client = HttpClient::new(MINT_URL.parse()?, None);
         let response = http_client.post_melt(melt_request.clone()).await;
 
         match response {
@@ -831,13 +842,9 @@ async fn test_fake_mint_multiple_unit_melt() -> Result<()> {
         usd_outputs.append(&mut sat_outputs);
         let quote = wallet.melt_quote(invoice.to_string(), None).await?;
 
-        let melt_request = MeltBolt11Request {
-            quote: quote.id,
-            inputs,
-            outputs: Some(usd_outputs),
-        };
+        let melt_request = MeltBolt11Request::new(quote.id, inputs, Some(usd_outputs));
 
-        let http_client = HttpClient::new(MINT_URL.parse()?);
+        let http_client = HttpClient::new(MINT_URL.parse()?, None);
 
         let response = http_client.post_melt(melt_request.clone()).await;
 
@@ -857,7 +864,7 @@ async fn test_fake_mint_multiple_unit_melt() -> Result<()> {
     Ok(())
 }
 
-/// Test swap where input unit != output unit
+/// Tests that swapping tokens where input unit doesn't match output unit fails
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_input_output_mismatch() -> Result<()> {
     let wallet = Wallet::new(
@@ -891,12 +898,9 @@ async fn test_fake_mint_input_output_mismatch() -> Result<()> {
         &SplitTarget::None,
     )?;
 
-    let swap_request = SwapRequest {
-        inputs,
-        outputs: pre_mint.blinded_messages(),
-    };
+    let swap_request = SwapRequest::new(inputs, pre_mint.blinded_messages());
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     match response {
@@ -912,7 +916,7 @@ async fn test_fake_mint_input_output_mismatch() -> Result<()> {
     Ok(())
 }
 
-/// Test swap where input is less the output
+/// Tests that swapping tokens where output amount is greater than input amount fails
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_swap_inflated() -> Result<()> {
     let wallet = Wallet::new(
@@ -931,12 +935,9 @@ async fn test_fake_mint_swap_inflated() -> Result<()> {
     let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
     let pre_mint = PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None)?;
 
-    let swap_request = SwapRequest {
-        inputs: proofs,
-        outputs: pre_mint.blinded_messages(),
-    };
+    let swap_request = SwapRequest::new(proofs, pre_mint.blinded_messages());
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     match response {
@@ -954,7 +955,7 @@ async fn test_fake_mint_swap_inflated() -> Result<()> {
     Ok(())
 }
 
-/// Test swap after failure
+/// Tests that tokens cannot be spent again after a failed swap attempt
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_swap_spend_after_fail() -> Result<()> {
     let wallet = Wallet::new(
@@ -974,49 +975,33 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> {
 
     let pre_mint = PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None)?;
 
-    let swap_request = SwapRequest {
-        inputs: proofs.clone(),
-        outputs: pre_mint.blinded_messages(),
-    };
+    let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages());
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     assert!(response.is_ok());
 
     let pre_mint = PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None)?;
 
-    let swap_request = SwapRequest {
-        inputs: proofs.clone(),
-        outputs: pre_mint.blinded_messages(),
-    };
+    let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages());
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     match response {
         Err(err) => match err {
-            cdk::Error::TokenAlreadySpent => (),
-            err => {
-                bail!(
-                    "Wrong mint error returned expected already spent: {}",
-                    err.to_string()
-                );
-            }
+            cdk::Error::TransactionUnbalanced(_, _, _) => (),
+            err => bail!("Wrong mint error returned expected TransactionUnbalanced, got: {err}"),
         },
-        Ok(_) => {
-            bail!("Should not have allowed swap with unbalanced");
-        }
+        Ok(_) => bail!("Should not have allowed swap with unbalanced"),
     }
 
     let pre_mint = PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None)?;
 
-    let swap_request = SwapRequest {
-        inputs: proofs,
-        outputs: pre_mint.blinded_messages(),
-    };
+    let swap_request = SwapRequest::new(proofs, pre_mint.blinded_messages());
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     match response {
@@ -1034,7 +1019,7 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> {
     Ok(())
 }
 
-/// Test swap after failure
+/// Tests that tokens cannot be melted after a failed swap attempt
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_melt_spend_after_fail() -> Result<()> {
     let wallet = Wallet::new(
@@ -1054,49 +1039,35 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> {
 
     let pre_mint = PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None)?;
 
-    let swap_request = SwapRequest {
-        inputs: proofs.clone(),
-        outputs: pre_mint.blinded_messages(),
-    };
+    let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages());
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     assert!(response.is_ok());
 
     let pre_mint = PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None)?;
 
-    let swap_request = SwapRequest {
-        inputs: proofs.clone(),
-        outputs: pre_mint.blinded_messages(),
-    };
+    let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages());
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     match response {
         Err(err) => match err {
-            cdk::Error::TokenAlreadySpent => (),
-            err => {
-                bail!("Wrong mint error returned: {}", err.to_string());
-            }
+            cdk::Error::TransactionUnbalanced(_, _, _) => (),
+            err => bail!("Wrong mint error returned expected TransactionUnbalanced, got: {err}"),
         },
-        Ok(_) => {
-            bail!("Should not have allowed to mint with multiple units");
-        }
+        Ok(_) => bail!("Should not have allowed swap with unbalanced"),
     }
 
     let input_amount: u64 = proofs.total_amount()?.into();
     let invoice = create_fake_invoice((input_amount - 1) * 1000, "".to_string());
     let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
 
-    let melt_request = MeltBolt11Request {
-        quote: melt_quote.id,
-        inputs: proofs,
-        outputs: None,
-    };
+    let melt_request = MeltBolt11Request::new(melt_quote.id, proofs, None);
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_melt(melt_request.clone()).await;
 
     match response {
@@ -1114,7 +1085,7 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> {
     Ok(())
 }
 
-/// Test swap where input unit != output unit
+/// Tests that attempting to swap with duplicate proofs fails
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> {
     let wallet = Wallet::new(
@@ -1138,12 +1109,9 @@ async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> {
     let pre_mint =
         PreMintSecrets::random(active_keyset_id, inputs.total_amount()?, &SplitTarget::None)?;
 
-    let swap_request = SwapRequest {
-        inputs: inputs.clone(),
-        outputs: pre_mint.blinded_messages(),
-    };
+    let swap_request = SwapRequest::new(inputs.clone(), pre_mint.blinded_messages());
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     match response {
@@ -1165,9 +1133,9 @@ async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> {
 
     let outputs = vec![blinded_message[0].clone(), blinded_message[0].clone()];
 
-    let swap_request = SwapRequest { inputs, outputs };
+    let swap_request = SwapRequest::new(inputs, outputs);
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_swap(swap_request.clone()).await;
 
     match response {
@@ -1188,7 +1156,7 @@ async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> {
     Ok(())
 }
 
-/// Test duplicate proofs in melt
+/// Tests that attempting to melt with duplicate proofs fails
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_fake_mint_duplicate_proofs_melt() -> Result<()> {
     let wallet = Wallet::new(
@@ -1211,13 +1179,9 @@ async fn test_fake_mint_duplicate_proofs_melt() -> Result<()> {
 
     let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
 
-    let melt_request = MeltBolt11Request {
-        quote: melt_quote.id,
-        inputs,
-        outputs: None,
-    };
+    let melt_request = MeltBolt11Request::new(melt_quote.id, inputs, None);
 
-    let http_client = HttpClient::new(MINT_URL.parse()?);
+    let http_client = HttpClient::new(MINT_URL.parse()?, None);
     let response = http_client.post_melt(melt_request.clone()).await;
 
     match response {

+ 431 - 0
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -0,0 +1,431 @@
+//! Integration tests for mint-wallet interactions that should work across all mint implementations
+//!
+//! These tests verify the core functionality of the wallet-mint interaction protocol,
+//! including minting, melting, and wallet restoration. They are designed to be
+//! implementation-agnostic and should pass against any compliant Cashu mint,
+//! including Nutshell, CDK, and other implementations that follow the Cashu NUTs.
+//!
+//! The tests use environment variables to determine which mint to connect to and
+//! whether to use real Lightning Network payments (regtest mode) or simulated payments.
+
+use core::panic;
+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 cdk::amount::{Amount, SplitTarget};
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::{CurrencyUnit, MeltQuoteState, NotificationPayload, State};
+use cdk::wallet::{HttpClient, MintConnector, Wallet};
+use cdk_integration_tests::{
+    create_invoice_for_env, get_mint_url_from_env, pay_if_regtest, wait_for_mint_to_be_paid,
+};
+use cdk_sqlite::wallet::memory;
+use futures::{SinkExt, StreamExt};
+use lightning_invoice::Bolt11Invoice;
+use serde_json::json;
+use tokio::time::timeout;
+use tokio_tungstenite::connect_async;
+use tokio_tungstenite::tungstenite::protocol::Message;
+
+async fn get_notification<T: StreamExt<Item = Result<Message, E>> + Unpin, E: Debug>(
+    reader: &mut T,
+    timeout_to_wait: Duration,
+) -> (String, NotificationPayload<String>) {
+    let msg = timeout(timeout_to_wait, reader.next())
+        .await
+        .expect("timeout")
+        .unwrap()
+        .unwrap();
+
+    let mut response: serde_json::Value =
+        serde_json::from_str(msg.to_text().unwrap()).expect("valid json");
+
+    let mut params_raw = response
+        .as_object_mut()
+        .expect("object")
+        .remove("params")
+        .expect("valid params");
+
+    let params_map = params_raw.as_object_mut().expect("params is object");
+
+    (
+        params_map
+            .remove("subId")
+            .unwrap()
+            .as_str()
+            .unwrap()
+            .to_string(),
+        serde_json::from_value(params_map.remove("payload").unwrap()).unwrap(),
+    )
+}
+
+/// Tests a complete mint-melt round trip with WebSocket notifications
+///
+/// This test verifies the full lifecycle of tokens:
+/// 1. Creates a mint quote and pays the invoice
+/// 2. Mints tokens and verifies the correct amount
+/// 3. Creates a melt quote to spend tokens
+/// 4. Subscribes to WebSocket notifications for the melt process
+/// 5. Executes the melt and verifies the payment was successful
+/// 6. Validates all WebSocket notifications received during the process
+///
+/// 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<()> {
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let (ws_stream, _) = connect_async(format!(
+        "{}/v1/ws",
+        get_mint_url_from_env().replace("http", "ws")
+    ))
+    .await
+    .expect("Failed to connect");
+    let (mut write, mut reader) = ws_stream.split();
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
+    pay_if_regtest(&invoice).await.unwrap();
+
+    let proofs = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let mint_amount = proofs.total_amount()?;
+
+    assert!(mint_amount == 100.into());
+
+    let invoice = create_invoice_for_env(Some(50)).await.unwrap();
+
+    let melt = wallet.melt_quote(invoice, None).await?;
+
+    write
+        .send(Message::Text(
+            serde_json::to_string(&json!({
+                    "jsonrpc": "2.0",
+                    "id": 2,
+                    "method": "subscribe",
+                    "params": {
+                      "kind": "bolt11_melt_quote",
+                      "filters": [
+                        melt.id.clone(),
+                      ],
+                      "subId": "test-sub",
+                    }
+
+            }))?
+            .into(),
+        ))
+        .await?;
+
+    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}"#
+    );
+
+    let melt_response = wallet.melt(&melt.id).await.unwrap();
+    assert!(melt_response.preimage.is_some());
+    assert!(melt_response.state == MeltQuoteState::Paid);
+
+    let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
+    // first message is the current state
+    assert_eq!("test-sub", sub_id);
+    let payload = match payload {
+        NotificationPayload::MeltQuoteBolt11Response(melt) => melt,
+        _ => panic!("Wrong payload"),
+    };
+
+    // assert_eq!(payload.amount + payload.fee_reserve, 50.into());
+    assert_eq!(payload.quote.to_string(), melt.id);
+    assert_eq!(payload.state, MeltQuoteState::Unpaid);
+
+    // get current state
+    let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
+    assert_eq!("test-sub", sub_id);
+    let payload = match payload {
+        NotificationPayload::MeltQuoteBolt11Response(melt) => melt,
+        _ => panic!("Wrong payload"),
+    };
+    assert_eq!(payload.quote.to_string(), melt.id);
+    assert_eq!(payload.state, MeltQuoteState::Pending);
+
+    // get current state
+    let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
+    assert_eq!("test-sub", sub_id);
+    let payload = match payload {
+        NotificationPayload::MeltQuoteBolt11Response(melt) => melt,
+        _ => panic!("Wrong payload"),
+    };
+    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
+///
+/// This test focuses on the core minting process:
+/// 1. Creates a mint quote for a specific amount (100 sats)
+/// 2. Verifies the quote has the correct amount
+/// 3. Pays the invoice (or simulates payment in non-regtest environments)
+/// 4. Waits for the mint to recognize the payment
+/// 5. Mints tokens and verifies the correct amount was received
+///
+/// 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<()> {
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let mint_amount = Amount::from(100);
+
+    let mint_quote = wallet.mint_quote(mint_amount, None).await?;
+
+    assert_eq!(mint_quote.amount, mint_amount);
+
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
+    pay_if_regtest(&invoice).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+
+    let proofs = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let mint_amount = proofs.total_amount()?;
+
+    assert!(mint_amount == 100.into());
+
+    Ok(())
+}
+
+/// Tests wallet restoration and proof state verification
+///
+/// This test verifies the wallet restoration process:
+/// 1. Creates a wallet with a specific seed and mints tokens
+/// 2. Verifies the wallet has the expected balance
+/// 3. Creates a new wallet instance with the same seed but empty storage
+/// 4. Confirms the new wallet starts with zero balance
+/// 5. Restores the wallet state from the mint
+/// 6. Swaps the proofs to ensure they're valid
+/// 7. Verifies the restored wallet has the correct balance
+/// 8. Checks that the original proofs are now marked as spent
+///
+/// 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("");
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &seed,
+        None,
+    )?;
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
+    pay_if_regtest(&invoice).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+
+    let _mint_amount = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    assert_eq!(wallet.total_balance().await?, 100.into());
+
+    let wallet_2 = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &seed,
+        None,
+    )?;
+
+    assert_eq!(wallet_2.total_balance().await?, 0.into());
+
+    let restored = wallet_2.restore().await?;
+    let proofs = wallet_2.get_unspent_proofs().await?;
+
+    let expected_fee = wallet.get_proofs_fee(&proofs).await?;
+    wallet_2
+        .swap(None, SplitTarget::default(), proofs, None, false)
+        .await?;
+
+    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?,
+        Amount::from(100) - expected_fee
+    );
+
+    let proofs = wallet.get_unspent_proofs().await?;
+
+    let states = wallet.check_proofs_spent(proofs).await?;
+
+    for state in states {
+        if state.state != State::Spent {
+            bail!("All proofs should be spent");
+        }
+    }
+
+    Ok(())
+}
+
+/// Tests that change outputs in a melt quote are correctly handled
+///
+/// This test verifies the following workflow:
+/// 1. Mint 100 sats of tokens
+/// 2. Create a melt quote for 9 sats (which requires 100 sats input with 91 sats change)
+/// 3. Manually construct a melt request with proofs and blinded messages for change
+/// 4. Verify that the change proofs in the response match what's reported by the quote status
+///
+/// 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<()> {
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    let bolt11 = Bolt11Invoice::from_str(&mint_quote.request)?;
+
+    pay_if_regtest(&bolt11).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+
+    let _mint_amount = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let invoice = create_invoice_for_env(Some(9)).await?;
+
+    let proofs = wallet.get_unspent_proofs().await?;
+
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
+
+    let keyset = wallet.get_active_mint_keyset().await?;
+
+    let premint_secrets = PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default())?;
+
+    let client = HttpClient::new(get_mint_url_from_env().parse()?, None);
+
+    let melt_request = MeltBolt11Request::new(
+        melt_quote.id.clone(),
+        proofs.clone(),
+        Some(premint_secrets.blinded_messages()),
+    );
+
+    let melt_response = client.post_melt(melt_request).await?;
+
+    assert!(melt_response.change.is_some());
+
+    let check = wallet.melt_quote_status(&melt_quote.id).await?;
+    let mut melt_change = melt_response.change.unwrap();
+    melt_change.sort_by(|a, b| a.amount.cmp(&b.amount));
+
+    let mut check = check.change.unwrap();
+    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<()> {
+    let ln_backend = match env::var("LN_BACKEND") {
+        Ok(val) => Some(val),
+        Err(_) => env::var("CDK_MINTD_LN_BACKEND").ok(),
+    };
+
+    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(());
+    }
+
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    pay_if_regtest(&mint_quote.request.parse()?).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+
+    let proofs = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let mint_amount = proofs.total_amount()?;
+
+    assert_eq!(mint_amount, 100.into());
+
+    let invoice = create_invoice_for_env(Some(25)).await?;
+
+    let melt_quote = wallet.melt_quote(invoice.clone(), None).await?;
+
+    let melt = wallet.melt(&melt_quote.id).await.unwrap();
+
+    let melt_two = wallet.melt_quote(invoice, None).await?;
+
+    let melt_two = wallet.melt(&melt_two.id).await;
+
+    match melt_two {
+        Err(err) => match err {
+            cdk::Error::RequestAlreadyPaid => (),
+            err => {
+                bail!("Wrong invoice already paid: {}", err.to_string());
+            }
+        },
+        Ok(_) => {
+            bail!("Should not have allowed second payment");
+        }
+    }
+
+    let balance = wallet.total_balance().await?;
+
+    assert_eq!(balance, (Amount::from(100) - melt.fee_paid - melt.amount));
+
+    Ok(())
+}

+ 906 - 24
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -1,45 +1,927 @@
+//! This file contains integration tests for the Cashu Development Kit (CDK)
+//!
+//! These tests verify the interaction between mint and wallet components, simulating real-world usage scenarios.
+//! They test the complete flow of operations including wallet funding, token swapping, sending tokens between wallets,
+//! and other operations that require client-mint interaction.
+
 use std::assert_eq;
+use std::collections::{HashMap, HashSet};
+use std::hash::RandomState;
+use std::str::FromStr;
 
-use cdk::amount::SplitTarget;
+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,
+};
+use cdk::mint::Mint;
 use cdk::nuts::nut00::ProofsMethods;
-use cdk::wallet::SendKind;
+use cdk::subscription::{IndexableParams, Params};
+use cdk::wallet::types::{TransactionDirection, TransactionId};
+use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions};
 use cdk::Amount;
-use cdk_integration_tests::init_pure_tests::{
-    create_and_start_test_mint, create_test_wallet_for_mint, fund_wallet,
-};
+use cdk_fake_wallet::create_fake_invoice;
+use cdk_integration_tests::init_pure_tests::*;
 
+/// Tests the token swap and send functionality:
+/// 1. Alice gets funded with 64 sats
+/// 2. Alice prepares to send 40 sats (which requires internal swapping)
+/// 3. Alice sends the token
+/// 4. Carol receives the token and has the correct balance
 #[tokio::test]
-async fn test_swap_to_send() -> anyhow::Result<()> {
-    let mint_bob = create_and_start_test_mint().await?;
-    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone()).await?;
+async fn test_swap_to_send() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
 
     // Alice gets 64 sats
-    fund_wallet(wallet_alice.clone(), 64).await?;
-    let balance_alice = wallet_alice.total_balance().await?;
+    fund_wallet(wallet_alice.clone(), 64, None)
+        .await
+        .expect("Failed to fund wallet");
+    let balance_alice = wallet_alice
+        .total_balance()
+        .await
+        .expect("Failed to get balance");
     assert_eq!(Amount::from(64), balance_alice);
 
     // Alice wants to send 40 sats, which internally swaps
+    let prepared_send = wallet_alice
+        .prepare_send(Amount::from(40), SendOptions::default())
+        .await
+        .expect("Failed to prepare send");
+    assert_eq!(
+        HashSet::<_, RandomState>::from_iter(
+            prepared_send.proofs().ys().expect("Failed to get ys")
+        ),
+        HashSet::from_iter(
+            wallet_alice
+                .get_reserved_proofs()
+                .await
+                .expect("Failed to get reserved proofs")
+                .ys()
+                .expect("Failed to get ys")
+        )
+    );
     let token = wallet_alice
         .send(
-            Amount::from(40),
-            None,
-            None,
-            &SplitTarget::None,
-            &SendKind::OnlineExact,
-            false,
+            prepared_send,
+            Some(SendMemo::for_token("test_swapt_to_send")),
+        )
+        .await
+        .expect("Failed to send token");
+    assert_eq!(
+        Amount::from(40),
+        token
+            .proofs()
+            .total_amount()
+            .expect("Failed to get total amount")
+    );
+    assert_eq!(
+        Amount::from(24),
+        wallet_alice
+            .total_balance()
+            .await
+            .expect("Failed to get balance")
+    );
+    assert_eq!(
+        HashSet::<_, RandomState>::from_iter(token.proofs().ys().expect("Failed to get ys")),
+        HashSet::from_iter(
+            wallet_alice
+                .get_pending_spent_proofs()
+                .await
+                .expect("Failed to get pending spent proofs")
+                .ys()
+                .expect("Failed to get ys")
         )
-        .await?;
-    assert_eq!(Amount::from(40), token.proofs().total_amount()?);
-    assert_eq!(Amount::from(24), wallet_alice.total_balance().await?);
+    );
+
+    let transaction_id = TransactionId::from_proofs(token.proofs()).expect("Failed to get tx id");
+
+    let transaction = wallet_alice
+        .get_transaction(transaction_id)
+        .await
+        .expect("Failed to get transaction")
+        .expect("Transaction not found");
+    assert_eq!(wallet_alice.mint_url, transaction.mint_url);
+    assert_eq!(TransactionDirection::Outgoing, transaction.direction);
+    assert_eq!(Amount::from(40), transaction.amount);
+    assert_eq!(Amount::from(0), transaction.fee);
+    assert_eq!(CurrencyUnit::Sat, transaction.unit);
+    assert_eq!(token.proofs().ys().unwrap(), transaction.ys);
 
     // Alice sends cashu, Carol receives
-    let wallet_carol = create_test_wallet_for_mint(mint_bob.clone()).await?;
+    let wallet_carol = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create Carol's wallet");
     let received_amount = wallet_carol
-        .receive_proofs(token.proofs(), SplitTarget::None, &[], &[])
-        .await?;
+        .receive_proofs(
+            token.proofs(),
+            ReceiveOptions::default(),
+            token.memo().clone(),
+        )
+        .await
+        .expect("Failed to receive proofs");
 
     assert_eq!(Amount::from(40), received_amount);
-    assert_eq!(Amount::from(40), wallet_carol.total_balance().await?);
+    assert_eq!(
+        Amount::from(40),
+        wallet_carol
+            .total_balance()
+            .await
+            .expect("Failed to get Carol's balance")
+    );
+
+    let transaction = wallet_carol
+        .get_transaction(transaction_id)
+        .await
+        .expect("Failed to get transaction")
+        .expect("Transaction not found");
+    assert_eq!(wallet_carol.mint_url, transaction.mint_url);
+    assert_eq!(TransactionDirection::Incoming, transaction.direction);
+    assert_eq!(Amount::from(40), transaction.amount);
+    assert_eq!(Amount::from(0), transaction.fee);
+    assert_eq!(CurrencyUnit::Sat, transaction.unit);
+    assert_eq!(token.proofs().ys().unwrap(), transaction.ys);
+    assert_eq!(token.memo().clone(), transaction.memo);
+}
+
+/// Tests the NUT-06 functionality (mint discovery):
+/// 1. Alice gets funded with 64 sats
+/// 2. Verifies the initial mint URL is in the mint info
+/// 3. Updates the mint URL to a new value
+/// 4. Verifies the wallet balance is maintained after changing the mint URL
+#[tokio::test]
+async fn test_mint_nut06() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let mut wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 64 sats
+    fund_wallet(wallet_alice.clone(), 64, None)
+        .await
+        .expect("Failed to fund wallet");
+    let balance_alice = wallet_alice
+        .total_balance()
+        .await
+        .expect("Failed to get balance");
+    assert_eq!(Amount::from(64), balance_alice);
+
+    let transaction = wallet_alice
+        .list_transactions(None)
+        .await
+        .expect("Failed to list transactions")
+        .pop()
+        .expect("No transactions found");
+    assert_eq!(wallet_alice.mint_url, transaction.mint_url);
+    assert_eq!(TransactionDirection::Incoming, transaction.direction);
+    assert_eq!(Amount::from(64), transaction.amount);
+    assert_eq!(Amount::from(0), transaction.fee);
+    assert_eq!(CurrencyUnit::Sat, transaction.unit);
+
+    let initial_mint_url = wallet_alice.mint_url.clone();
+    let mint_info_before = wallet_alice
+        .get_mint_info()
+        .await
+        .expect("Failed to get mint info")
+        .unwrap();
+    assert!(mint_info_before
+        .urls
+        .unwrap()
+        .contains(&initial_mint_url.to_string()));
+
+    // Wallet updates mint URL
+    let new_mint_url = MintUrl::from_str("https://new-mint-url").expect("Failed to parse mint URL");
+    wallet_alice
+        .update_mint_url(new_mint_url.clone())
+        .await
+        .expect("Failed to update mint URL");
+
+    // Check balance after mint URL was updated
+    let balance_alice_after = wallet_alice
+        .total_balance()
+        .await
+        .expect("Failed to get balance after URL update");
+    assert_eq!(Amount::from(64), balance_alice_after);
+}
+
+/// Attempt to double spend proofs on swap
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_double_spend() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 64 sats
+    fund_wallet(wallet_alice.clone(), 64, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keys = mint_bob
+        .pubkeys()
+        .await
+        .unwrap()
+        .keysets
+        .first()
+        .unwrap()
+        .clone()
+        .keys;
+    let keyset_id = Id::from(&keys);
+
+    let preswap = PreMintSecrets::random(
+        keyset_id,
+        proofs.total_amount().unwrap(),
+        &SplitTarget::default(),
+    )
+    .unwrap();
+
+    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
+
+    let swap = mint_bob.process_swap_request(swap_request).await;
+    assert!(swap.is_ok());
+
+    let preswap_two = PreMintSecrets::random(
+        keyset_id,
+        proofs.total_amount().unwrap(),
+        &SplitTarget::default(),
+    )
+    .unwrap();
+
+    let swap_two_request = SwapRequest::new(proofs, preswap_two.blinded_messages());
+
+    match mint_bob.process_swap_request(swap_two_request).await {
+        Ok(_) => panic!("Proofs double spent"),
+        Err(err) => match err {
+            cdk::Error::TokenAlreadySpent => (),
+            _ => panic!("Wrong error returned"),
+        },
+    }
+}
+
+/// This attempts to swap for more outputs then inputs.
+/// This will work if the mint does not check for outputs amounts overflowing
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_attempt_to_swap_by_overflowing() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 64 sats
+    fund_wallet(wallet_alice.clone(), 64, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let amount = 2_u64.pow(63);
+
+    let keys = mint_bob
+        .pubkeys()
+        .await
+        .unwrap()
+        .keysets
+        .first()
+        .unwrap()
+        .clone()
+        .keys;
+    let keyset_id = Id::from(&keys);
+
+    let pre_mint_amount =
+        PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap();
+    let pre_mint_amount_two =
+        PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap();
+
+    let mut pre_mint =
+        PreMintSecrets::random(keyset_id, 1.into(), &SplitTarget::default()).unwrap();
+
+    pre_mint.combine(pre_mint_amount);
+    pre_mint.combine(pre_mint_amount_two);
+
+    let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages());
+
+    match mint_bob.process_swap_request(swap_request).await {
+        Ok(_) => panic!("Swap occurred with overflow"),
+        Err(err) => match err {
+            cdk::Error::NUT03(cdk::nuts::nut03::Error::Amount(_)) => (),
+            cdk::Error::AmountOverflow => (),
+            cdk::Error::AmountError(_) => (),
+            _ => {
+                println!("{:?}", err);
+                panic!("Wrong error returned in swap overflow")
+            }
+        },
+    }
+}
+
+/// Tests that the mint correctly rejects unbalanced swap requests:
+/// 1. Attempts to swap for less than the input amount (95 < 100)
+/// 2. Attempts to swap for more than the input amount (101 > 100)
+/// 3. Both should fail with TransactionUnbalanced error
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_unbalanced() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 100 sats
+    fund_wallet(wallet_alice.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint_bob).await;
+
+    // Try to swap for less than the input amount (95 < 100)
+    let preswap = PreMintSecrets::random(keyset_id, 95.into(), &SplitTarget::default())
+        .expect("Failed to create preswap");
+
+    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
+
+    match mint_bob.process_swap_request(swap_request).await {
+        Ok(_) => panic!("Swap was allowed unbalanced"),
+        Err(err) => match err {
+            cdk::Error::TransactionUnbalanced(_, _, _) => (),
+            _ => panic!("Wrong error returned"),
+        },
+    }
+
+    // Try to swap for more than the input amount (101 > 100)
+    let preswap = PreMintSecrets::random(keyset_id, 101.into(), &SplitTarget::default())
+        .expect("Failed to create preswap");
+
+    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
+
+    match mint_bob.process_swap_request(swap_request).await {
+        Ok(_) => panic!("Swap was allowed unbalanced"),
+        Err(err) => match err {
+            cdk::Error::TransactionUnbalanced(_, _, _) => (),
+            _ => panic!("Wrong error returned"),
+        },
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+pub async fn test_p2pk_swap() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 100 sats
+    fund_wallet(wallet_alice.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint_bob).await;
+
+    let secret = SecretKey::generate();
+
+    let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None);
+
+    let pre_swap = PreMintSecrets::with_conditions(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &spending_conditions,
+    )
+    .unwrap();
+
+    let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages());
+
+    let keys = mint_bob
+        .pubkeys()
+        .await
+        .unwrap()
+        .keysets
+        .first()
+        .cloned()
+        .unwrap()
+        .keys;
+
+    let post_swap = mint_bob.process_swap_request(swap_request).await.unwrap();
+
+    let mut proofs = construct_proofs(
+        post_swap.signatures,
+        pre_swap.rs(),
+        pre_swap.secrets(),
+        &keys,
+    )
+    .unwrap();
+
+    let pre_swap = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()).unwrap();
+
+    let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages());
+
+    // Listen for status updates on all input proof pks
+    let public_keys_to_listen: Vec<_> = swap_request
+        .inputs()
+        .ys()
+        .unwrap()
+        .iter()
+        .map(|pk| pk.to_string())
+        .collect();
+
+    let mut listener = mint_bob
+        .pubsub_manager
+        .try_subscribe::<IndexableParams>(
+            Params {
+                kind: cdk::nuts::nut17::Kind::ProofState,
+                filters: public_keys_to_listen.clone(),
+                id: "test".into(),
+            }
+            .into(),
+        )
+        .await
+        .expect("valid subscription");
+
+    match mint_bob.process_swap_request(swap_request).await {
+        Ok(_) => panic!("Proofs spent without sig"),
+        Err(err) => match err {
+            cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided) => (),
+            _ => {
+                println!("{:?}", err);
+                panic!("Wrong error returned")
+            }
+        },
+    }
+
+    for proof in &mut proofs {
+        proof.sign_p2pk(secret.clone()).unwrap();
+    }
+
+    let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages());
+
+    let attempt_swap = mint_bob.process_swap_request(swap_request).await;
+
+    assert!(attempt_swap.is_ok());
+
+    let mut msgs = HashMap::new();
+    while let Ok((sub_id, msg)) = listener.try_recv() {
+        assert_eq!(sub_id, "test".into());
+        match msg {
+            NotificationPayload::ProofState(ProofState { y, state, .. }) => {
+                msgs.entry(y.to_string())
+                    .or_insert_with(Vec::new)
+                    .push(state);
+            }
+            _ => panic!("Wrong message received"),
+        }
+    }
+
+    for keys in public_keys_to_listen {
+        let statuses = msgs.remove(&keys).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!(listener.try_recv().is_err(), "no other event is happening");
+    assert!(msgs.is_empty(), "Only expected key events are received");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_overpay_underpay_fee() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    mint_bob
+        .rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new())
+        .await
+        .unwrap();
+
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 100 sats
+    fund_wallet(wallet_alice.clone(), 1000, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keys = mint_bob
+        .pubkeys()
+        .await
+        .unwrap()
+        .keysets
+        .first()
+        .unwrap()
+        .clone()
+        .keys;
+    let keyset_id = Id::from(&keys);
+
+    let preswap = PreMintSecrets::random(keyset_id, 9998.into(), &SplitTarget::default()).unwrap();
+
+    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
+
+    // Attempt to swap overpaying fee
+    match mint_bob.process_swap_request(swap_request).await {
+        Ok(_) => panic!("Swap was allowed unbalanced"),
+        Err(err) => match err {
+            cdk::Error::TransactionUnbalanced(_, _, _) => (),
+            _ => {
+                println!("{:?}", err);
+                panic!("Wrong error returned")
+            }
+        },
+    }
+
+    let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default()).unwrap();
+
+    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
+
+    // Attempt to swap underpaying fee
+    match mint_bob.process_swap_request(swap_request).await {
+        Ok(_) => panic!("Swap was allowed unbalanced"),
+        Err(err) => match err {
+            cdk::Error::TransactionUnbalanced(_, _, _) => (),
+            _ => {
+                println!("{:?}", err);
+                panic!("Wrong error returned")
+            }
+        },
+    }
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_enforce_fee() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    mint_bob
+        .rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new())
+        .await
+        .unwrap();
+
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 100 sats
+    fund_wallet(
+        wallet_alice.clone(),
+        1010,
+        Some(SplitTarget::Value(Amount::ONE)),
+    )
+    .await
+    .expect("Failed to fund wallet");
+
+    let mut proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keys = mint_bob
+        .pubkeys()
+        .await
+        .unwrap()
+        .keysets
+        .first()
+        .unwrap()
+        .clone()
+        .keys;
+    let keyset_id = Id::from(&keys);
+
+    let five_proofs: Vec<_> = proofs.drain(..5).collect();
+
+    let preswap = PreMintSecrets::random(keyset_id, 5.into(), &SplitTarget::default()).unwrap();
+
+    let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages());
+
+    // Attempt to swap underpaying fee
+    match mint_bob.process_swap_request(swap_request).await {
+        Ok(_) => panic!("Swap was allowed unbalanced"),
+        Err(err) => match err {
+            cdk::Error::TransactionUnbalanced(_, _, _) => (),
+            _ => {
+                println!("{:?}", err);
+                panic!("Wrong error returned")
+            }
+        },
+    }
+
+    let preswap = PreMintSecrets::random(keyset_id, 4.into(), &SplitTarget::default()).unwrap();
+
+    let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages());
+
+    let res = mint_bob.process_swap_request(swap_request).await;
+
+    assert!(res.is_ok());
+
+    let thousnad_proofs: Vec<_> = proofs.drain(..1001).collect();
+
+    let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default()).unwrap();
+
+    let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages());
+
+    // Attempt to swap underpaying fee
+    match mint_bob.process_swap_request(swap_request).await {
+        Ok(_) => panic!("Swap was allowed unbalanced"),
+        Err(err) => match err {
+            cdk::Error::TransactionUnbalanced(_, _, _) => (),
+            _ => {
+                println!("{:?}", err);
+                panic!("Wrong error returned")
+            }
+        },
+    }
+
+    let preswap = PreMintSecrets::random(keyset_id, 999.into(), &SplitTarget::default()).unwrap();
+
+    let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages());
+
+    let _ = mint_bob.process_swap_request(swap_request).await.unwrap();
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_mint_change_with_fee_melt() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+
+    mint_bob
+        .rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new())
+        .await
+        .unwrap();
+
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 100 sats
+    fund_wallet(
+        wallet_alice.clone(),
+        100,
+        Some(SplitTarget::Value(Amount::ONE)),
+    )
+    .await
+    .expect("Failed to fund wallet");
+
+    let proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let fake_invoice = create_fake_invoice(1000, "".to_string());
+
+    let melt_quote = wallet_alice
+        .melt_quote(fake_invoice.to_string(), None)
+        .await
+        .unwrap();
+
+    let w = wallet_alice
+        .melt_proofs(&melt_quote.id, proofs)
+        .await
+        .unwrap();
+
+    assert_eq!(w.change.unwrap().total_amount().unwrap(), 97.into());
+}
+/// Tests concurrent double-spending attempts by trying to use the same proofs
+/// in 3 swap transactions simultaneously using tokio tasks
+#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
+async fn test_concurrent_double_spend_swap() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 100 sats
+    fund_wallet(wallet_alice.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint_bob).await;
+
+    // Create 3 identical swap requests with the same proofs
+    let preswap1 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())
+        .expect("Failed to create preswap");
+    let swap_request1 = SwapRequest::new(proofs.clone(), preswap1.blinded_messages());
+
+    let preswap2 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())
+        .expect("Failed to create preswap");
+    let swap_request2 = SwapRequest::new(proofs.clone(), preswap2.blinded_messages());
+
+    let preswap3 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())
+        .expect("Failed to create preswap");
+    let swap_request3 = SwapRequest::new(proofs.clone(), preswap3.blinded_messages());
+
+    // Spawn 3 concurrent tasks to process the swap requests
+    let mint_clone1 = mint_bob.clone();
+    let mint_clone2 = mint_bob.clone();
+    let mint_clone3 = mint_bob.clone();
+
+    let task1 = tokio::spawn(async move { mint_clone1.process_swap_request(swap_request1).await });
+
+    let task2 = tokio::spawn(async move { mint_clone2.process_swap_request(swap_request2).await });
+
+    let task3 = tokio::spawn(async move { mint_clone3.process_swap_request(swap_request3).await });
+
+    // Wait for all tasks to complete
+    let results = tokio::try_join!(task1, task2, task3).expect("Tasks failed to complete");
+
+    // Count successes and failures
+    let mut success_count = 0;
+    let mut token_already_spent_count = 0;
+
+    for result in [results.0, results.1, results.2] {
+        match result {
+            Ok(_) => success_count += 1,
+            Err(err) => match err {
+                cdk::Error::TokenAlreadySpent | cdk::Error::TokenPending => {
+                    token_already_spent_count += 1
+                }
+                other_err => panic!("Unexpected error: {:?}", other_err),
+            },
+        }
+    }
+
+    // Only one swap should succeed, the other two should fail with TokenAlreadySpent
+    assert_eq!(1, success_count, "Expected exactly one successful swap");
+    assert_eq!(
+        2, token_already_spent_count,
+        "Expected exactly two TokenAlreadySpent errors"
+    );
+
+    // Verify that all proofs are marked as spent in the mint
+    let states = mint_bob
+        .localstore
+        .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::<Vec<_>>())
+        .await
+        .expect("Failed to get proof state");
+
+    for state in states {
+        assert_eq!(
+            State::Spent,
+            state.expect("Known state"),
+            "Expected proof to be marked as spent, but got {:?}",
+            state
+        );
+    }
+}
+
+/// Tests concurrent double-spending attempts by trying to use the same proofs
+/// in 3 melt transactions simultaneously using tokio tasks
+#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
+async fn test_concurrent_double_spend_melt() {
+    setup_tracing();
+    let mint_bob = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet_alice = create_test_wallet_for_mint(mint_bob.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Alice gets 100 sats
+    fund_wallet(wallet_alice.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet_alice
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    // Create a Lightning invoice for the melt
+    let invoice = create_fake_invoice(1000, "".to_string());
+
+    // Create a melt quote
+    let melt_quote = wallet_alice
+        .melt_quote(invoice.to_string(), None)
+        .await
+        .expect("Failed to create melt quote");
+
+    // Get the quote ID and payment request
+    let quote_id = melt_quote.id.clone();
+
+    // Create 3 identical melt requests with the same proofs
+    let mint_clone1 = mint_bob.clone();
+    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_request2 = melt_request.clone();
+    let melt_request3 = melt_request.clone();
+
+    // Spawn 3 concurrent tasks to process the melt requests
+    let task1 = tokio::spawn(async move { mint_clone1.melt_bolt11(&melt_request).await });
+
+    let task2 = tokio::spawn(async move { mint_clone2.melt_bolt11(&melt_request2).await });
+
+    let task3 = tokio::spawn(async move { mint_clone3.melt_bolt11(&melt_request3).await });
+
+    // Wait for all tasks to complete
+    let results = tokio::try_join!(task1, task2, task3).expect("Tasks failed to complete");
+
+    // Count successes and failures
+    let mut success_count = 0;
+    let mut token_already_spent_count = 0;
+
+    for result in [results.0, results.1, results.2] {
+        match result {
+            Ok(_) => success_count += 1,
+            Err(err) => match err {
+                cdk::Error::TokenAlreadySpent | cdk::Error::TokenPending => {
+                    token_already_spent_count += 1;
+                    println!("Got expected error: {:?}", err);
+                }
+                other_err => {
+                    println!("Got unexpected error: {:?}", other_err);
+                    token_already_spent_count += 1;
+                }
+            },
+        }
+    }
+
+    // Only one melt should succeed, the other two should fail
+    assert_eq!(1, success_count, "Expected exactly one successful melt");
+    assert_eq!(
+        2, token_already_spent_count,
+        "Expected exactly two TokenAlreadySpent errors"
+    );
+
+    // Verify that all proofs are marked as spent in the mint
+    let states = mint_bob
+        .localstore
+        .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::<Vec<_>>())
+        .await
+        .expect("Failed to get proof state");
+
+    for state in states {
+        assert_eq!(
+            State::Spent,
+            state.expect("Known state"),
+            "Expected proof to be marked as spent, but got {:?}",
+            state
+        );
+    }
+}
 
-    Ok(())
+async fn get_keyset_id(mint: &Mint) -> Id {
+    let keys = mint
+        .pubkeys()
+        .await
+        .unwrap()
+        .keysets
+        .first()
+        .unwrap()
+        .clone()
+        .keys;
+    Id::from(&keys)
 }

+ 16 - 435
crates/cdk-integration-tests/tests/mint.rs

@@ -1,444 +1,23 @@
 //! Mint tests
+//!
+//! This file contains tests that focus on the mint's internal functionality without client interaction.
+//! These tests verify the mint's behavior in isolation, such as keyset management, database operations,
+//! and other mint-specific functionality that doesn't require wallet clients.
 
 use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
-use std::time::Duration;
 
-use anyhow::{bail, Result};
+use anyhow::Result;
 use bip39::Mnemonic;
-use cdk::amount::{Amount, SplitTarget};
 use cdk::cdk_database::MintDatabase;
-use cdk::dhke::construct_proofs;
-use cdk::mint::{FeeReserve, MintBuilder, MintMeltLimits, MintQuote};
-use cdk::nuts::nut00::ProofsMethods;
-use cdk::nuts::{
-    CurrencyUnit, Id, MintBolt11Request, MintInfo, NotificationPayload, Nuts, PaymentMethod,
-    PreMintSecrets, ProofState, Proofs, SecretKey, SpendingConditions, State, SwapRequest,
-};
-use cdk::subscription::{IndexableParams, Params};
-use cdk::types::QuoteTTL;
-use cdk::util::unix_time;
-use cdk::Mint;
+use cdk::mint::{MintBuilder, MintMeltLimits};
+use cdk::nuts::{CurrencyUnit, PaymentMethod};
+use cdk::types::{FeeReserve, QuoteTTL};
 use cdk_fake_wallet::FakeWallet;
 use cdk_sqlite::mint::memory;
-use tokio::time::sleep;
 
 pub const MINT_URL: &str = "http://127.0.0.1:8088";
 
-async fn new_mint(fee: u64) -> Mint {
-    let mut supported_units = HashMap::new();
-    supported_units.insert(CurrencyUnit::Sat, (fee, 32));
-
-    let nuts = Nuts::new()
-        .nut07(true)
-        .nut08(true)
-        .nut09(true)
-        .nut10(true)
-        .nut11(true)
-        .nut12(true)
-        .nut14(true);
-
-    let mint_info = MintInfo::new().nuts(nuts);
-
-    let localstore = memory::empty().await.expect("valid db instance");
-
-    localstore
-        .set_mint_info(mint_info)
-        .await
-        .expect("Could not set mint info");
-    let mnemonic = Mnemonic::generate(12).unwrap();
-
-    Mint::new(
-        &mnemonic.to_seed_normalized(""),
-        Arc::new(localstore),
-        HashMap::new(),
-        supported_units,
-        HashMap::new(),
-    )
-    .await
-    .unwrap()
-}
-
-async fn initialize() -> Mint {
-    new_mint(0).await
-}
-
-async fn mint_proofs(
-    mint: &Mint,
-    amount: Amount,
-    split_target: &SplitTarget,
-    keys: cdk::nuts::Keys,
-) -> Result<Proofs> {
-    let request_lookup = uuid::Uuid::new_v4().to_string();
-
-    let quote = MintQuote::new(
-        "".to_string(),
-        CurrencyUnit::Sat,
-        amount,
-        unix_time() + 36000,
-        request_lookup.to_string(),
-        None,
-    );
-
-    mint.localstore.add_mint_quote(quote.clone()).await?;
-
-    mint.pay_mint_quote_for_request_id(&request_lookup).await?;
-    let keyset_id = Id::from(&keys);
-
-    let premint = PreMintSecrets::random(keyset_id, amount, split_target)?;
-
-    let mint_request = MintBolt11Request {
-        quote: quote.id,
-        outputs: premint.blinded_messages(),
-        signature: None,
-    };
-
-    let after_mint = mint.process_mint_request(mint_request).await?;
-
-    let proofs = construct_proofs(
-        after_mint.signatures,
-        premint.rs(),
-        premint.secrets(),
-        &keys,
-    )?;
-
-    Ok(proofs)
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_mint_double_spend() -> Result<()> {
-    let mint = initialize().await;
-
-    let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys;
-    let keyset_id = Id::from(&keys);
-
-    let proofs = mint_proofs(&mint, 100.into(), &SplitTarget::default(), keys).await?;
-
-    let preswap = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
-
-    let swap = mint.process_swap_request(swap_request).await;
-
-    assert!(swap.is_ok());
-
-    let preswap_two = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())?;
-
-    let swap_two_request = SwapRequest::new(proofs, preswap_two.blinded_messages());
-
-    match mint.process_swap_request(swap_two_request).await {
-        Ok(_) => bail!("Proofs double spent"),
-        Err(err) => match err {
-            cdk::Error::TokenAlreadySpent => (),
-            _ => bail!("Wrong error returned"),
-        },
-    }
-
-    Ok(())
-}
-
-/// This attempts to swap for more outputs then inputs.
-/// This will work if the mint does not check for outputs amounts overflowing
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_attempt_to_swap_by_overflowing() -> Result<()> {
-    let mint = initialize().await;
-
-    let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys;
-    let keyset_id = Id::from(&keys);
-
-    let proofs = mint_proofs(&mint, 100.into(), &SplitTarget::default(), keys).await?;
-
-    let amount = 2_u64.pow(63);
-
-    let pre_mint_amount =
-        PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default())?;
-    let pre_mint_amount_two =
-        PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default())?;
-
-    let mut pre_mint = PreMintSecrets::random(keyset_id, 1.into(), &SplitTarget::default())?;
-
-    pre_mint.combine(pre_mint_amount);
-    pre_mint.combine(pre_mint_amount_two);
-
-    let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages());
-
-    match mint.process_swap_request(swap_request).await {
-        Ok(_) => bail!("Swap occurred with overflow"),
-        Err(err) => match err {
-            cdk::Error::NUT03(cdk::nuts::nut03::Error::Amount(_)) => (),
-            cdk::Error::AmountOverflow => (),
-            cdk::Error::AmountError(_) => (),
-            _ => {
-                println!("{:?}", err);
-                bail!("Wrong error returned in swap overflow")
-            }
-        },
-    }
-
-    Ok(())
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-pub async fn test_p2pk_swap() -> Result<()> {
-    let mint = initialize().await;
-
-    let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys;
-    let keyset_id = Id::from(&keys);
-
-    let proofs = mint_proofs(&mint, 100.into(), &SplitTarget::default(), keys).await?;
-
-    let secret = SecretKey::generate();
-
-    let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None);
-
-    let pre_swap = PreMintSecrets::with_conditions(
-        keyset_id,
-        100.into(),
-        &SplitTarget::default(),
-        &spending_conditions,
-    )?;
-
-    let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages());
-
-    let keys = mint.pubkeys().await?.keysets.first().cloned().unwrap().keys;
-
-    let post_swap = mint.process_swap_request(swap_request).await?;
-
-    let mut proofs = construct_proofs(
-        post_swap.signatures,
-        pre_swap.rs(),
-        pre_swap.secrets(),
-        &keys,
-    )?;
-
-    let pre_swap = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages());
-
-    let public_keys_to_listen: Vec<_> = swap_request
-        .inputs
-        .ys()
-        .expect("key")
-        .into_iter()
-        .enumerate()
-        .filter_map(|(key, pk)| {
-            if key % 2 == 0 {
-                // Only expect messages from every other key
-                Some(pk.to_string())
-            } else {
-                None
-            }
-        })
-        .collect();
-
-    let mut listener = mint
-        .pubsub_manager
-        .try_subscribe::<IndexableParams>(
-            Params {
-                kind: cdk::nuts::nut17::Kind::ProofState,
-                filters: public_keys_to_listen.clone(),
-                id: "test".into(),
-            }
-            .into(),
-        )
-        .await
-        .expect("valid subscription");
-
-    match mint.process_swap_request(swap_request).await {
-        Ok(_) => bail!("Proofs spent without sig"),
-        Err(err) => match err {
-            cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided) => (),
-            _ => {
-                println!("{:?}", err);
-                bail!("Wrong error returned")
-            }
-        },
-    }
-
-    for proof in &mut proofs {
-        proof.sign_p2pk(secret.clone())?;
-    }
-
-    let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages());
-
-    let attempt_swap = mint.process_swap_request(swap_request).await;
-
-    assert!(attempt_swap.is_ok());
-
-    sleep(Duration::from_millis(10)).await;
-
-    let mut msgs = HashMap::new();
-    while let Ok((sub_id, msg)) = listener.try_recv() {
-        assert_eq!(sub_id, "test".into());
-        match msg {
-            NotificationPayload::ProofState(ProofState { y, state, .. }) => {
-                let pk = y.to_string();
-                msgs.get_mut(&pk)
-                    .map(|x: &mut Vec<State>| {
-                        x.push(state);
-                    })
-                    .unwrap_or_else(|| {
-                        msgs.insert(pk, vec![state]);
-                    });
-            }
-            _ => bail!("Wrong message received"),
-        }
-    }
-
-    for keys in public_keys_to_listen {
-        let statuses = msgs.remove(&keys).expect("some events");
-        assert_eq!(statuses, vec![State::Pending, State::Pending, State::Spent]);
-    }
-
-    assert!(listener.try_recv().is_err(), "no other event is happening");
-    assert!(msgs.is_empty(), "Only expected key events are received");
-
-    Ok(())
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_swap_unbalanced() -> Result<()> {
-    let mint = initialize().await;
-
-    let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys;
-    let keyset_id = Id::from(&keys);
-
-    let proofs = mint_proofs(&mint, 100.into(), &SplitTarget::default(), keys).await?;
-
-    let preswap = PreMintSecrets::random(keyset_id, 95.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
-
-    match mint.process_swap_request(swap_request).await {
-        Ok(_) => bail!("Swap was allowed unbalanced"),
-        Err(err) => match err {
-            cdk::Error::TransactionUnbalanced(_, _, _) => (),
-            _ => bail!("Wrong error returned"),
-        },
-    }
-
-    let preswap = PreMintSecrets::random(keyset_id, 101.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
-
-    match mint.process_swap_request(swap_request).await {
-        Ok(_) => bail!("Swap was allowed unbalanced"),
-        Err(err) => match err {
-            cdk::Error::TransactionUnbalanced(_, _, _) => (),
-            _ => bail!("Wrong error returned"),
-        },
-    }
-
-    Ok(())
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_swap_overpay_underpay_fee() -> Result<()> {
-    let mint = new_mint(1).await;
-
-    mint.rotate_keyset(CurrencyUnit::Sat, 1, 32, 1, &HashMap::new())
-        .await?;
-
-    let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys;
-    let keyset_id = Id::from(&keys);
-
-    let proofs = mint_proofs(&mint, 1000.into(), &SplitTarget::default(), keys).await?;
-
-    let preswap = PreMintSecrets::random(keyset_id, 9998.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
-
-    // Attempt to swap overpaying fee
-    match mint.process_swap_request(swap_request).await {
-        Ok(_) => bail!("Swap was allowed unbalanced"),
-        Err(err) => match err {
-            cdk::Error::TransactionUnbalanced(_, _, _) => (),
-            _ => {
-                println!("{:?}", err);
-                bail!("Wrong error returned")
-            }
-        },
-    }
-
-    let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
-
-    // Attempt to swap underpaying fee
-    match mint.process_swap_request(swap_request).await {
-        Ok(_) => bail!("Swap was allowed unbalanced"),
-        Err(err) => match err {
-            cdk::Error::TransactionUnbalanced(_, _, _) => (),
-            _ => {
-                println!("{:?}", err);
-                bail!("Wrong error returned")
-            }
-        },
-    }
-
-    Ok(())
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_mint_enforce_fee() -> Result<()> {
-    let mint = new_mint(1).await;
-
-    let keys = mint.pubkeys().await?.keysets.first().unwrap().clone().keys;
-    let keyset_id = Id::from(&keys);
-
-    let mut proofs = mint_proofs(&mint, 1010.into(), &SplitTarget::Value(1.into()), keys).await?;
-
-    let five_proofs: Vec<_> = proofs.drain(..5).collect();
-
-    let preswap = PreMintSecrets::random(keyset_id, 5.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages());
-
-    // Attempt to swap underpaying fee
-    match mint.process_swap_request(swap_request).await {
-        Ok(_) => bail!("Swap was allowed unbalanced"),
-        Err(err) => match err {
-            cdk::Error::TransactionUnbalanced(_, _, _) => (),
-            _ => {
-                println!("{:?}", err);
-                bail!("Wrong error returned")
-            }
-        },
-    }
-
-    let preswap = PreMintSecrets::random(keyset_id, 4.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages());
-
-    let _ = mint.process_swap_request(swap_request).await?;
-
-    let thousnad_proofs: Vec<_> = proofs.drain(..1001).collect();
-
-    let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages());
-
-    // Attempt to swap underpaying fee
-    match mint.process_swap_request(swap_request).await {
-        Ok(_) => bail!("Swap was allowed unbalanced"),
-        Err(err) => match err {
-            cdk::Error::TransactionUnbalanced(_, _, _) => (),
-            _ => {
-                println!("{:?}", err);
-                bail!("Wrong error returned")
-            }
-        },
-    }
-
-    let preswap = PreMintSecrets::random(keyset_id, 999.into(), &SplitTarget::default())?;
-
-    let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages());
-
-    let _ = mint.process_swap_request(swap_request).await?;
-
-    Ok(())
-}
-
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_correct_keyset() -> Result<()> {
     let mnemonic = Mnemonic::generate(12)?;
@@ -455,12 +34,14 @@ async fn test_correct_keyset() -> Result<()> {
     let localstore = Arc::new(database);
     mint_builder = mint_builder.with_localstore(localstore.clone());
 
-    mint_builder = mint_builder.add_ln_backend(
-        CurrencyUnit::Sat,
-        PaymentMethod::Bolt11,
-        MintMeltLimits::new(1, 5_000),
-        Arc::new(fake_wallet),
-    );
+    mint_builder = mint_builder
+        .add_ln_backend(
+            CurrencyUnit::Sat,
+            PaymentMethod::Bolt11,
+            MintMeltLimits::new(1, 5_000),
+            Arc::new(fake_wallet),
+        )
+        .await?;
 
     mint_builder = mint_builder
         .with_name("regtest mint".to_string())

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

@@ -0,0 +1,252 @@
+use std::time::Duration;
+
+use cdk_fake_wallet::create_fake_invoice;
+use reqwest::Client;
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use tokio::time::sleep;
+
+/// Response from the invoice creation endpoint
+#[derive(Debug, Serialize, Deserialize)]
+struct InvoiceResponse {
+    payment_request: String,
+    checking_id: Option<String>,
+}
+
+/// Maximum number of attempts to check invoice payment status
+const MAX_PAYMENT_CHECK_ATTEMPTS: u8 = 20;
+/// Delay between payment status checks in milliseconds
+const PAYMENT_CHECK_DELAY_MS: u64 = 500;
+/// Default test amount in satoshis
+const DEFAULT_TEST_AMOUNT: u64 = 10000;
+
+/// Helper function to mint tokens via Lightning invoice
+async fn mint_tokens(base_url: &str, amount: u64) -> String {
+    let client = Client::new();
+
+    // Create an invoice for the specified amount
+    let invoice_url = format!("{}/lightning/create_invoice?amount={}", base_url, amount);
+
+    let invoice_response = client
+        .post(&invoice_url)
+        .send()
+        .await
+        .expect("Failed to send invoice creation request")
+        .json::<InvoiceResponse>()
+        .await
+        .expect("Failed to parse invoice response");
+
+    println!("Created invoice: {}", invoice_response.payment_request);
+
+    invoice_response.payment_request
+}
+
+/// Helper function to wait for payment confirmation
+async fn wait_for_payment_confirmation(base_url: &str, payment_request: &str) {
+    let client = Client::new();
+    let check_url = format!(
+        "{}/lightning/invoice_state?payment_request={}",
+        base_url, payment_request
+    );
+
+    let mut payment_confirmed = false;
+
+    for attempt in 1..=MAX_PAYMENT_CHECK_ATTEMPTS {
+        println!(
+            "Checking invoice state (attempt {}/{})...",
+            attempt, MAX_PAYMENT_CHECK_ATTEMPTS
+        );
+
+        let response = client
+            .get(&check_url)
+            .send()
+            .await
+            .expect("Failed to send payment check request");
+
+        if response.status().is_success() {
+            let state: Value = response
+                .json()
+                .await
+                .expect("Failed to parse payment state response");
+            println!("Payment state: {:?}", state);
+
+            if let Some(result) = state.get("result") {
+                if result == 1 {
+                    payment_confirmed = true;
+                    break;
+                }
+            }
+        } else {
+            println!("Failed to check payment state: {}", response.status());
+        }
+
+        sleep(Duration::from_millis(PAYMENT_CHECK_DELAY_MS)).await;
+    }
+
+    if !payment_confirmed {
+        panic!("Payment not confirmed after maximum attempts");
+    }
+}
+
+/// Helper function to get the current wallet balance
+async fn get_wallet_balance(base_url: &str) -> u64 {
+    let client = Client::new();
+    let balance_url = format!("{}/balance", base_url);
+
+    let balance_response = client
+        .get(&balance_url)
+        .send()
+        .await
+        .expect("Failed to send balance request");
+
+    let balance: Value = balance_response
+        .json()
+        .await
+        .expect("Failed to parse balance response");
+
+    println!("Wallet balance: {:?}", balance);
+
+    balance["balance"]
+        .as_u64()
+        .expect("Could not parse balance as u64")
+}
+
+/// Test the Nutshell wallet's ability to mint tokens from a Lightning invoice
+#[tokio::test]
+async fn test_nutshell_wallet_mint() {
+    // Get the wallet URL from environment variable
+    let base_url = std::env::var("WALLET_URL").expect("Wallet url is not set");
+
+    // Step 1: Create an invoice and mint tokens
+    let amount = DEFAULT_TEST_AMOUNT;
+    let payment_request = mint_tokens(&base_url, amount).await;
+
+    // Step 2: Wait for the invoice to be paid
+    wait_for_payment_confirmation(&base_url, &payment_request).await;
+
+    // Step 3: Check the wallet balance
+    let available_balance = get_wallet_balance(&base_url).await;
+
+    // Verify the balance is at least the amount we minted
+    assert!(
+        available_balance >= amount,
+        "Balance should be at least {} but was {}",
+        amount,
+        available_balance
+    );
+}
+
+/// Test the Nutshell wallet's ability to mint tokens from a Lightning invoice
+#[tokio::test]
+async fn test_nutshell_wallet_swap() {
+    // Get the wallet URL from environment variable
+    let base_url = std::env::var("WALLET_URL").expect("Wallet url is not set");
+
+    // Step 1: Create an invoice and mint tokens
+    let amount = DEFAULT_TEST_AMOUNT;
+    let payment_request = mint_tokens(&base_url, amount).await;
+
+    // Step 2: Wait for the invoice to be paid
+    wait_for_payment_confirmation(&base_url, &payment_request).await;
+
+    let send_amount = 100;
+    let send_url = format!("{}/send?amount={}", base_url, send_amount);
+    let client = Client::new();
+
+    let response: Value = client
+        .post(&send_url)
+        .send()
+        .await
+        .expect("Failed to send payment check request")
+        .json()
+        .await
+        .expect("Valid json");
+
+    // Extract the token and remove the surrounding quotes
+    let token_with_quotes = response
+        .get("token")
+        .expect("Missing token")
+        .as_str()
+        .expect("Token is not a string");
+    let token = token_with_quotes.trim_matches('"');
+
+    let receive_url = format!("{}/receive?token={}", base_url, token);
+
+    let response: Value = client
+        .post(&receive_url)
+        .send()
+        .await
+        .expect("Failed to receive request")
+        .json()
+        .await
+        .expect("Valid json");
+
+    let balance = response
+        .get("balance")
+        .expect("Bal in response")
+        .as_u64()
+        .expect("Valid num");
+    let initial_balance = response
+        .get("initial_balance")
+        .expect("Bal in response")
+        .as_u64()
+        .expect("Valid num");
+
+    let token_received = balance - initial_balance;
+
+    let fee = 1;
+    assert_eq!(token_received, send_amount - fee);
+}
+
+/// Test the Nutshell wallet's ability to melt tokens to pay a Lightning invoice
+#[tokio::test]
+async fn test_nutshell_wallet_melt() {
+    // Get the wallet URL from environment variable
+    let base_url = std::env::var("WALLET_URL").expect("Wallet url is not set");
+
+    // Step 1: Create an invoice and mint tokens
+    let amount = DEFAULT_TEST_AMOUNT;
+    let payment_request = mint_tokens(&base_url, amount).await;
+
+    // Step 2: Wait for the invoice to be paid
+    wait_for_payment_confirmation(&base_url, &payment_request).await;
+
+    // Get initial balance
+    let initial_balance = get_wallet_balance(&base_url).await;
+    println!("Initial balance: {}", initial_balance);
+
+    // Step 3: Create a fake invoice to pay
+    let payment_amount = 1000; // 1000 sats
+    let fake_invoice = create_fake_invoice(payment_amount, "Test payment".to_string());
+    let pay_url = format!("{}/lightning/pay_invoice?bolt11={}", base_url, fake_invoice);
+    let client = Client::new();
+
+    // Step 4: Pay the invoice
+    let _response: Value = client
+        .post(&pay_url)
+        .send()
+        .await
+        .expect("Failed to send pay request")
+        .json()
+        .await
+        .expect("Failed to parse pay response");
+
+    let final_balance = get_wallet_balance(&base_url).await;
+    println!("Final balance: {}", final_balance);
+
+    assert!(
+        initial_balance > final_balance,
+        "Balance should decrease after payment"
+    );
+
+    let balance_difference = initial_balance - final_balance;
+    println!("Balance decreased by: {}", balance_difference);
+
+    // The balance difference should be at least the payment amount
+    assert!(
+        balance_difference >= (payment_amount / 1000),
+        "Balance should decrease by at least the payment amount ({}) but decreased by {}",
+        payment_amount,
+        balance_difference
+    );
+}

+ 97 - 364
crates/cdk-integration-tests/tests/regtest.rs

@@ -1,34 +1,29 @@
-use std::fmt::Debug;
 use std::str::FromStr;
 use std::sync::Arc;
 use std::time::Duration;
 
 use anyhow::{bail, Result};
 use bip39::Mnemonic;
-use cashu::{MeltOptions, Mpp};
+use cashu::ProofsMethods;
 use cdk::amount::{Amount, SplitTarget};
-use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{
-    CurrencyUnit, MeltQuoteState, MintBolt11Request, MintQuoteState, NotificationPayload,
-    PreMintSecrets, State,
+    CurrencyUnit, MeltOptions, MeltQuoteState, MintBolt11Request, MintQuoteState, Mpp,
+    NotificationPayload, PreMintSecrets,
 };
-use cdk::wallet::client::{HttpClient, MintConnector};
-use cdk::wallet::Wallet;
-use cdk::WalletSubscription;
+use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription};
 use cdk_integration_tests::init_regtest::{
     get_cln_dir, get_lnd_cert_file_path, get_lnd_dir, get_lnd_macaroon_path, get_mint_port,
-    get_mint_url, get_mint_ws_url, LND_RPC_ADDR, LND_TWO_RPC_ADDR,
+    LND_RPC_ADDR, LND_TWO_RPC_ADDR,
+};
+use cdk_integration_tests::{
+    get_mint_url_from_env, get_second_mint_url_from_env, wait_for_mint_to_be_paid,
 };
-use cdk_integration_tests::wait_for_mint_to_be_paid;
 use cdk_sqlite::wallet::{self, memory};
-use futures::{join, SinkExt, StreamExt};
+use futures::join;
 use lightning_invoice::Bolt11Invoice;
 use ln_regtest_rs::ln_client::{ClnClient, LightningClient, LndClient};
 use ln_regtest_rs::InvoiceStatus;
-use serde_json::json;
 use tokio::time::timeout;
-use tokio_tungstenite::connect_async;
-use tokio_tungstenite::tungstenite::protocol::Message;
 
 // This is the ln wallet we use to send/receive ln payements as the wallet
 async fn init_lnd_client() -> LndClient {
@@ -44,283 +39,15 @@ async fn init_lnd_client() -> LndClient {
     .unwrap()
 }
 
-async fn get_notification<T: StreamExt<Item = Result<Message, E>> + Unpin, E: Debug>(
-    reader: &mut T,
-    timeout_to_wait: Duration,
-) -> (String, NotificationPayload<String>) {
-    let msg = timeout(timeout_to_wait, reader.next())
-        .await
-        .expect("timeout")
-        .unwrap()
-        .unwrap();
-
-    let mut response: serde_json::Value =
-        serde_json::from_str(msg.to_text().unwrap()).expect("valid json");
-
-    let mut params_raw = response
-        .as_object_mut()
-        .expect("object")
-        .remove("params")
-        .expect("valid params");
-
-    let params_map = params_raw.as_object_mut().expect("params is object");
-
-    (
-        params_map
-            .remove("subId")
-            .unwrap()
-            .as_str()
-            .unwrap()
-            .to_string(),
-        serde_json::from_value(params_map.remove("payload").unwrap()).unwrap(),
-    )
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_regtest_mint_melt_round_trip() -> Result<()> {
-    let lnd_client = init_lnd_client().await;
-
-    let wallet = Wallet::new(
-        &get_mint_url("0"),
-        CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
-        None,
-    )?;
-
-    let (ws_stream, _) = connect_async(get_mint_ws_url("0"))
-        .await
-        .expect("Failed to connect");
-    let (mut write, mut reader) = ws_stream.split();
-
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
-
-    lnd_client.pay_invoice(mint_quote.request).await.unwrap();
-
-    let proofs = wallet
-        .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
-
-    let mint_amount = proofs.total_amount()?;
-
-    assert!(mint_amount == 100.into());
-
-    let invoice = lnd_client.create_invoice(Some(50)).await?;
-
-    let melt = wallet.melt_quote(invoice, None).await?;
-
-    write
-        .send(Message::Text(serde_json::to_string(&json!({
-                "jsonrpc": "2.0",
-                "id": 2,
-                "method": "subscribe",
-                "params": {
-                  "kind": "bolt11_melt_quote",
-                  "filters": [
-                    melt.id.clone(),
-                  ],
-                  "subId": "test-sub",
-                }
-
-        }))?))
-        .await?;
-
-    assert_eq!(
-        reader.next().await.unwrap().unwrap().to_text().unwrap(),
-        r#"{"jsonrpc":"2.0","result":{"status":"OK","subId":"test-sub"},"id":2}"#
-    );
-
-    let melt_response = wallet.melt(&melt.id).await.unwrap();
-    assert!(melt_response.preimage.is_some());
-    assert!(melt_response.state == MeltQuoteState::Paid);
-
-    let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
-    // first message is the current state
-    assert_eq!("test-sub", sub_id);
-    let payload = match payload {
-        NotificationPayload::MeltQuoteBolt11Response(melt) => melt,
-        _ => panic!("Wrong payload"),
-    };
-
-    assert_eq!(payload.amount + payload.fee_reserve, 50.into());
-    assert_eq!(payload.quote.to_string(), melt.id);
-    assert_eq!(payload.state, MeltQuoteState::Unpaid);
-
-    // get current state
-    let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
-    assert_eq!("test-sub", sub_id);
-    let payload = match payload {
-        NotificationPayload::MeltQuoteBolt11Response(melt) => melt,
-        _ => panic!("Wrong payload"),
-    };
-    assert_eq!(payload.amount + payload.fee_reserve, 50.into());
-    assert_eq!(payload.quote.to_string(), melt.id);
-    assert_eq!(payload.state, MeltQuoteState::Paid);
-
-    Ok(())
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_regtest_mint_melt() -> Result<()> {
-    let lnd_client = init_lnd_client().await;
-
-    let wallet = Wallet::new(
-        &get_mint_url("0"),
-        CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
-        None,
-    )?;
-
-    let mint_amount = Amount::from(100);
-
-    let mint_quote = wallet.mint_quote(mint_amount, None).await?;
-
-    assert_eq!(mint_quote.amount, mint_amount);
-
-    lnd_client.pay_invoice(mint_quote.request).await?;
-
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
-
-    let proofs = wallet
-        .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
-
-    let mint_amount = proofs.total_amount()?;
-
-    assert!(mint_amount == 100.into());
-
-    Ok(())
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_restore() -> Result<()> {
-    let lnd_client = init_lnd_client().await;
-
-    let seed = Mnemonic::generate(12)?.to_seed_normalized("");
-    let wallet = Wallet::new(
-        &get_mint_url("0"),
-        CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &seed,
-        None,
-    )?;
-
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
-
-    lnd_client.pay_invoice(mint_quote.request).await?;
-
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
-
-    let _mint_amount = wallet
-        .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
-
-    assert!(wallet.total_balance().await? == 100.into());
-
-    let wallet_2 = Wallet::new(
-        &get_mint_url("0"),
-        CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &seed,
-        None,
-    )?;
-
-    assert!(wallet_2.total_balance().await? == 0.into());
-
-    let restored = wallet_2.restore().await?;
-    let proofs = wallet_2.get_unspent_proofs().await?;
-
-    wallet_2
-        .swap(None, SplitTarget::default(), proofs, None, false)
-        .await?;
-
-    assert!(restored == 100.into());
-
-    assert!(wallet_2.total_balance().await? == 100.into());
-
-    let proofs = wallet.get_unspent_proofs().await?;
-
-    let states = wallet.check_proofs_spent(proofs).await?;
-
-    for state in states {
-        if state.state != State::Spent {
-            bail!("All proofs should be spent");
-        }
-    }
-
-    Ok(())
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_pay_invoice_twice() -> Result<()> {
-    let lnd_client = init_lnd_client().await;
-
-    let seed = Mnemonic::generate(12)?.to_seed_normalized("");
-    let wallet = Wallet::new(
-        &get_mint_url("0"),
-        CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &seed,
-        None,
-    )?;
-
-    let mint_quote = wallet.mint_quote(100.into(), None).await?;
-
-    lnd_client
-        .pay_invoice(mint_quote.request)
-        .await
-        .expect("Could not pay invoice");
-
-    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
-
-    let proofs = wallet
-        .mint(&mint_quote.id, SplitTarget::default(), None)
-        .await?;
-
-    let mint_amount = proofs.total_amount()?;
-
-    assert_eq!(mint_amount, 100.into());
-
-    let invoice = lnd_client.create_invoice(Some(10)).await?;
-
-    let melt_quote = wallet.melt_quote(invoice.clone(), None).await?;
-
-    let melt = wallet.melt(&melt_quote.id).await.unwrap();
-
-    let melt_two = wallet.melt_quote(invoice, None).await?;
-
-    let melt_two = wallet.melt(&melt_two.id).await;
-
-    match melt_two {
-        Err(err) => match err {
-            cdk::Error::RequestAlreadyPaid => (),
-            err => {
-                bail!("Wrong invoice already paid: {}", err.to_string());
-            }
-        },
-        Ok(_) => {
-            bail!("Should not have allowed second payment");
-        }
-    }
-
-    let balance = wallet.total_balance().await?;
-
-    assert_eq!(balance, (Amount::from(100) - melt.fee_paid - melt.amount));
-
-    Ok(())
-}
-
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_internal_payment() -> Result<()> {
     let lnd_client = init_lnd_client().await;
 
-    let seed = Mnemonic::generate(12)?.to_seed_normalized("");
     let wallet = Wallet::new(
-        &get_mint_url("0"),
+        &get_mint_url_from_env(),
         CurrencyUnit::Sat,
         Arc::new(memory::empty().await?),
-        &seed,
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
         None,
     )?;
 
@@ -336,13 +63,11 @@ async fn test_internal_payment() -> Result<()> {
 
     assert!(wallet.total_balance().await? == 100.into());
 
-    let seed = Mnemonic::generate(12)?.to_seed_normalized("");
-
     let wallet_2 = Wallet::new(
-        &get_mint_url("0"),
+        &get_mint_url_from_env(),
         CurrencyUnit::Sat,
         Arc::new(memory::empty().await?),
-        &seed,
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
         None,
     )?;
 
@@ -408,50 +133,9 @@ async fn test_internal_payment() -> Result<()> {
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_cached_mint() -> Result<()> {
-    let lnd_client = init_lnd_client().await;
-
-    let wallet = Wallet::new(
-        &get_mint_url("0"),
-        CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
-        &Mnemonic::generate(12)?.to_seed_normalized(""),
-        None,
-    )?;
-
-    let mint_amount = Amount::from(100);
-
-    let quote = wallet.mint_quote(mint_amount, None).await?;
-    lnd_client.pay_invoice(quote.request).await?;
-
-    wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?;
-
-    let active_keyset_id = wallet.get_active_mint_keyset().await?.id;
-    let http_client = HttpClient::new(get_mint_url("0").as_str().parse()?);
-    let premint_secrets =
-        PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap();
-
-    let mut request = MintBolt11Request {
-        quote: quote.id,
-        outputs: premint_secrets.blinded_messages(),
-        signature: None,
-    };
-
-    let secret_key = quote.secret_key;
-
-    request.sign(secret_key.expect("Secret key on quote"))?;
-
-    let response = http_client.post_mint(request.clone()).await?;
-    let response1 = http_client.post_mint(request).await?;
-
-    assert!(response == response1);
-    Ok(())
-}
-
-#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_websocket_connection() -> Result<()> {
     let wallet = Wallet::new(
-        &get_mint_url("0"),
+        &get_mint_url_from_env(),
         CurrencyUnit::Sat,
         Arc::new(wallet::memory::empty().await?),
         &Mnemonic::generate(12)?.to_seed_normalized(""),
@@ -505,17 +189,20 @@ async fn test_websocket_connection() -> Result<()> {
 async fn test_multimint_melt() -> Result<()> {
     let lnd_client = init_lnd_client().await;
 
+    let db = Arc::new(memory::empty().await?);
     let wallet1 = Wallet::new(
-        &get_mint_url("0"),
+        &get_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
+        db,
         &Mnemonic::generate(12)?.to_seed_normalized(""),
         None,
     )?;
+
+    let db = Arc::new(memory::empty().await?);
     let wallet2 = Wallet::new(
-        &get_mint_url("1"),
+        &get_second_mint_url_from_env(),
         CurrencyUnit::Sat,
-        Arc::new(memory::empty().await?),
+        db,
         &Mnemonic::generate(12)?.to_seed_normalized(""),
         None,
     )?;
@@ -571,36 +258,82 @@ async fn test_multimint_melt() -> Result<()> {
 }
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
-async fn test_database_type() -> Result<()> {
-    // Get the database type and work dir from environment
-    let db_type = std::env::var("MINT_DATABASE").expect("MINT_DATABASE env var should be set");
-    let work_dir =
-        std::env::var("CDK_MINTD_WORK_DIR").expect("CDK_MINTD_WORK_DIR env var should be set");
-
-    // Check that the correct database file exists
-    match db_type.as_str() {
-        "REDB" => {
-            let db_path = std::path::Path::new(&work_dir).join("cdk-mintd.redb");
-            assert!(
-                db_path.exists(),
-                "Expected redb database file to exist at {:?}",
-                db_path
-            );
-        }
-        "SQLITE" => {
-            let db_path = std::path::Path::new(&work_dir).join("cdk-mintd.sqlite");
-            assert!(
-                db_path.exists(),
-                "Expected sqlite database file to exist at {:?}",
-                db_path
-            );
-        }
-        "MEMORY" => {
-            // Memory database has no file to check
-            println!("Memory database in use - no file to check");
-        }
-        _ => bail!("Unknown database type: {}", db_type),
-    }
+async fn test_cached_mint() -> Result<()> {
+    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(""),
+        None,
+    )?;
+
+    let mint_amount = Amount::from(100);
+
+    let quote = wallet.mint_quote(mint_amount, None).await?;
+    lnd_client.pay_invoice(quote.request.clone()).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?;
+
+    let active_keyset_id = wallet.get_active_mint_keyset().await?.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 {
+        quote: quote.id,
+        outputs: premint_secrets.blinded_messages(),
+        signature: None,
+    };
+
+    let secret_key = quote.secret_key;
+
+    request.sign(secret_key.expect("Secret key on quote"))?;
+
+    let response = http_client.post_mint(request.clone()).await?;
+    let response1 = http_client.post_mint(request).await?;
+
+    assert!(response == response1);
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_regtest_melt_amountless() -> Result<()> {
+    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(""),
+        None,
+    )?;
+
+    let mint_amount = Amount::from(100);
+
+    let mint_quote = wallet.mint_quote(mint_amount, None).await?;
+
+    assert_eq!(mint_quote.amount, mint_amount);
+
+    lnd_client.pay_invoice(mint_quote.request).await?;
+
+    let proofs = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let amount = proofs.total_amount()?;
+
+    assert!(mint_amount == amount);
+
+    let invoice = lnd_client.create_invoice(None).await?;
+
+    let options = MeltOptions::new_amountless(5_000);
+
+    let melt_quote = wallet.melt_quote(invoice.clone(), Some(options)).await?;
+
+    let melt = wallet.melt(&melt_quote.id).await.unwrap();
+
+    assert!(melt.amount == 5.into());
 
     Ok(())
 }

+ 125 - 0
crates/cdk-integration-tests/tests/test_fees.rs

@@ -0,0 +1,125 @@
+use std::str::FromStr;
+use std::sync::Arc;
+
+use anyhow::Result;
+use bip39::Mnemonic;
+use cashu::{Bolt11Invoice, ProofsMethods};
+use cdk::amount::{Amount, SplitTarget};
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::{ReceiveOptions, SendKind, SendOptions, Wallet};
+use cdk_integration_tests::{
+    create_invoice_for_env, get_mint_url_from_env, pay_if_regtest, wait_for_mint_to_be_paid,
+};
+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("");
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &seed,
+        None,
+    )?;
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request)?;
+    pay_if_regtest(&invoice).await?;
+
+    let _mint_amount = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let proofs: Vec<Amount> = wallet
+        .get_unspent_proofs()
+        .await?
+        .iter()
+        .map(|p| p.amount)
+        .collect();
+
+    println!("{:?}", proofs);
+
+    let send = wallet
+        .prepare_send(
+            4.into(),
+            SendOptions {
+                send_kind: SendKind::OfflineExact,
+                ..Default::default()
+            },
+        )
+        .await?;
+
+    let proofs = send.proofs();
+
+    let fee = wallet.get_proofs_fee(&proofs).await?;
+
+    assert_eq!(fee, 1.into());
+
+    let send = wallet.send(send, None).await?;
+
+    let rec_amount = wallet
+        .receive(&send.to_string(), ReceiveOptions::default())
+        .await?;
+
+    assert_eq!(rec_amount, 3.into());
+
+    let wallet_balance = wallet.total_balance().await?;
+
+    assert_eq!(wallet_balance, 99.into());
+
+    Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_fake_melt_change_in_quote() -> Result<()> {
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await?),
+        &Mnemonic::generate(12)?.to_seed_normalized(""),
+        None,
+    )?;
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await?;
+
+    let bolt11 = Bolt11Invoice::from_str(&mint_quote.request)?;
+
+    pay_if_regtest(&bolt11).await?;
+
+    wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?;
+
+    let _mint_amount = wallet
+        .mint(&mint_quote.id, SplitTarget::default(), None)
+        .await?;
+
+    let invoice_amount = 9;
+
+    let invoice = create_invoice_for_env(Some(invoice_amount)).await?;
+
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await?;
+
+    let proofs = wallet.get_unspent_proofs().await?;
+
+    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 change = melt.change.unwrap().total_amount().unwrap();
+    let idk = proofs.total_amount()? - Amount::from(invoice_amount) - change;
+
+    println!("{}", idk);
+    println!("{}", fee);
+    println!("{}", proofs_total);
+    println!("{}", change);
+
+    let ln_fee = 1;
+
+    assert_eq!(
+        wallet.total_balance().await?,
+        Amount::from(100 - invoice_amount - u64::from(fee) - ln_fee)
+    );
+
+    Ok(())
+}

+ 17 - 15
crates/cdk-lnbits/Cargo.toml

@@ -1,23 +1,25 @@
 [package]
 name = "cdk-lnbits"
-version = "0.7.1"
-edition = "2021"
+version.workspace = true
+edition.workspace = true
 authors = ["CDK Developers"]
-license = "MIT"
+license.workspace = true
 homepage = "https://github.com/cashubtc/cdk"
 repository = "https://github.com/cashubtc/cdk.git"
-rust-version = "1.63.0" # MSRV
+rust-version.workspace = true # MSRV
 description = "CDK ln backend for lnbits"
+readme = "README.md"
 
 [dependencies]
-async-trait = "0.1"
-anyhow = "1"
-axum = "0.6.20"
-bitcoin = { version = "0.32.2", default-features = false }
-cdk = { path = "../cdk", version = "0.7.1", default-features = false, features = ["mint"] }
-futures = { version = "0.3.28", default-features = false }
-tokio = { version = "1", default-features = false }
-tokio-util = { version = "0.7.11", default-features = false }
-tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] }
-thiserror = "1"
-lnbits-rs = "0.3.0"
+async-trait.workspace = true
+anyhow.workspace = true
+axum.workspace = true
+bitcoin.workspace = true
+cdk = { 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"
+serde_json.workspace = true

+ 30 - 0
crates/cdk-lnbits/README.md

@@ -0,0 +1,30 @@
+# 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)
+
+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.
+
+## Overview
+
+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
+
+Add this to your `Cargo.toml`:
+
+```toml
+[dependencies]
+cdk-lnbits = "*"
+```
+
+## License
+
+This project is licensed under the [MIT License](https://github.com/cashubtc/cdk/blob/main/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_lightning::Error {
+impl From<Error> for cdk::cdk_payment::Error {
     fn from(e: Error) -> Self {
         Self::Lightning(Box::new(e))
     }

+ 59 - 41
crates/cdk-lnbits/src/lib.rs

@@ -1,9 +1,12 @@
 //! CDK lightning backend for lnbits
 
+#![doc = include_str!("../README.md")]
 #![warn(missing_docs)]
 #![warn(rustdoc::bare_urls)]
 
+use std::cmp::max;
 use std::pin::Pin;
+use std::str::FromStr;
 use std::sync::atomic::{AtomicBool, Ordering};
 use std::sync::Arc;
 
@@ -11,11 +14,12 @@ use anyhow::anyhow;
 use async_trait::async_trait;
 use axum::Router;
 use cdk::amount::{to_unit, Amount, MSAT_IN_SAT};
-use cdk::cdk_lightning::{
-    self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings,
+use cdk::cdk_payment::{
+    self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment,
+    PaymentQuoteResponse,
 };
-use cdk::mint::FeeReserve;
-use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState};
+use cdk::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState};
+use cdk::types::FeeReserve;
 use cdk::util::unix_time;
 use cdk::{mint, Bolt11Invoice};
 use error::Error;
@@ -23,6 +27,7 @@ use futures::stream::StreamExt;
 use futures::Stream;
 use lnbits_rs::api::invoice::CreateInvoiceRequest;
 use lnbits_rs::LNBitsClient;
+use serde_json::Value;
 use tokio::sync::Mutex;
 use tokio_util::sync::CancellationToken;
 
@@ -37,6 +42,7 @@ pub struct LNbits {
     webhook_url: String,
     wait_invoice_cancel_token: CancellationToken,
     wait_invoice_is_active: Arc<AtomicBool>,
+    settings: Bolt11Settings,
 }
 
 impl LNbits {
@@ -59,20 +65,22 @@ impl LNbits {
             webhook_url,
             wait_invoice_cancel_token: CancellationToken::new(),
             wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
+            settings: Bolt11Settings {
+                mpp: false,
+                unit: CurrencyUnit::Sat,
+                invoice_description: true,
+                amountless: false,
+            },
         })
     }
 }
 
 #[async_trait]
-impl MintLightning for LNbits {
-    type Err = cdk_lightning::Error;
+impl MintPayment for LNbits {
+    type Err = cdk_payment::Error;
 
-    fn get_settings(&self) -> Settings {
-        Settings {
-            mpp: false,
-            unit: CurrencyUnit::Sat,
-            invoice_description: true,
-        }
+    async fn get_settings(&self) -> Result<Value, Self::Err> {
+        Ok(serde_json::to_value(&self.settings)?)
     }
 
     fn is_wait_invoice_active(&self) -> bool {
@@ -83,8 +91,7 @@ impl MintLightning for LNbits {
         self.wait_invoice_cancel_token.cancel()
     }
 
-    #[allow(clippy::incompatible_msrv)]
-    async fn wait_any_invoice(
+    async fn wait_any_incoming_payment(
         &self,
     ) -> Result<Pin<Box<dyn Stream<Item = String> + Send>>, Self::Err> {
         let receiver = self
@@ -146,43 +153,55 @@ impl MintLightning for LNbits {
 
     async fn get_payment_quote(
         &self,
-        melt_quote_request: &MeltQuoteBolt11Request,
+        request: &str,
+        unit: &CurrencyUnit,
+        options: Option<MeltOptions>,
     ) -> Result<PaymentQuoteResponse, Self::Err> {
-        if melt_quote_request.unit != CurrencyUnit::Sat {
+        if unit != &CurrencyUnit::Sat {
             return Err(Self::Err::Anyhow(anyhow!("Unsupported unit")));
         }
 
-        let amount = melt_quote_request.amount_msat()?;
+        let bolt11 = Bolt11Invoice::from_str(request)?;
 
-        let amount = amount / MSAT_IN_SAT.into();
+        let amount_msat = match options {
+            Some(amount) => {
+                if matches!(amount, MeltOptions::Mpp { mpp: _ }) {
+                    return Err(cdk_payment::Error::UnsupportedPaymentOption);
+                }
+                amount.amount_msat()
+            }
+            None => bolt11
+                .amount_milli_satoshis()
+                .ok_or(Error::UnknownInvoiceAmount)?
+                .into(),
+        };
+
+        let amount = amount_msat / MSAT_IN_SAT.into();
 
         let relative_fee_reserve =
             (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64;
 
         let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
 
-        let fee = match relative_fee_reserve > absolute_fee_reserve {
-            true => relative_fee_reserve,
-            false => absolute_fee_reserve,
-        };
+        let fee = max(relative_fee_reserve, absolute_fee_reserve);
 
         Ok(PaymentQuoteResponse {
-            request_lookup_id: melt_quote_request.request.payment_hash().to_string(),
+            request_lookup_id: bolt11.payment_hash().to_string(),
             amount,
             fee: fee.into(),
             state: MeltQuoteState::Unpaid,
         })
     }
 
-    async fn pay_invoice(
+    async fn make_payment(
         &self,
         melt_quote: mint::MeltQuote,
         _partial_msats: Option<Amount>,
         _max_fee_msats: Option<Amount>,
-    ) -> Result<PayInvoiceResponse, Self::Err> {
+    ) -> Result<MakePaymentResponse, Self::Err> {
         let pay_response = self
             .lnbits_api
-            .pay_invoice(&melt_quote.request)
+            .pay_invoice(&melt_quote.request, None)
             .await
             .map_err(|err| {
                 tracing::error!("Could not pay invoice");
@@ -213,36 +232,35 @@ impl MintLightning for LNbits {
             .unsigned_abs(),
         );
 
-        Ok(PayInvoiceResponse {
+        Ok(MakePaymentResponse {
             payment_lookup_id: pay_response.payment_hash,
-            payment_preimage: Some(invoice_info.payment_hash),
+            payment_proof: Some(invoice_info.payment_hash),
             status,
             total_spent,
             unit: CurrencyUnit::Sat,
         })
     }
 
-    async fn create_invoice(
+    async fn create_incoming_payment_request(
         &self,
         amount: Amount,
         unit: &CurrencyUnit,
         description: String,
-        unix_expiry: u64,
-    ) -> Result<CreateInvoiceResponse, Self::Err> {
+        unix_expiry: Option<u64>,
+    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
         if unit != &CurrencyUnit::Sat {
             return Err(Self::Err::Anyhow(anyhow!("Unsupported unit")));
         }
 
         let time_now = unix_time();
-        assert!(unix_expiry > time_now);
 
-        let expiry = unix_expiry - time_now;
+        let expiry = unix_expiry.map(|t| t - time_now);
 
         let invoice_request = CreateInvoiceRequest {
             amount: to_unit(amount, unit, &CurrencyUnit::Sat)?.into(),
             memo: Some(description),
             unit: unit.to_string(),
-            expiry: Some(expiry),
+            expiry,
             webhook: Some(self.webhook_url.clone()),
             internal: None,
             out: false,
@@ -261,14 +279,14 @@ impl MintLightning for LNbits {
         let request: Bolt11Invoice = create_invoice_response.payment_request.parse()?;
         let expiry = request.expires_at().map(|t| t.as_secs());
 
-        Ok(CreateInvoiceResponse {
+        Ok(CreateIncomingPaymentResponse {
             request_lookup_id: create_invoice_response.payment_hash,
-            request,
+            request: request.to_string(),
             expiry,
         })
     }
 
-    async fn check_incoming_invoice_status(
+    async fn check_incoming_payment_status(
         &self,
         payment_hash: &str,
     ) -> Result<MintQuoteState, Self::Err> {
@@ -293,7 +311,7 @@ impl MintLightning for LNbits {
     async fn check_outgoing_payment(
         &self,
         payment_hash: &str,
-    ) -> Result<PayInvoiceResponse, Self::Err> {
+    ) -> Result<MakePaymentResponse, Self::Err> {
         let payment = self
             .lnbits_api
             .get_payment_info(payment_hash)
@@ -304,15 +322,15 @@ impl MintLightning for LNbits {
                 Self::Err::Anyhow(anyhow!("Could not check invoice status"))
             })?;
 
-        let pay_response = PayInvoiceResponse {
+        let pay_response = MakePaymentResponse {
             payment_lookup_id: payment.details.payment_hash,
-            payment_preimage: Some(payment.preimage),
+            payment_proof: Some(payment.preimage),
             status: lnbits_to_melt_status(&payment.details.status, payment.details.pending),
             total_spent: Amount::from(
                 payment.details.amount.unsigned_abs()
                     + payment.details.fee.unsigned_abs() / MSAT_IN_SAT,
             ),
-            unit: self.get_settings().unit,
+            unit: self.settings.unit.clone(),
         };
 
         Ok(pay_response)

Some files were not shown because too many files changed in this diff