34 Commits e14469a8fa ... fd6f848120

Author SHA1 Message Date
  tsk fd6f848120 fix: Enable pure environment variable configuration for Lightning backends (#1299) 5 days ago
  tsk 141e62a8dc feat(cdk): add Lightning address support with BIP353 fallback (#1295) 5 days ago
  C a12fd4dbea Prevent database contention in metadata cache load operations (#1300) 5 days ago
  tsk 36c6037442 fix: allow starting insecure man server (#1297) 5 days ago
  thesimplekid 288be4a377 fix: nightly fmt pr 5 days ago
  thesimplekid ccb84efe6c chore: fix nightly pr 5 days ago
  tsk ee910578ab fix: nightly ci (#1298) 5 days ago
  tsk 15c315ea09 feat(cdk): add invoice decoding for bolt11 and bolt12 (#1294) 5 days ago
  tsk c880ef7027 refactor(cdk/wallet): extract keyset key loading into helper method (#1296) 5 days ago
  tsk e1c18e51d4 fix: flaky test by using wait and pay (#1292) 6 days ago
  tsk e7fe058188 feat: add test coverage for mutants caught in https://github.com/cashubtc/cdk/issues/1290 (#1293) 6 days ago
  tsk 3df35b4a38 fix: load keyset keys from database to prevent duplicate insertions (#1291) 6 days ago
  tsk 023e7b97b6 chore: change mutation testing ci time (#1289) 6 days ago
  tsk df468139cb fix: we use the nightly flake so we don't need +nightly (#1288) 6 days ago
  black5box 0b95cbc3f6 chore: fix some minor issues in comments (#1287) 6 days ago
  tsk 66f7561680 ci: reduce ci jobs (#1284) 6 days ago
  C 501e72f7c7 Fix missing try_proof_operation_or_reclaim wrapping of a swap (#1278) 1 week ago
  C 24d397d10b Don't read keys from the database (#1280) 1 week ago
  C 2dfac3425e Update Wallet::fetch_mint_info (#1277) 1 week ago
  gudnuf 4723e32886 fix: return actual error from get_payment_quote (#1274) 1 week ago
  tsk 836a50aaa3 fix: require 0 signatures for HTLC with no pubkeys specified (#1275) 1 week ago
  SatsAndSports 9eaa6f1c02 feat: update NUT-11 SIG_ALL message aggregation per spec 1 week ago
  tsk e5882dc2eb test: add mutation testing infrastructure and security-critical coverage (#1210) 1 week ago
  tsk 2f9100ea4f Metadata follow up (#1268) 1 week ago
  tsk c859939289 fix: nut14 disabled in info (#1269) 1 week ago
  C 32c9288940 Introduce MintMetadataCache for efficient key and metadata management (#1240) 1 week ago
  tsk 2f0ff7fe9e chore: meeting agenda fmt (#1266) 1 week ago
  asmo d002481eb6 fix: check the removed_ys argument before creating the delete query (#1198) 1 week ago
  tsk f989fb784d chore: update stable rust to 1.91.1 (#1265) 1 week ago
  github-actions[bot] 0f1f2fe5a0 chore: add weekly meeting agenda for 2025-11-12 (#1261) 1 week ago
  thesimplekid 5af6976da2 chore: rust version check open issue 1 week ago
  tsk 50cf1d83b9 chore: rust version workflow (#1262) 1 week ago
  tsk 9354c2c698 feat(ci): add nightly rustfmt automation with flexible formatting policy (#1260) 1 week ago
  tsk 4feed9f6c2 mint async melt (#1258) 1 week ago
99 changed files with 10180 additions and 1164 deletions
  1. 9 0
      .cargo-mutants.toml
  2. 39 0
      .cargo/mutants.toml
  3. 58 0
      .github/ISSUE_TEMPLATE/mutation-testing.md
  4. 35 9
      .github/scripts/generate-agenda.sh
  5. 17 90
      .github/workflows/ci.yml
  6. 83 0
      .github/workflows/mutation-testing-weekly.yml
  7. 57 0
      .github/workflows/nightly-rustfmt.yml
  8. 77 0
      .github/workflows/update-rust-version.yml
  9. 5 0
      .gitignore
  10. 61 0
      DEVELOPMENT.md
  11. 345 0
      crates/cashu/src/amount.rs
  12. 164 0
      crates/cashu/src/dhke.rs
  13. 1 1
      crates/cashu/src/nuts/mod.rs
  14. 4 7
      crates/cashu/src/nuts/nut00/mod.rs
  15. 26 0
      crates/cashu/src/nuts/nut03.rs
  16. 39 1
      crates/cashu/src/nuts/nut05.rs
  17. 473 0
      crates/cashu/src/nuts/nut10.rs
  18. 556 433
      crates/cashu/src/nuts/nut11/mod.rs
  19. 153 0
      crates/cashu/src/nuts/nut12.rs
  20. 374 66
      crates/cashu/src/nuts/nut14/mod.rs
  21. 96 3
      crates/cdk-axum/src/router_handlers.rs
  22. 37 0
      crates/cdk-cln/README.md
  23. 10 0
      crates/cdk-common/src/error.rs
  24. 3 0
      crates/cdk-common/src/lib.rs
  25. 3 3
      crates/cdk-common/src/mint.rs
  26. 3 14
      crates/cdk-common/src/pub_sub/pubsub.rs
  27. 2 8
      crates/cdk-common/src/pub_sub/remote_consumer.rs
  28. 25 0
      crates/cdk-common/src/task.rs
  29. 96 0
      crates/cdk-ffi/src/types/invoice.rs
  30. 4 4
      crates/cdk-ffi/src/types/mint.rs
  31. 2 0
      crates/cdk-ffi/src/types/mod.rs
  32. 39 0
      crates/cdk-ffi/src/wallet.rs
  33. 14 0
      crates/cdk-integration-tests/src/init_pure_tests.rs
  34. 2 2
      crates/cdk-integration-tests/src/lib.rs
  35. 146 0
      crates/cdk-integration-tests/tests/async_melt.rs
  36. 8 3
      crates/cdk-integration-tests/tests/bolt12.rs
  37. 1 1
      crates/cdk-integration-tests/tests/test_swap_flow.rs
  38. 45 0
      crates/cdk-lnbits/README.md
  39. 38 0
      crates/cdk-lnd/README.md
  40. 13 5
      crates/cdk-mintd/README.md
  41. 14 14
      crates/cdk-mintd/example.config.toml
  42. 456 45
      crates/cdk-mintd/src/config.rs
  43. 10 5
      crates/cdk-mintd/src/lib.rs
  44. 35 0
      crates/cdk-mintd/src/setup.rs
  45. 2 2
      crates/cdk-redb/src/wallet/mod.rs
  46. 1 1
      crates/cdk-signatory/src/signatory.rs
  47. 2 3
      crates/cdk-sql-common/build.rs
  48. 7 7
      crates/cdk-sql-common/src/mint/mod.rs
  49. 12 0
      crates/cdk-sql-common/src/stmt.rs
  50. 15 0
      crates/cdk-sql-common/src/wallet/migrations/postgres/20251111000000_keyset_counter_table.sql
  51. 36 0
      crates/cdk-sql-common/src/wallet/migrations/sqlite/20251111000000_keyset_counter_table.sql
  52. 19 17
      crates/cdk-sql-common/src/wallet/mod.rs
  53. 4 0
      crates/cdk/Cargo.toml
  54. 300 0
      crates/cdk/examples/human_readable_payment.rs
  55. 1 1
      crates/cdk/examples/proof-selection.rs
  56. 129 0
      crates/cdk/src/invoice.rs
  57. 7 3
      crates/cdk/src/lib.rs
  58. 238 0
      crates/cdk/src/lightning_address.rs
  59. 50 0
      crates/cdk/src/mint/builder.rs
  60. 0 2
      crates/cdk/src/mint/melt/melt_saga/tests.rs
  61. 113 5
      crates/cdk/src/mint/melt/mod.rs
  62. 180 0
      crates/cdk/src/mint/melt/tests/htlc_sigall_spending_conditions_tests.rs
  63. 191 0
      crates/cdk/src/mint/melt/tests/htlc_spending_conditions_tests.rs
  64. 276 0
      crates/cdk/src/mint/melt/tests/locktime_spending_conditions_tests.rs
  65. 5 0
      crates/cdk/src/mint/melt/tests/mod.rs
  66. 168 0
      crates/cdk/src/mint/melt/tests/p2pk_sigall_spending_conditions_tests.rs
  67. 134 0
      crates/cdk/src/mint/melt/tests/p2pk_spending_conditions_tests.rs
  68. 4 32
      crates/cdk/src/mint/mod.rs
  69. 12 15
      crates/cdk/src/mint/swap/mod.rs
  70. 7 8
      crates/cdk/src/mint/swap/swap_saga/tests.rs
  71. 389 0
      crates/cdk/src/mint/swap/tests/htlc_sigall_spending_conditions_tests.rs
  72. 396 0
      crates/cdk/src/mint/swap/tests/htlc_spending_conditions_tests.rs
  73. 4 0
      crates/cdk/src/mint/swap/tests/mod.rs
  74. 1441 0
      crates/cdk/src/mint/swap/tests/p2pk_sigall_spending_conditions_tests.rs
  75. 804 0
      crates/cdk/src/mint/swap/tests/p2pk_spending_conditions_tests.rs
  76. 2 9
      crates/cdk/src/test_helpers/mint.rs
  77. 2 1
      crates/cdk/src/test_helpers/mod.rs
  78. 145 0
      crates/cdk/src/test_helpers/nut10.rs
  79. 62 74
      crates/cdk/src/wallet/auth/auth_wallet.rs
  80. 81 6
      crates/cdk/src/wallet/builder.rs
  81. 0 4
      crates/cdk/src/wallet/issue/issue_bolt11.rs
  82. 0 4
      crates/cdk/src/wallet/issue/issue_bolt12.rs
  83. 96 108
      crates/cdk/src/wallet/keysets.rs
  84. 2 8
      crates/cdk/src/wallet/melt/melt_bolt11.rs
  85. 90 0
      crates/cdk/src/wallet/melt/melt_lightning_address.rs
  86. 63 0
      crates/cdk/src/wallet/melt/mod.rs
  87. 22 0
      crates/cdk/src/wallet/mint_connector/http_client.rs
  88. 14 0
      crates/cdk/src/wallet/mint_connector/mod.rs
  89. 677 0
      crates/cdk/src/wallet/mint_metadata_cache.rs
  90. 102 68
      crates/cdk/src/wallet/mod.rs
  91. 4 30
      crates/cdk/src/wallet/multi_mint_wallet.rs
  92. 6 3
      crates/cdk/src/wallet/receive.rs
  93. 2 8
      crates/cdk/src/wallet/swap.rs
  94. 18 18
      flake.lock
  95. 13 3
      flake.nix
  96. 84 5
      justfile
  97. 54 0
      meetings/2025-11-12-agenda.md
  98. 15 4
      misc/fake_itests.sh
  99. 1 1
      rust-toolchain.toml

+ 9 - 0
.cargo-mutants.toml

@@ -0,0 +1,9 @@
+# Cargo mutants configuration
+# See: https://mutants.rs/
+
+# Skip simple getters that are trivially correct and tested by integration tests
+# These mutations would be caught by integration tests like test_split_with_fee
+exclude_re = [
+    "cashu/src/amount.rs.*FeeAndAmounts::fee",
+    "cashu/src/amount.rs.*FeeAndAmounts::amounts",
+]

+ 39 - 0
.cargo/mutants.toml

@@ -0,0 +1,39 @@
+# Mutation Testing Configuration for CDK
+# Phase 1: Focus on cashu crate only
+
+# Start with cashu crate only - exclude other crates initially
+exclude_globs = [
+    "crates/cdk-*/**",  # Exclude other crates initially
+    "**/tests/**",      # Don't mutate test code
+    "**/benches/**",    # Don't mutate benchmarks
+]
+
+# Reasonable timeout to catch hangs (5 minutes minimum)
+minimum_test_timeout = 300
+
+# Skip specific mutations that cause infinite loops
+# These mutations create scenarios where loops never terminate or recursive functions never return.
+# Format: "file.rs:line.*pattern"
+exclude_re = [
+    # dhke.rs:61 - Mutating counter += to *= causes infinite loop (counter stays 0)
+    "crates/cashu/src/dhke.rs:61:.*replace \\+= with \\*=",
+
+    # amount.rs:108 - Mutating % to / in split causes infinite loop
+    "crates/cashu/src/amount.rs:108:.*replace % with /",
+
+    # amount.rs:100 - split() returning empty vec causes infinite loops
+    "crates/cashu/src/amount.rs:100:.*replace.*split.*with vec!\\[\\]",
+    "crates/cashu/src/amount.rs:100:.*replace.*split.*with vec!\\[Default",
+
+    # amount.rs:203 - checked_add returning Some(Default/0) causes infinite increment loops
+    "crates/cashu/src/amount.rs:203:.*replace.*checked_add.*with Some\\(Default",
+
+    # amount.rs:226 - try_sum returning Ok(Default/0) causes infinite loops
+    "crates/cashu/src/amount.rs:226:.*replace.*try_sum.*with Ok\\(Default",
+
+    # amount.rs:288 - From<u64> returning Default/0 causes infinite loops
+    "crates/cashu/src/amount.rs:288:.*replace.*from.*with Default",
+
+    # amount.rs:331 - Sub returning Default/0 causes infinite loops
+    "crates/cashu/src/amount.rs:331:.*replace.*sub.*with Default",
+]

+ 58 - 0
.github/ISSUE_TEMPLATE/mutation-testing.md

@@ -0,0 +1,58 @@
+---
+name: Mutation Testing Improvement
+about: Track improvements to mutation test coverage
+title: '[Mutation] '
+labels: 'mutation-testing, enhancement'
+assignees: ''
+
+---
+
+## Mutation Details
+
+**File:** `crates/cashu/src/...`
+**Line:** 123
+**Mutation:** replace foo() with bar()
+
+**Current Status:** MISSED
+
+## Why This Matters
+
+<!-- Explain the security/correctness implications -->
+
+## Proposed Fix
+
+<!-- Describe the test(s) needed to catch this mutation -->
+
+### Test Strategy
+
+- [ ] Add negative test case
+- [ ] Add edge case test
+- [ ] Add integration test
+- [ ] Other: ___________
+
+### Expected Test
+
+```rust
+#[test]
+fn test_() {
+    // Test that ensures this mutation would be caught
+}
+```
+
+## Verification
+
+After implementing the fix:
+
+```bash
+# Run mutation test on specific file
+cargo mutants --file crates/cashu/src/...
+
+# Or run mutation test on the specific function
+cargo mutants --file crates/cashu/src/... --re "function_name"
+```
+
+Expected result: Mutation should be **CAUGHT** ✅
+
+## Related
+
+<!-- Link to any related issues or PRs -->

+ 35 - 9
.github/scripts/generate-agenda.sh

@@ -37,13 +37,14 @@ MERGED_PRS=$(gh pr list \
     --jq '.[] | [.number, .title, .url] | @tsv' \
     2>/dev/null || echo "")
 
-# Fetch ongoing (open) PRs
-echo "Fetching ongoing PRs..."
-ONGOING_PRS=$(gh pr list \
+# Fetch recently active PRs (updated in last week, but not newly created)
+echo "Fetching recently active PRs..."
+RECENTLY_ACTIVE_PRS=$(gh pr list \
     --repo "$REPO" \
     --state open \
-    --json number,title,url,createdAt \
-    --jq '.[] | select(.createdAt < "'$SINCE_DATE'") | [.number, .title, .url] | @tsv' \
+    --search "updated:>=$SINCE_DATE -created:>=$SINCE_DATE" \
+    --json number,title,url \
+    --jq '.[] | [.number, .title, .url] | @tsv' \
     2>/dev/null || echo "")
 
 # Fetch new PRs (opened in the last week)
@@ -66,6 +67,27 @@ NEW_ISSUES=$(gh issue list \
     --jq '.[] | [.number, .title, .url] | @tsv' \
     2>/dev/null || echo "")
 
+# Fetch discussion items (labeled with meeting-discussion)
+echo "Fetching discussion items..."
+DISCUSSION_PRS=$(gh pr list \
+    --repo "$REPO" \
+    --state open \
+    --label "meeting-discussion" \
+    --json number,title,url \
+    --jq '.[] | [.number, .title, .url] | @tsv' \
+    2>/dev/null || echo "")
+
+DISCUSSION_ISSUES=$(gh issue list \
+    --repo "$REPO" \
+    --state open \
+    --label "meeting-discussion" \
+    --json number,title,url \
+    --jq '.[] | [.number, .title, .url] | @tsv' \
+    2>/dev/null || echo "")
+
+# Combine discussion items (PRs and issues)
+DISCUSSION_ITEMS=$(printf "%s\n%s" "$DISCUSSION_PRS" "$DISCUSSION_ISSUES" | grep -v '^$' || echo "")
+
 # Generate markdown
 AGENDA=$(cat <<EOF
 # CDK Development Meeting
@@ -78,10 +100,6 @@ Meeting Link: $MEETING_LINK
 
 $(format_list "$MERGED_PRS")
 
-## Ongoing
-
-$(format_list "$ONGOING_PRS")
-
 ## New
 
 ### Issues
@@ -91,6 +109,14 @@ $(format_list "$NEW_ISSUES")
 ### PRs
 
 $(format_list "$NEW_PRS")
+
+## Recently Active
+
+$(format_list "$RECENTLY_ACTIVE_PRS")
+
+## Discussion
+
+$(format_list "$DISCUSSION_ITEMS")
 EOF
 )
 

+ 17 - 90
.github/workflows/ci.yml

@@ -34,16 +34,11 @@ jobs:
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
         with:
-          shared-key: "nightly-${{ steps.flake-hash.outputs.hash }}"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Cargo fmt
-        run: |
-          nix develop -i -L .#nightly --command bash -c '
-            # Force use of Nix-provided rustfmt
-            export RUSTFMT=$(command -v rustfmt)
-            cargo fmt --check
-          '
+        run: nix develop -i -L .#stable --command cargo fmt --check
       - name: typos
-        run: nix develop -i -L .#nightly --command typos
+        run: nix develop -i -L .#stable --command typos
 
   examples:
     name: "Run examples"
@@ -381,61 +376,17 @@ jobs:
       matrix:
         build-args:
           [
-            -p cashu --no-default-features --features "wallet mint",
-            -p cdk-common --no-default-features --features "wallet mint",
-            -p cdk-sql-common,
-            -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-mintd,
-            -p cdk-payment-processor --no-default-features,
-          ]
-    steps:
-      - name: checkout
-        uses: actions/checkout@v4
-      - name: Get flake hash
-        id: flake-hash
-        run: echo "hash=$(sha256sum flake.lock | cut -d' ' -f1 | cut -c1-8)" >> $GITHUB_OUTPUT
-      - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v17
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@main
-        with:
-          diagnostic-endpoint: ""
-          use-flakehub: false
-      - name: Rust Cache
-        uses: Swatinem/rust-cache@v2
-        with:
-          shared-key: "msrv-${{ steps.flake-hash.outputs.hash }}"
-      - name: Build
-        run: nix develop -i -L .#msrv --command cargo build ${{ matrix.build-args }}
+            # Core library - all features EXCEPT swagger (which breaks MSRV)
+            '-p cdk --features "mint,wallet,auth,nostr,bip353,tor,prometheus"',
 
+            # Mintd with all backends, databases, and features (no swagger)
+            # This also validates cdk-axum, all LN backends, all databases as dependencies
+            '-p cdk-mintd --no-default-features --features "cln,lnd,lnbits,fakewallet,ldk-node,grpc-processor,sqlite,postgres,auth,prometheus,redis,management-rpc"',
 
-  check-wasm:
-    name: Check WASM
-    runs-on: ubuntu-latest
-    timeout-minutes: 30
-    needs: pre-commit-checks
-    strategy:
-      fail-fast: true
-      matrix:
-        rust:
-          - stable
-        target:
-          - wasm32-unknown-unknown
-        build-args:
-          [
-            -p cdk,
-            -p cdk --no-default-features,
+            # CLI - default features (excludes redb which breaks MSRV)
+            -p cdk-cli,
+
+            # Minimal builds to ensure no-default-features works
             -p cdk --no-default-features --features wallet,
           ]
     steps:
@@ -454,10 +405,9 @@ jobs:
       - name: Rust Cache
         uses: Swatinem/rust-cache@v2
         with:
-          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
-      - name: Build cdk and binding
-        run: nix develop -i -L ".#${{ matrix.rust }}" --command cargo build ${{ matrix.build-args }} --target ${{ matrix.target }}
-
+          shared-key: "msrv-${{ steps.flake-hash.outputs.hash }}"
+      - name: Build
+        run: nix develop -i -L .#msrv --command cargo build ${{ matrix.build-args }}
 
   check-wasm-msrv:
     name: Check WASM
@@ -550,8 +500,8 @@ jobs:
         run: |
           docker compose -f misc/keycloak/docker-compose-recover.yml down
 
-  doc-tests:
-    name: "Documentation Tests"
+  docs:
+    name: "Documentation tests and checks"
     runs-on: ubuntu-latest
     timeout-minutes: 30
     needs: pre-commit-checks
@@ -584,28 +534,5 @@ jobs:
           shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - 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: 30
-    needs: doc-tests
-    steps:
-      - name: checkout
-        uses: actions/checkout@v4
-      - name: Get flake hash
-        id: flake-hash
-        run: echo "hash=$(sha256sum flake.lock | cut -d' ' -f1 | cut -c1-8)" >> $GITHUB_OUTPUT
-      - name: Install Nix
-        uses: DeterminateSystems/nix-installer-action@v17
-      - name: Nix Cache
-        uses: DeterminateSystems/magic-nix-cache-action@main
-        with:
-          diagnostic-endpoint: ""
-          use-flakehub: false
-      - name: Rust Cache
-        uses: Swatinem/rust-cache@v2
-        with:
-          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Check docs with strict warnings
         run: nix develop -i -L .#stable --command just docs-strict

+ 83 - 0
.github/workflows/mutation-testing-weekly.yml

@@ -0,0 +1,83 @@
+name: Weekly Mutation Testing
+
+on:
+  # Run every Friday at 3 AM UTC
+  schedule:
+    - cron: '0 3 * * 5'
+  # Allow manual trigger
+  workflow_dispatch:
+
+env:
+  CARGO_TERM_COLOR: always
+
+jobs:
+  cargo-mutants:
+    runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      issues: write
+
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Install Rust
+        uses: dtolnay/rust-toolchain@stable
+
+      - uses: taiki-e/install-action@v2
+        with:
+          tool: cargo-mutants
+
+      - name: Run mutation tests on cashu crate
+        run: cargo mutants --package cashu --in-place --no-shuffle
+        continue-on-error: true
+
+      - name: Upload mutation results
+        uses: actions/upload-artifact@v4
+        if: always()
+        with:
+          name: mutants.out
+          path: mutants.out
+          retention-days: 90
+
+      - name: Check for missed mutants and create issue
+        if: always()
+        run: |
+          if [ -s mutants.out/missed.txt ]; then
+            echo "Missed mutants found"
+            MUTANTS_VERSION=$(cargo mutants --version)
+            MISSED_COUNT=$(wc -l < mutants.out/missed.txt)
+            CAUGHT_COUNT=$(wc -l < mutants.out/caught.txt 2>/dev/null || echo "0")
+
+            gh issue create \
+              --title "🧬 Weekly Mutation Testing Report - $(date +%Y-%m-%d)" \
+              --label "mutation-testing,weekly-report" \
+              --body "$(cat <<EOF
+          ## Mutation Testing Results
+
+          - ✅ Caught: ${CAUGHT_COUNT}
+          - ❌ Missed: ${MISSED_COUNT}
+
+          ### Top 10 Missed Mutations
+
+          \`\`\`
+          $(head -n 10 mutants.out/missed.txt)
+          \`\`\`
+
+          ### Action Items
+
+          1. Review the missed mutations above
+          2. Add tests to catch these mutations
+          3. For the complete list, check the [mutants.out artifact](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})
+
+          **cargo-mutants version:** ${MUTANTS_VERSION}
+
+          ---
+
+          💡 **Tip:** Use \`just mutants-quick\` to test only your changes before pushing!
+          EOF
+          )"
+          else
+            echo "No missed mutants found! 🎉"
+          fi
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 57 - 0
.github/workflows/nightly-rustfmt.yml

@@ -0,0 +1,57 @@
+name: Nightly rustfmt
+on:
+  schedule:
+    - cron: "0 0 * * *" # runs daily at 00:00 UTC
+  workflow_dispatch: # allows manual triggering
+
+permissions: {}
+
+jobs:
+  format:
+    name: Nightly rustfmt
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write
+      pull-requests: write
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          ref: main
+      - name: Get flake hash
+        id: flake-hash
+        run: echo "hash=$(sha256sum flake.lock | cut -d' ' -f1 | cut -c1-8)" >> $GITHUB_OUTPUT
+      - name: Install Nix
+        uses: DeterminateSystems/nix-installer-action@v17
+      - name: Nix Cache
+        uses: DeterminateSystems/magic-nix-cache-action@main
+        with:
+          diagnostic-endpoint: ""
+          use-flakehub: false
+      - name: Rust Cache
+        uses: Swatinem/rust-cache@v2
+        with:
+          shared-key: "nightly-${{ steps.flake-hash.outputs.hash }}"
+      - name: Run Nightly rustfmt
+        run: |
+          nix develop -i -L .#nightly --command bash -c '
+            # Force use of Nix-provided rustfmt
+            export RUSTFMT=$(command -v rustfmt)
+            cargo fmt
+          '
+          # Manually remove trailing whitespace
+          find . -name '*.rs' -type f -exec sed -E -i 's/[[:space:]]+$//' {} +
+      - name: Get the current date
+        run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_ENV
+      - name: Create Pull Request
+        uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
+        env:
+          PRE_COMMIT_ALLOW_NO_CONFIG: 1
+        with:
+          token: ${{ secrets.BACKPORT_TOKEN }}
+          author: Fmt Bot <bot@cashudevkit.org>
+          title: Automated nightly rustfmt (${{ env.date }})
+          body: |
+            Automated nightly `rustfmt` changes by [create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub action
+          commit-message: ${{ env.date }} automated rustfmt nightly
+          labels: rustfmt
+          branch: automated-rustfmt-${{ env.date }}

+ 77 - 0
.github/workflows/update-rust-version.yml

@@ -0,0 +1,77 @@
+name: Update Rust Version
+
+on:
+  schedule:
+    # Run weekly on Monday at 9 AM UTC
+    - cron: '0 9 * * 1'
+  workflow_dispatch: # Allow manual triggering
+
+permissions: {}
+
+jobs:
+  check-rust-version:
+    name: Check and update Rust version
+    runs-on: ubuntu-latest
+    permissions:
+      issues: write
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Get latest stable Rust version
+        id: latest-rust
+        run: |
+          # Fetch the latest stable Rust version from GitHub releases API
+          LATEST_VERSION=$(curl -s https://api.github.com/repos/rust-lang/rust/releases | jq -r '[.[] | select(.prerelease == false and .draft == false)][0].tag_name')
+          echo "version=$LATEST_VERSION" >> $GITHUB_OUTPUT
+          echo "Latest stable Rust version: $LATEST_VERSION"
+
+      - name: Get current Rust version
+        id: current-rust
+        run: |
+          # Extract current version from rust-toolchain.toml
+          CURRENT_VERSION=$(grep '^channel=' rust-toolchain.toml | sed 's/channel="\(.*\)"/\1/')
+          echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
+          echo "Current Rust version: $CURRENT_VERSION"
+
+      - name: Compare versions
+        id: compare
+        run: |
+          if [ "${{ steps.latest-rust.outputs.version }}" != "${{ steps.current-rust.outputs.version }}" ]; then
+            echo "needs_update=true" >> $GITHUB_OUTPUT
+            echo "Rust version needs update: ${{ steps.current-rust.outputs.version }} -> ${{ steps.latest-rust.outputs.version }}"
+          else
+            echo "needs_update=false" >> $GITHUB_OUTPUT
+            echo "Rust version is up to date"
+          fi
+
+      - name: Create Issue
+        if: steps.compare.outputs.needs_update == 'true'
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          gh issue create \
+            --title "Update Rust to ${{ steps.latest-rust.outputs.version }}" \
+            --label "rust-version" \
+            --assignee thesimplekid \
+            --body "$(cat <<'EOF'
+          New Rust version **${{ steps.latest-rust.outputs.version }}** is available (currently on **${{ steps.current-rust.outputs.version }}**).
+
+          ## Files to update
+          - \`rust-toolchain.toml\` - Update channel to \`${{ steps.latest-rust.outputs.version }}\`
+          - \`flake.nix\` - Update stable_toolchain to \`pkgs.rust-bin.stable."${{ steps.latest-rust.outputs.version }}".default\`
+          - Run \`nix flake update rust-overlay\` to update \`flake.lock\`
+
+          ## Release Notes
+          Check the [Rust release notes](https://github.com/rust-lang/rust/blob/master/RELEASES.md) for details on what's new in this version.
+
+          ---
+          🤖 Automated issue created by update-rust-version workflow
+          EOF
+          )"
+
+      - name: No update needed
+        if: steps.compare.outputs.needs_update == 'false'
+        run: |
+          echo "✓ Rust version is already up to date (${{ steps.current-rust.outputs.version }})"

+ 5 - 0
.gitignore

@@ -12,3 +12,8 @@ Cargo.lock
 .aider*
 **/postgres_data/
 **/.env
+
+# Mutation testing artifacts
+mutants.out/
+mutants-*.log
+.mutants.lock

+ 61 - 0
DEVELOPMENT.md

@@ -170,11 +170,72 @@ 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";`)
 
+### Running Mutation Tests
+
+Mutation testing validates test suite quality by introducing small code changes (mutations) and verifying tests catch them.
+
+```bash
+# Run mutation tests on cashu crate (configured in .cargo/mutants.toml)
+cargo mutants
+
+# Check specific files only
+cargo mutants --file crates/cashu/src/amount.rs
+
+# Re-run previously caught mutations to verify fixes
+cargo mutants --in-diff
+```
+
+**Understanding Results:**
+- **Caught mutations**: Tests correctly detected the code change (good!)
+- **Missed mutations**: Code change went undetected - indicates missing test coverage
+- **Timeouts**: Mutation caused infinite loop - some are excluded in config to keep tests practical
+
+The `.cargo/mutants.toml` file excludes mutations that cause infinite loops during testing. These don't indicate bugs - they're just mutations that would make the test suite hang indefinitely.
+
+See [cargo-mutants documentation](https://mutants.rs/) for more options.
+
 ### Running Format
 ```bash
 just format
 ```
 
+## Code Formatting
+
+CDK uses a flexible rustfmt policy to balance code quality with developer experience:
+
+### Formatting Requirements for PRs
+Pull requests can be formatted with **either stable or nightly** rustfmt - both are accepted:
+
+- **Stable rustfmt:** Standard Rust formatting (less strict)
+- **Nightly rustfmt:** More strict formatting with additional rules
+
+**Why both are accepted:**
+- We prefer nightly rustfmt's stricter formatting
+- We don't want to force contributors to install nightly Rust
+- This reduces friction for developers using stable toolchains
+
+```bash
+# Format with stable (default)
+just format
+
+# Format with nightly (if you have it installed)
+cargo +nightly fmt
+```
+
+The CI will check your PR with stable rustfmt, so as long as your code passes stable formatting, your PR will pass CI.
+
+### Automated Nightly Formatting
+To keep the codebase consistently formatted with nightly rustfmt over time:
+
+- **Daily Check:** Every night at midnight UTC, a GitHub Action runs nightly rustfmt on the `main` branch
+- **Automated PRs:** If nightly rustfmt produces formatting changes, a PR is automatically created with:
+  - Title: `Automated nightly rustfmt (YYYY-MM-DD)`
+  - Label: `rustfmt`
+  - Author: `Fmt Bot <bot@cashudevkit.org>`
+- **Review Process:** These automated PRs are reviewed and merged to keep the codebase aligned with nightly formatting
+
+This approach ensures the codebase gradually adopts nightly formatting improvements without blocking contributors who use stable Rust.
+
 
 ### Running Clippy
 ```bash

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

@@ -753,4 +753,349 @@ mod tests {
 
         assert!(converted.is_err());
     }
+
+    /// Tests that the subtraction operator correctly computes the difference between amounts.
+    ///
+    /// This test verifies that the `-` operator for Amount produces the expected result.
+    /// It's particularly important because the subtraction operation is used in critical
+    /// code paths like `split_targeted`, where incorrect subtraction could lead to
+    /// infinite loops or wrong calculations.
+    ///
+    /// Mutant testing: Catches mutations that replace the subtraction implementation
+    /// with `Default::default()` (returning Amount::ZERO), which would cause infinite
+    /// loops in `split_targeted` at line 138 where `*self - parts_total` is computed.
+    #[test]
+    fn test_amount_sub_operator() {
+        let amount1 = Amount::from(100);
+        let amount2 = Amount::from(30);
+
+        let result = amount1 - amount2;
+        assert_eq!(result, Amount::from(70));
+
+        let amount1 = Amount::from(1000);
+        let amount2 = Amount::from(1);
+
+        let result = amount1 - amount2;
+        assert_eq!(result, Amount::from(999));
+
+        let amount1 = Amount::from(255);
+        let amount2 = Amount::from(128);
+
+        let result = amount1 - amount2;
+        assert_eq!(result, Amount::from(127));
+    }
+
+    /// Tests that the subtraction operator panics when attempting to subtract
+    /// a larger amount from a smaller amount (underflow).
+    ///
+    /// This test verifies the safety property that Amount subtraction will panic
+    /// rather than wrap around on underflow. This is critical for preventing
+    /// bugs where negative amounts could be interpreted as very large positive amounts.
+    ///
+    /// Mutant testing: Catches mutations that remove the panic behavior or return
+    /// default values instead of properly handling underflow.
+    #[test]
+    #[should_panic(expected = "Subtraction underflow")]
+    fn test_amount_sub_underflow() {
+        let amount1 = Amount::from(30);
+        let amount2 = Amount::from(100);
+
+        let _result = amount1 - amount2;
+    }
+
+    /// Tests that checked_add correctly computes the sum and returns the actual value.
+    ///
+    /// This is critical because checked_add is used in recursive functions like
+    /// split_with_fee. If it returns Some(Amount::ZERO) instead of the actual sum,
+    /// the recursion would never terminate.
+    ///
+    /// Mutant testing: Kills mutations that replace the implementation with
+    /// `Some(Default::default())`, which would cause infinite loops in split_with_fee
+    /// at line 198 where it recursively calls itself with incremented amounts.
+    #[test]
+    fn test_checked_add_returns_correct_value() {
+        let amount1 = Amount::from(100);
+        let amount2 = Amount::from(50);
+
+        let result = amount1.checked_add(amount2);
+        assert_eq!(result, Some(Amount::from(150)));
+
+        let amount1 = Amount::from(1);
+        let amount2 = Amount::from(1);
+
+        let result = amount1.checked_add(amount2);
+        assert_eq!(result, Some(Amount::from(2)));
+        assert_ne!(result, Some(Amount::ZERO));
+
+        let amount1 = Amount::from(1000);
+        let amount2 = Amount::from(337);
+
+        let result = amount1.checked_add(amount2);
+        assert_eq!(result, Some(Amount::from(1337)));
+    }
+
+    /// Tests that checked_add returns None on overflow.
+    #[test]
+    fn test_checked_add_overflow() {
+        let amount1 = Amount::from(u64::MAX);
+        let amount2 = Amount::from(1);
+
+        let result = amount1.checked_add(amount2);
+        assert!(result.is_none());
+    }
+
+    /// Tests that try_sum correctly computes the total sum of amounts.
+    ///
+    /// This is critical because try_sum is used in loops like split_targeted at line 130
+    /// to track progress. If it returns Ok(Amount::ZERO) instead of the actual sum,
+    /// the loop condition `parts_total.eq(self)` would never be true, causing an infinite loop.
+    ///
+    /// Mutant testing: Kills mutations that replace the implementation with
+    /// `Ok(Default::default())`, which would cause infinite loops.
+    #[test]
+    fn test_try_sum_returns_correct_value() {
+        let amounts = vec![Amount::from(10), Amount::from(20), Amount::from(30)];
+        let result = Amount::try_sum(amounts).unwrap();
+        assert_eq!(result, Amount::from(60));
+        assert_ne!(result, Amount::ZERO);
+
+        let amounts = vec![Amount::from(1), Amount::from(1), Amount::from(1)];
+        let result = Amount::try_sum(amounts).unwrap();
+        assert_eq!(result, Amount::from(3));
+
+        let amounts = vec![Amount::from(100)];
+        let result = Amount::try_sum(amounts).unwrap();
+        assert_eq!(result, Amount::from(100));
+
+        let empty: Vec<Amount> = vec![];
+        let result = Amount::try_sum(empty).unwrap();
+        assert_eq!(result, Amount::ZERO);
+    }
+
+    /// Tests that try_sum returns error on overflow.
+    #[test]
+    fn test_try_sum_overflow() {
+        let amounts = vec![Amount::from(u64::MAX), Amount::from(1)];
+        let result = Amount::try_sum(amounts);
+        assert!(result.is_err());
+    }
+
+    /// Tests that split returns a non-empty vec with actual values, not defaults.
+    ///
+    /// The split function is used in split_targeted's while loop (line 122).
+    /// If split returns an empty vec or vec with Amount::ZERO when it shouldn't,
+    /// the loop that extends parts with split results would never make progress,
+    /// causing an infinite loop.
+    ///
+    /// Mutant testing: Kills mutations that replace split with `vec![]` or
+    /// `vec![Default::default()]` which would cause infinite loops.
+    #[test]
+    fn test_split_returns_correct_values() {
+        let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into();
+
+        let amount = Amount::from(11);
+        let result = amount.split(&fee_and_amounts);
+        assert!(!result.is_empty());
+        assert_eq!(Amount::try_sum(result.iter().copied()).unwrap(), amount);
+
+        let amount = Amount::from(255);
+        let result = amount.split(&fee_and_amounts);
+        assert!(!result.is_empty());
+        assert_eq!(Amount::try_sum(result.iter().copied()).unwrap(), amount);
+
+        let amount = Amount::from(7);
+        let result = amount.split(&fee_and_amounts);
+        assert_eq!(
+            result,
+            vec![Amount::from(4), Amount::from(2), Amount::from(1)]
+        );
+        for r in &result {
+            assert_ne!(*r, Amount::ZERO);
+        }
+    }
+
+    /// Tests that the modulo operation in split works correctly.
+    ///
+    /// At line 108, split uses modulo (%) to compute the remainder.
+    /// If this is mutated to division (/), it would produce wrong results
+    /// that could cause infinite loops in code that depends on split.
+    ///
+    /// Mutant testing: Kills mutations that replace `%` with `/`.
+    #[test]
+    fn test_split_modulo_operation() {
+        let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into();
+
+        let amount = Amount::from(15);
+        let result = amount.split(&fee_and_amounts);
+
+        assert_eq!(
+            result,
+            vec![
+                Amount::from(8),
+                Amount::from(4),
+                Amount::from(2),
+                Amount::from(1)
+            ]
+        );
+
+        let total = Amount::try_sum(result.iter().copied()).unwrap();
+        assert_eq!(total, amount);
+    }
+
+    /// Tests that From<u64> correctly converts values to Amount.
+    ///
+    /// This conversion is used throughout the codebase including in loops and split operations.
+    /// If it returns Default::default() (Amount::ZERO) instead of the actual value,
+    /// it can cause infinite loops where amounts are being accumulated or compared.
+    ///
+    /// Mutant testing: Kills mutations that replace From<u64> with `Default::default()`.
+    #[test]
+    fn test_from_u64_returns_correct_value() {
+        let amount = Amount::from(100u64);
+        assert_eq!(amount, Amount(100));
+        assert_ne!(amount, Amount::ZERO);
+
+        let amount = Amount::from(1u64);
+        assert_eq!(amount, Amount(1));
+        assert_eq!(amount, Amount::ONE);
+
+        let amount = Amount::from(1337u64);
+        assert_eq!(amount.to_u64(), 1337);
+    }
+
+    /// Tests that checked_mul returns the correct product value.
+    ///
+    /// This is critical for any multiplication operations. If it returns None
+    /// or Some(Amount::ZERO) instead of the actual product, calculations will be wrong.
+    ///
+    /// Mutant testing: Kills mutations that replace checked_mul with None or Some(Default::default()).
+    #[test]
+    fn test_checked_mul_returns_correct_value() {
+        let amount1 = Amount::from(10);
+        let amount2 = Amount::from(5);
+        let result = amount1.checked_mul(amount2);
+        assert_eq!(result, Some(Amount::from(50)));
+        assert_ne!(result, None);
+        assert_ne!(result, Some(Amount::ZERO));
+
+        let amount1 = Amount::from(100);
+        let amount2 = Amount::from(20);
+        let result = amount1.checked_mul(amount2);
+        assert_eq!(result, Some(Amount::from(2000)));
+        assert_ne!(result, Some(Amount::ZERO));
+
+        let amount1 = Amount::from(7);
+        let amount2 = Amount::from(13);
+        let result = amount1.checked_mul(amount2);
+        assert_eq!(result, Some(Amount::from(91)));
+
+        // Test multiplication by zero
+        let amount1 = Amount::from(100);
+        let amount2 = Amount::ZERO;
+        let result = amount1.checked_mul(amount2);
+        assert_eq!(result, Some(Amount::ZERO));
+
+        // Test multiplication by one
+        let amount1 = Amount::from(42);
+        let amount2 = Amount::ONE;
+        let result = amount1.checked_mul(amount2);
+        assert_eq!(result, Some(Amount::from(42)));
+
+        // Test overflow
+        let amount1 = Amount::from(u64::MAX);
+        let amount2 = Amount::from(2);
+        let result = amount1.checked_mul(amount2);
+        assert!(result.is_none());
+    }
+
+    /// Tests that checked_div returns the correct quotient value.
+    ///
+    /// This is critical for division operations. If it returns None or
+    /// Some(Amount::ZERO) instead of the actual quotient, calculations will be wrong.
+    ///
+    /// Mutant testing: Kills mutations that replace checked_div with None or Some(Default::default()).
+    #[test]
+    fn test_checked_div_returns_correct_value() {
+        let amount1 = Amount::from(100);
+        let amount2 = Amount::from(5);
+        let result = amount1.checked_div(amount2);
+        assert_eq!(result, Some(Amount::from(20)));
+        assert_ne!(result, None);
+        assert_ne!(result, Some(Amount::ZERO));
+
+        let amount1 = Amount::from(1000);
+        let amount2 = Amount::from(10);
+        let result = amount1.checked_div(amount2);
+        assert_eq!(result, Some(Amount::from(100)));
+        assert_ne!(result, Some(Amount::ZERO));
+
+        let amount1 = Amount::from(91);
+        let amount2 = Amount::from(7);
+        let result = amount1.checked_div(amount2);
+        assert_eq!(result, Some(Amount::from(13)));
+
+        // Test division by one
+        let amount1 = Amount::from(42);
+        let amount2 = Amount::ONE;
+        let result = amount1.checked_div(amount2);
+        assert_eq!(result, Some(Amount::from(42)));
+
+        // Test integer division (truncation)
+        let amount1 = Amount::from(10);
+        let amount2 = Amount::from(3);
+        let result = amount1.checked_div(amount2);
+        assert_eq!(result, Some(Amount::from(3)));
+
+        // Test division by zero
+        let amount1 = Amount::from(100);
+        let amount2 = Amount::ZERO;
+        let result = amount1.checked_div(amount2);
+        assert!(result.is_none());
+    }
+
+    /// Tests that Amount::convert_unit returns the correct converted value.
+    ///
+    /// This is critical for unit conversions. If it returns Ok(Amount::ZERO)
+    /// instead of the actual converted value, all conversions will be wrong.
+    ///
+    /// Mutant testing: Kills mutations that replace convert_unit with Ok(Default::default()).
+    #[test]
+    fn test_convert_unit_returns_correct_value() {
+        let amount = Amount::from(1000);
+        let result = amount
+            .convert_unit(&CurrencyUnit::Sat, &CurrencyUnit::Msat)
+            .unwrap();
+        assert_eq!(result, Amount::from(1_000_000));
+        assert_ne!(result, Amount::ZERO);
+
+        let amount = Amount::from(5000);
+        let result = amount
+            .convert_unit(&CurrencyUnit::Msat, &CurrencyUnit::Sat)
+            .unwrap();
+        assert_eq!(result, Amount::from(5));
+        assert_ne!(result, Amount::ZERO);
+
+        let amount = Amount::from(123);
+        let result = amount
+            .convert_unit(&CurrencyUnit::Sat, &CurrencyUnit::Sat)
+            .unwrap();
+        assert_eq!(result, Amount::from(123));
+
+        let amount = Amount::from(456);
+        let result = amount
+            .convert_unit(&CurrencyUnit::Usd, &CurrencyUnit::Usd)
+            .unwrap();
+        assert_eq!(result, Amount::from(456));
+
+        let amount = Amount::from(789);
+        let result = amount
+            .convert_unit(&CurrencyUnit::Eur, &CurrencyUnit::Eur)
+            .unwrap();
+        assert_eq!(result, Amount::from(789));
+
+        // Test invalid conversion
+        let amount = Amount::from(100);
+        let result = amount.convert_unit(&CurrencyUnit::Sat, &CurrencyUnit::Eur);
+        assert!(result.is_err());
+    }
 }

+ 164 - 0
crates/cashu/src/dhke.rs

@@ -388,4 +388,168 @@ mod tests {
 
         assert!(verify_message(&bob_sec, unblinded, &message).is_ok());
     }
+
+    /// Tests that `verify_message` correctly rejects verification when using an incorrect key.
+    ///
+    /// This test ensures that the verification process fails when attempting to verify
+    /// a signature with a different key than the one used to create it. This is critical
+    /// for security - if this check didn't exist, tokens could be forged by anyone.
+    ///
+    /// Mutant testing: Catches mutations that remove or weaken the key comparison logic
+    /// in `verify_message`, such as always returning Ok or ignoring the key parameter.
+    #[test]
+    fn test_verify_message_wrong_key() {
+        // Test that verify_message fails with wrong key
+        let message = b"test message";
+        let correct_key =
+            SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
+                .unwrap();
+        let wrong_key =
+            SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
+                .unwrap();
+
+        let (blinded, r) = blind_message(message, None).unwrap();
+        let signed = sign_message(&correct_key, &blinded).unwrap();
+        let unblinded = unblind_message(&signed, &r, &correct_key.public_key()).unwrap();
+
+        // Should fail with wrong key
+        assert!(verify_message(&wrong_key, unblinded, message).is_err());
+    }
+
+    /// Tests that `verify_message` correctly rejects verification when the message doesn't match.
+    ///
+    /// This test ensures that attempting to verify a signature against a different message
+    /// than the one originally signed results in an error. This prevents message substitution
+    /// attacks where an attacker might try to claim a signature for one message is valid
+    /// for a different message.
+    ///
+    /// Mutant testing: Catches mutations that remove or weaken the message comparison logic,
+    /// such as skipping the hash_to_curve step or ignoring the message parameter entirely.
+    #[test]
+    fn test_verify_message_wrong_message() {
+        // Test that verify_message fails with wrong message
+        let message = b"test message";
+        let wrong_message = b"wrong message";
+        let key =
+            SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
+                .unwrap();
+
+        let (blinded, r) = blind_message(message, None).unwrap();
+        let signed = sign_message(&key, &blinded).unwrap();
+        let unblinded = unblind_message(&signed, &r, &key.public_key()).unwrap();
+
+        // Should fail with wrong message
+        assert!(verify_message(&key, unblinded, wrong_message).is_err());
+    }
+
+    /// Tests that `construct_proofs` returns an error when input vectors have mismatched lengths.
+    ///
+    /// This test verifies that the function properly validates that the `promises`, `rs`, and
+    /// `secrets` vectors all have the same length before processing. This is essential for
+    /// correctness - each proof requires exactly one promise, one blinding factor (r), and
+    /// one secret. Mismatched lengths would indicate a programming error or corrupted data.
+    ///
+    /// Mutant testing: Catches mutations that remove or weaken the length validation check
+    /// at the beginning of `construct_proofs`, such as changing `!=` to `==` or removing
+    /// the validation entirely, which could lead to panics or incorrect proof construction.
+    #[test]
+    fn test_construct_proofs_length_mismatch() {
+        use std::collections::BTreeMap;
+
+        use crate::nuts::nut02::Id;
+        use crate::Amount;
+
+        // Test that construct_proofs fails when lengths don't match
+        let mut keys_map = BTreeMap::new();
+        keys_map.insert(Amount::from(1), SecretKey::generate().public_key());
+        let keys = Keys::new(keys_map);
+
+        // Mismatched promises and rs lengths
+        let promise = BlindSignature {
+            amount: Amount::from(1),
+            c: SecretKey::generate().public_key(),
+            keyset_id: Id::from_str("00deadbeef123456").unwrap(),
+            dleq: None,
+        };
+        let promises = vec![promise];
+        let rs = vec![SecretKey::generate(), SecretKey::generate()]; // Different length
+        let secrets = vec![Secret::from_str("test").unwrap()];
+
+        let result = construct_proofs(promises, rs, secrets, &keys);
+        assert!(result.is_err());
+    }
+
+    /// Tests that `construct_proofs` returns the correct number of proof objects.
+    ///
+    /// This test verifies that when given N valid inputs (promises, blinding factors, secrets),
+    /// the function returns exactly N proofs, not zero or any other count. This ensures that
+    /// the loop in `construct_proofs` actually processes all inputs and accumulates results
+    /// correctly.
+    ///
+    /// Mutant testing: Specifically designed to catch mutations that replace the function body
+    /// with `Ok(Default::default())` or similar shortcuts that would return an empty vector
+    /// instead of processing the inputs. This is a common mutation that could pass tests that
+    /// only check for success without verifying the actual results.
+    #[test]
+    fn test_construct_proofs_returns_correct_count() {
+        use std::collections::BTreeMap;
+
+        use crate::nuts::nut02::Id;
+        use crate::Amount;
+
+        // Test that construct_proofs returns the correct number of proofs
+        let secret_key = SecretKey::generate();
+        let mut keys_map = BTreeMap::new();
+        keys_map.insert(Amount::from(1), secret_key.public_key());
+        let keys = Keys::new(keys_map);
+
+        let secret = Secret::from_str("test").unwrap();
+        let (blinded_message, r) = blind_message(secret.as_bytes(), None).unwrap();
+        let signature = sign_message(&secret_key, &blinded_message).unwrap();
+
+        let promise = BlindSignature {
+            amount: Amount::from(1),
+            c: signature,
+            keyset_id: Id::from_str("00deadbeef123456").unwrap(),
+            dleq: None,
+        };
+
+        let promises = vec![promise.clone(), promise.clone()];
+        let rs = vec![r.clone(), r];
+        let secrets = vec![secret.clone(), secret];
+
+        let proofs = construct_proofs(promises, rs, secrets, &keys).unwrap();
+
+        // Should return 2 proofs, not 0 (kills the Ok(Default::default()) mutant)
+        assert_eq!(proofs.len(), 2);
+    }
+
+    /// Tests that hash_to_curve properly increments the counter and terminates.
+    ///
+    /// The hash_to_curve function uses a counter that increments in a loop at line 61.
+    /// If the counter increment is mutated (e.g., to `counter *= 1`), the loop would
+    /// never progress and would run until the timeout.
+    ///
+    /// This test uses a message that requires multiple iterations to find a valid point,
+    /// ensuring the counter increment logic is working correctly.
+    ///
+    /// Mutant testing: Kills mutations that replace `counter += 1` with `counter *= 1`
+    /// or other operations that don't advance the counter.
+    #[test]
+    fn test_hash_to_curve_counter_increments() {
+        // This specific message is documented in test_hash_to_curve as taking
+        // "a few iterations of the loop before finding a valid point"
+        let secret = "0000000000000000000000000000000000000000000000000000000000000002";
+        let sec_hex = hex::decode(secret).unwrap();
+
+        let result = hash_to_curve(&sec_hex);
+        assert!(result.is_ok(), "hash_to_curve should find a valid point");
+
+        let y = result.unwrap();
+        let expected_y = PublicKey::from_hex(
+            "026cdbe15362df59cd1dd3c9c11de8aedac2106eca69236ecd9fbe117af897be4f",
+        )
+        .unwrap();
+        assert_eq!(y, expected_y);
+    }
 }

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

@@ -54,7 +54,7 @@ pub use nut05::{
 pub use nut06::{ContactInfo, MintInfo, MintVersion, Nuts};
 pub use nut07::{CheckStateRequest, CheckStateResponse, ProofState, State};
 pub use nut09::{RestoreRequest, RestoreResponse};
-pub use nut10::{Kind, Secret as Nut10Secret, SecretData};
+pub use nut10::{Kind, Secret as Nut10Secret, SecretData, SpendingConditionVerification};
 pub use nut11::{Conditions, P2PKWitness, SigFlag, SpendingConditions};
 pub use nut12::{BlindSignatureDleq, ProofDleq};
 pub use nut14::HTLCWitness;

+ 4 - 7
crates/cashu/src/nuts/nut00/mod.rs

@@ -306,13 +306,10 @@ impl Witness {
     pub fn add_signatures(&mut self, signatues: Vec<String>) {
         match self {
             Self::P2PKWitness(p2pk_witness) => p2pk_witness.signatures.extend(signatues),
-            Self::HTLCWitness(htlc_witness) => {
-                htlc_witness.signatures = htlc_witness.signatures.clone().map(|sigs| {
-                    let mut sigs = sigs;
-                    sigs.extend(signatues);
-                    sigs
-                });
-            }
+            Self::HTLCWitness(htlc_witness) => match &mut htlc_witness.signatures {
+                Some(sigs) => sigs.extend(signatues),
+                None => htlc_witness.signatures = Some(signatues),
+            },
         }
     }
 

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

@@ -91,6 +91,32 @@ impl SwapRequest {
     }
 }
 
+impl super::nut10::SpendingConditionVerification for SwapRequest {
+    fn inputs(&self) -> &Proofs {
+        &self.inputs
+    }
+
+    fn sig_all_msg_to_sign(&self) -> String {
+        let mut msg = String::new();
+
+        // Add all input secrets and C values in order
+        // msg = secret_0 || C_0 || ... || secret_n || C_n
+        for proof in &self.inputs {
+            msg.push_str(&proof.secret.to_string());
+            msg.push_str(&proof.c.to_hex());
+        }
+
+        // Add all output amounts and B_ values in order
+        // msg = ... || amount_0 || B_0 || ... || amount_m || B_m
+        for output in &self.outputs {
+            msg.push_str(&output.amount.to_string());
+            msg.push_str(&output.blinded_secret.to_hex());
+        }
+
+        msg
+    }
+}
+
 /// Split Response [NUT-06]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]

+ 39 - 1
crates/cashu/src/nuts/nut05.rs

@@ -129,7 +129,10 @@ impl<Q> MeltRequest<Q> {
     }
 }
 
-impl<Q: Serialize + DeserializeOwned> MeltRequest<Q> {
+impl<Q> MeltRequest<Q>
+where
+    Q: Serialize + DeserializeOwned,
+{
     /// Create new [`MeltRequest`]
     pub fn new(quote: Q, inputs: Proofs, outputs: Option<Vec<BlindedMessage>>) -> Self {
         Self {
@@ -151,6 +154,41 @@ impl<Q: Serialize + DeserializeOwned> MeltRequest<Q> {
     }
 }
 
+impl<Q> super::nut10::SpendingConditionVerification for MeltRequest<Q>
+where
+    Q: std::fmt::Display,
+{
+    fn inputs(&self) -> &Proofs {
+        &self.inputs
+    }
+
+    fn sig_all_msg_to_sign(&self) -> String {
+        let mut msg = String::new();
+
+        // Add all input secrets and C values in order
+        // msg = secret_0 || C_0 || ... || secret_n || C_n
+        for proof in &self.inputs {
+            msg.push_str(&proof.secret.to_string());
+            msg.push_str(&proof.c.to_hex());
+        }
+
+        // Add all output amounts and B_ values in order (if any)
+        // msg = ... || amount_0 || B_0 || ... || amount_m || B_m
+        if let Some(outputs) = &self.outputs {
+            for output in outputs {
+                msg.push_str(&output.amount.to_string());
+                msg.push_str(&output.blinded_secret.to_hex());
+            }
+        }
+
+        // Add quote ID
+        // msg = ... || quote_id
+        msg.push_str(&self.quote.to_string());
+
+        msg
+    }
+}
+
 /// Melt Method Settings
 #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]

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

@@ -10,6 +10,23 @@ use serde::ser::SerializeTuple;
 use serde::{Deserialize, Serialize, Serializer};
 use thiserror::Error;
 
+use super::nut01::PublicKey;
+use super::Conditions;
+
+/// Spending requirements for P2PK or HTLC verification
+///
+/// Returned by `get_pubkeys_and_required_sigs` to indicate what conditions
+/// must be met to spend a proof.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct SpendingRequirements {
+    /// Whether a preimage is required (HTLC only, before locktime)
+    pub preimage_needed: bool,
+    /// Public keys that can provide valid signatures
+    pub pubkeys: Vec<PublicKey>,
+    /// Minimum number of signatures required from the pubkeys
+    pub required_sigs: u64,
+}
+
 /// NUT13 Error
 #[derive(Debug, Error)]
 pub enum Error {
@@ -105,6 +122,462 @@ impl Secret {
     }
 }
 
+/// Get the relevant public keys and required signature count for P2PK or HTLC verification
+/// This is for NUT-11(P2PK) and NUT-14(HTLC)
+///
+/// Takes into account locktime - if locktime has passed, returns refund keys,
+/// otherwise returns primary pubkeys/hash path.
+/// From NUT-11: "If the tag locktime is the unix time and the mint's local clock is greater than
+/// locktime, the Proof becomes spendable by anyone, except [... if refund keys are specified]"
+///
+/// Returns `SpendingRequirements` containing:
+/// - `preimage_needed`: For P2PK, always false. For HTLC, true before locktime.
+/// - `pubkeys`: The public keys that can provide valid signatures
+/// - `required_sigs`: The minimum number of signatures required
+///
+/// From NUT-14: "if the current system time is later than Secret.tag.locktime, the Proof can
+/// be spent if Proof.witness includes a signature from the key in Secret.tags.refund."
+pub(crate) fn get_pubkeys_and_required_sigs(
+    secret: &Secret,
+    current_time: u64,
+) -> Result<SpendingRequirements, super::nut11::Error> {
+    debug_assert!(
+        secret.kind() == Kind::P2PK || secret.kind() == Kind::HTLC,
+        "get_pubkeys_and_required_sigs called with invalid kind - this is a bug"
+    );
+
+    let conditions: Conditions = secret
+        .secret_data()
+        .tags()
+        .cloned()
+        .unwrap_or_default()
+        .try_into()?;
+
+    // Check if locktime has passed
+    let locktime_passed = conditions
+        .locktime
+        .map(|locktime| locktime < current_time)
+        .unwrap_or(false);
+
+    // Determine which keys and signature count to use
+    if locktime_passed {
+        // After locktime: use refund path (no preimage needed)
+        if let Some(refund_keys) = &conditions.refund_keys {
+            // Locktime has passed and refund keys exist - use refund keys
+            let refund_sigs = conditions.num_sigs_refund.unwrap_or(1);
+            Ok(SpendingRequirements {
+                preimage_needed: false,
+                pubkeys: refund_keys.clone(),
+                required_sigs: refund_sigs,
+            })
+        } else {
+            // Locktime has passed with no refund keys - anyone can spend
+            Ok(SpendingRequirements {
+                preimage_needed: false,
+                pubkeys: vec![],
+                required_sigs: 0,
+            })
+        }
+    } else {
+        // Before locktime: logic differs between P2PK and HTLC
+        match secret.kind() {
+            Kind::P2PK => {
+                // P2PK: never needs preimage, use primary pubkeys
+                let mut primary_keys = vec![];
+
+                // Add the pubkey from secret.data
+                let data_pubkey = PublicKey::from_str(secret.secret_data().data())?;
+                primary_keys.push(data_pubkey);
+
+                // Add any additional pubkeys from conditions
+                if let Some(additional_keys) = &conditions.pubkeys {
+                    primary_keys.extend(additional_keys.clone());
+                }
+
+                let primary_num_sigs_required = conditions.num_sigs.unwrap_or(1);
+                Ok(SpendingRequirements {
+                    preimage_needed: false,
+                    pubkeys: primary_keys,
+                    required_sigs: primary_num_sigs_required,
+                })
+            }
+            Kind::HTLC => {
+                // HTLC: needs preimage before locktime, pubkeys from conditions
+                // (data contains hash, not pubkey)
+                let pubkeys = conditions.pubkeys.clone().unwrap_or_default();
+                // If no pubkeys are specified, require 0 signatures (only preimage needed)
+                // Otherwise, default to requiring 1 signature
+                let required_sigs = if pubkeys.is_empty() {
+                    0
+                } else {
+                    conditions.num_sigs.unwrap_or(1)
+                };
+                Ok(SpendingRequirements {
+                    preimage_needed: true,
+                    pubkeys,
+                    required_sigs,
+                })
+            }
+        }
+    }
+}
+
+use super::Proofs;
+
+/// Verify that a preimage matches the hash in the secret data
+///
+/// The preimage should be a 64-character hex string representing 32 bytes.
+/// We decode it from hex, hash it with SHA256, and compare to the hash in secret.data
+pub fn verify_htlc_preimage(
+    witness: &super::nut14::HTLCWitness,
+    secret: &Secret,
+) -> Result<(), super::nut14::Error> {
+    use bitcoin::hashes::sha256::Hash as Sha256Hash;
+    use bitcoin::hashes::Hash;
+
+    // Get the hash lock from the secret data
+    let hash_lock = Sha256Hash::from_str(secret.secret_data().data())
+        .map_err(|_| super::nut14::Error::InvalidHash)?;
+
+    // Decode and validate the preimage (returns [u8; 32])
+    let preimage_bytes = witness.preimage_data()?;
+
+    // Hash the 32-byte preimage
+    let preimage_hash = Sha256Hash::hash(&preimage_bytes);
+
+    // Compare with the hash lock
+    if hash_lock.ne(&preimage_hash) {
+        return Err(super::nut14::Error::Preimage);
+    }
+
+    Ok(())
+}
+
+/// Trait for requests that spend proofs (SwapRequest, MeltRequest)
+pub trait SpendingConditionVerification {
+    /// Get the input proofs
+    fn inputs(&self) -> &Proofs;
+
+    /// Construct the message to sign for SIG_ALL verification
+    ///
+    /// This concatenates all relevant transaction data that must be signed.
+    /// For swap: input secrets + output blinded messages
+    /// For melt: input secrets + quote/payment request
+    fn sig_all_msg_to_sign(&self) -> String;
+
+    /// Check if at least one proof in the set has SIG_ALL flag set
+    ///
+    /// SIG_ALL requires all proofs in the transaction to be signed.
+    /// If any proof has this flag, we need to verify signatures on all proofs.
+    fn has_at_least_one_sig_all(&self) -> Result<bool, super::nut11::Error> {
+        for proof in self.inputs() {
+            // Try to extract spending conditions from the proof's secret
+            if let Ok(spending_conditions) = super::SpendingConditions::try_from(&proof.secret) {
+                // Check for SIG_ALL flag in either P2PK or HTLC conditions
+                let has_sig_all = match spending_conditions {
+                    super::SpendingConditions::P2PKConditions { conditions, .. } => conditions
+                        .map(|c| c.sig_flag == super::SigFlag::SigAll)
+                        .unwrap_or(false),
+                    super::SpendingConditions::HTLCConditions { conditions, .. } => conditions
+                        .map(|c| c.sig_flag == super::SigFlag::SigAll)
+                        .unwrap_or(false),
+                };
+
+                if has_sig_all {
+                    return Ok(true);
+                }
+            }
+        }
+
+        Ok(false)
+    }
+
+    /// Verify all inputs meet SIG_ALL requirements per NUT-11
+    ///
+    /// When any input has SIG_ALL, all inputs must have:
+    /// 1. Same kind (P2PK or HTLC)
+    /// 2. SIG_ALL flag set
+    /// 3. Same Secret.data
+    /// 4. Same Secret.tags
+    fn verify_all_inputs_match_for_sig_all(&self) -> Result<(), super::nut11::Error> {
+        let inputs = self.inputs();
+
+        if inputs.is_empty() {
+            return Err(super::nut11::Error::SpendConditionsNotMet);
+        }
+
+        // Get first input's properties
+        let first_input = inputs.first().unwrap();
+        let first_secret = Secret::try_from(&first_input.secret)
+            .map_err(|_| super::nut11::Error::IncorrectSecretKind)?;
+        let first_kind = first_secret.kind();
+        let first_data = first_secret.secret_data().data();
+        let first_tags = first_secret.secret_data().tags();
+
+        // Get first input's conditions to check SIG_ALL flag
+        let first_conditions =
+            super::Conditions::try_from(first_tags.cloned().unwrap_or_default())?;
+
+        // Verify first input has SIG_ALL (it should, since we only call this function when SIG_ALL is detected)
+        if first_conditions.sig_flag != super::SigFlag::SigAll {
+            return Err(super::nut11::Error::SpendConditionsNotMet);
+        }
+
+        // Verify all remaining inputs match
+        for proof in inputs.iter().skip(1) {
+            let secret = Secret::try_from(&proof.secret)
+                .map_err(|_| super::nut11::Error::IncorrectSecretKind)?;
+
+            // Check kind matches
+            if secret.kind() != first_kind {
+                return Err(super::nut11::Error::SpendConditionsNotMet);
+            }
+
+            // Check data matches
+            if secret.secret_data().data() != first_data {
+                return Err(super::nut11::Error::SpendConditionsNotMet);
+            }
+
+            // Check tags match (this also ensures SIG_ALL flag matches, since sig_flag is part of tags)
+            if secret.secret_data().tags() != first_tags {
+                return Err(super::nut11::Error::SpendConditionsNotMet);
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Verify spending conditions for this transaction
+    ///
+    /// This is the main entry point for spending condition verification.
+    /// It checks if any input has SIG_ALL and dispatches to the appropriate verification path.
+    fn verify_spending_conditions(&self) -> Result<(), super::nut11::Error> {
+        // Check if any input has SIG_ALL flag
+        if self.has_at_least_one_sig_all()? {
+            // at least one input has SIG_ALL
+            self.verify_full_sig_all_check()
+        } else {
+            // none of the inputs are SIG_ALL, so we can simply check
+            // each independently and verify any spending conditions
+            // that may - or may not - be there.
+            self.verify_inputs_individually().map_err(|e| match e {
+                super::nut14::Error::NUT11(nut11_err) => nut11_err,
+                _ => super::nut11::Error::SpendConditionsNotMet,
+            })
+        }
+    }
+
+    /// Verify spending conditions when SIG_ALL is present
+    ///
+    /// When SIG_ALL is set, all proofs in the transaction must be signed together.
+    fn verify_full_sig_all_check(&self) -> Result<(), super::nut11::Error> {
+        debug_assert!(
+            self.has_at_least_one_sig_all()?,
+            "verify_full_sig_all_check() called on proofs without SIG_ALL. This shouldn't happen"
+        );
+        // Verify all inputs meet SIG_ALL requirements per NUT-11:
+        // All inputs must have: (1) same kind, (2) SIG_ALL flag, (3) same data, (4) same tags
+        self.verify_all_inputs_match_for_sig_all()?;
+
+        // Get the first input to determine the kind
+        let first_input = self
+            .inputs()
+            .first()
+            .ok_or(super::nut11::Error::SpendConditionsNotMet)?;
+        let first_secret = Secret::try_from(&first_input.secret)
+            .map_err(|_| super::nut11::Error::IncorrectSecretKind)?;
+
+        // Dispatch based on secret kind
+        match first_secret.kind() {
+            Kind::P2PK => {
+                self.verify_sig_all_p2pk()?;
+            }
+            Kind::HTLC => {
+                self.verify_sig_all_htlc()?;
+            }
+        }
+
+        Ok(())
+    }
+
+    /// Verify spending conditions for each input individually
+    ///
+    /// Handles SIG_INPUTS mode, non-NUT-10 secrets, and any other case where inputs
+    /// are verified independently rather than as a group.
+    /// This function will NOT be called if any input has SIG_ALL.
+    fn verify_inputs_individually(&self) -> Result<(), super::nut14::Error> {
+        debug_assert!(
+            !(self.has_at_least_one_sig_all()?),
+            "verify_inputs_individually() called on SIG_ALL. This shouldn't happen"
+        );
+        for proof in self.inputs() {
+            // Check if secret is a nut10 secret with conditions
+            if let Ok(secret) = Secret::try_from(&proof.secret) {
+                // Verify this function isn't being called with SIG_ALL proofs (development check)
+                if let Ok(conditions) = super::Conditions::try_from(
+                    secret.secret_data().tags().cloned().unwrap_or_default(),
+                ) {
+                    debug_assert!(
+                        conditions.sig_flag != super::SigFlag::SigAll,
+                        "verify_inputs_individually called with SIG_ALL proof - this is a bug"
+                    );
+                }
+
+                match secret.kind() {
+                    Kind::P2PK => {
+                        proof.verify_p2pk()?;
+                    }
+                    Kind::HTLC => {
+                        proof.verify_htlc()?;
+                    }
+                }
+            }
+            // If not a nut10 secret, skip verification (plain secret)
+        }
+        Ok(())
+    }
+
+    /// Verify P2PK SIG_ALL signatures
+    ///
+    /// Do NOT call this directly. This is called only from 'verify_full_sig_all_check',
+    /// which has already done many important SIG_ALL checks. This performs the final
+    /// signature verification for SIG_ALL+P2PK transactions.
+    fn verify_sig_all_p2pk(&self) -> Result<(), super::nut11::Error> {
+        // Get the first input, as it's the one with the signatures
+        let first_input = self
+            .inputs()
+            .first()
+            .ok_or(super::nut11::Error::SpendConditionsNotMet)?;
+        let first_secret = Secret::try_from(&first_input.secret)
+            .map_err(|_| super::nut11::Error::IncorrectSecretKind)?;
+
+        // Record current time for locktime evaluation
+        let current_time = crate::util::unix_time();
+
+        // Get the relevant public keys and required signature count based on locktime
+        let requirements = get_pubkeys_and_required_sigs(&first_secret, current_time)?;
+
+        debug_assert!(
+            !requirements.preimage_needed,
+            "P2PK should never require preimage"
+        );
+
+        // Handle "anyone can spend" case (locktime passed with no refund keys)
+        if requirements.required_sigs == 0 {
+            return Ok(());
+        }
+
+        // Construct the message that should be signed
+        let msg_to_sign = self.sig_all_msg_to_sign();
+
+        // Extract signatures from the first input's witness
+        let first_witness = first_input
+            .witness
+            .as_ref()
+            .ok_or(super::nut11::Error::SignaturesNotProvided)?;
+
+        let witness_sigs = first_witness
+            .signatures()
+            .ok_or(super::nut11::Error::SignaturesNotProvided)?;
+
+        // Convert witness strings to Signature objects
+        use std::str::FromStr;
+        let signatures: Vec<bitcoin::secp256k1::schnorr::Signature> = witness_sigs
+            .iter()
+            .map(|s| bitcoin::secp256k1::schnorr::Signature::from_str(s))
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(|_| super::nut11::Error::InvalidSignature)?;
+
+        // Verify signatures using the existing valid_signatures function
+        let valid_sig_count = super::nut11::valid_signatures(
+            msg_to_sign.as_bytes(),
+            &requirements.pubkeys,
+            &signatures,
+        )?;
+
+        // Check if we have enough valid signatures
+        if valid_sig_count < requirements.required_sigs {
+            return Err(super::nut11::Error::SpendConditionsNotMet);
+        }
+
+        Ok(())
+    }
+
+    /// Verify HTLC SIG_ALL signatures
+    ///
+    /// Do NOT call this directly. This is called only from 'verify_full_sig_all_check',
+    /// which has already done many important SIG_ALL checks. This performs the final
+    /// signature verification for SIG_ALL+HTLC transactions.
+    fn verify_sig_all_htlc(&self) -> Result<(), super::nut11::Error> {
+        // Get the first input, as it's the one with the signatures
+        let first_input = self
+            .inputs()
+            .first()
+            .ok_or(super::nut11::Error::SpendConditionsNotMet)?;
+        let first_secret = Secret::try_from(&first_input.secret)
+            .map_err(|_| super::nut11::Error::IncorrectSecretKind)?;
+
+        // Record current time for locktime evaluation
+        let current_time = crate::util::unix_time();
+
+        // Get the relevant public keys, required signature count, and whether preimage is needed
+        let requirements = get_pubkeys_and_required_sigs(&first_secret, current_time)?;
+
+        // If preimage is needed (before locktime), verify it
+        if requirements.preimage_needed {
+            // Extract HTLC witness
+            let htlc_witness = match first_input.witness.as_ref() {
+                Some(super::Witness::HTLCWitness(witness)) => witness,
+                _ => return Err(super::nut11::Error::SignaturesNotProvided),
+            };
+
+            // Verify the preimage matches the hash in the secret
+            verify_htlc_preimage(htlc_witness, &first_secret)
+                .map_err(|_| super::nut11::Error::SpendConditionsNotMet)?;
+        }
+
+        // Handle "anyone can spend" case (locktime passed with no refund keys)
+        if requirements.required_sigs == 0 {
+            return Ok(());
+        }
+
+        // Construct the message that should be signed
+        let msg_to_sign = self.sig_all_msg_to_sign();
+
+        // Extract signatures from the first input's witness
+        let first_witness = first_input
+            .witness
+            .as_ref()
+            .ok_or(super::nut11::Error::SignaturesNotProvided)?;
+
+        let witness_sigs = first_witness
+            .signatures()
+            .ok_or(super::nut11::Error::SignaturesNotProvided)?;
+
+        // Convert witness strings to Signature objects
+        use std::str::FromStr;
+        let signatures: Vec<bitcoin::secp256k1::schnorr::Signature> = witness_sigs
+            .iter()
+            .map(|s| bitcoin::secp256k1::schnorr::Signature::from_str(s))
+            .collect::<Result<Vec<_>, _>>()
+            .map_err(|_| super::nut11::Error::InvalidSignature)?;
+
+        // Verify signatures using the existing valid_signatures function
+        let valid_sig_count = super::nut11::valid_signatures(
+            msg_to_sign.as_bytes(),
+            &requirements.pubkeys,
+            &signatures,
+        )?;
+
+        // Check if we have enough valid signatures
+        if valid_sig_count < requirements.required_sigs {
+            return Err(super::nut11::Error::SpendConditionsNotMet);
+        }
+
+        Ok(())
+    }
+}
+
 impl Serialize for Secret {
     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
     where

File diff suppressed because it is too large
+ 556 - 433
crates/cashu/src/nuts/nut11/mod.rs


+ 153 - 0
crates/cashu/src/nuts/nut12.rs

@@ -265,4 +265,157 @@ mod tests {
 
         assert!(proof.verify_dleq(a).is_ok());
     }
+
+    /// Tests that verify_dleq correctly rejects verification with a wrong mint key.
+    ///
+    /// This test is critical for security - if the verification function doesn't properly
+    /// check the mint key, an attacker could forge proofs using any key.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_dleq with Ok(()) or remove
+    /// the verification logic.
+    #[test]
+    fn test_proof_dleq_wrong_mint_key() {
+        let proof = r#"{"amount": 1,"id": "00882760bfa2eb41","secret": "daf4dd00a2b68a0858a80450f52c8a7d2ccf87d375e43e216e0c571f089f63e9","C": "024369d2d22a80ecf78f3937da9d5f30c1b9f74f0c32684d583cca0fa6a61cdcfc","dleq": {"e": "b31e58ac6527f34975ffab13e70a48b6d2b0d35abc4b03f0151f09ee1a9763d4","s": "8fbae004c59e754d71df67e392b6ae4e29293113ddc2ec86592a0431d16306d8","r": "a6d13fcd7a18442e6076f5e1e7c887ad5de40a019824bdfa9fe740d302e8d861"}}"#;
+
+        let proof: Proof = serde_json::from_str(proof).unwrap();
+
+        // Wrong mint key - different from the one used to create the proof
+        let wrong_key: PublicKey = PublicKey::from_str(
+            "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5",
+        )
+        .unwrap();
+
+        // Verification should fail with wrong key
+        assert!(proof.verify_dleq(wrong_key).is_err());
+    }
+
+    /// Tests that verify_dleq correctly rejects proofs with missing DLEQ data.
+    ///
+    /// This test ensures that proofs without DLEQ data are rejected when DLEQ
+    /// verification is required.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_dleq with Ok(()) or
+    /// remove the None check.
+    #[test]
+    fn test_proof_dleq_missing() {
+        let proof = r#"{"amount": 1,"id": "00882760bfa2eb41","secret": "daf4dd00a2b68a0858a80450f52c8a7d2ccf87d375e43e216e0c571f089f63e9","C": "024369d2d22a80ecf78f3937da9d5f30c1b9f74f0c32684d583cca0fa6a61cdcfc"}"#;
+
+        let proof: Proof = serde_json::from_str(proof).unwrap();
+
+        let a: PublicKey = PublicKey::from_str(
+            "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+        )
+        .unwrap();
+
+        // Verification should fail when DLEQ is missing
+        let result = proof.verify_dleq(a);
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::MissingDleqProof));
+    }
+
+    /// Tests that BlindSignature::verify_dleq correctly rejects verification with wrong mint key.
+    ///
+    /// This test ensures that blind signature DLEQ verification properly validates the mint key.
+    ///
+    /// Mutant testing: Catches mutations that replace BlindSignature::verify_dleq with Ok(())
+    /// or remove the verification logic.
+    #[test]
+    fn test_blind_signature_dleq_wrong_key() {
+        let blinded_sig = r#"{"amount":8,"id":"00882760bfa2eb41","C_":"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2","dleq":{"e":"9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73d9","s":"9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73da"}}"#;
+
+        let blinded: BlindSignature = serde_json::from_str(blinded_sig).unwrap();
+
+        // Wrong secret key - different from the one used to create the signature
+        let wrong_key =
+            SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
+                .unwrap();
+
+        let blinded_secret = PublicKey::from_str(
+            "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+        )
+        .unwrap();
+
+        // Verification should fail with wrong key
+        assert!(blinded
+            .verify_dleq(wrong_key.public_key(), blinded_secret)
+            .is_err());
+    }
+
+    /// Tests that BlindSignature::verify_dleq correctly rejects verification with tampered DLEQ data.
+    ///
+    /// This test ensures that tampering with the 'e' or 's' values in the DLEQ proof
+    /// causes verification to fail.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_dleq with Ok(()) or
+    /// weaken the cryptographic checks.
+    #[test]
+    fn test_blind_signature_dleq_tampered() {
+        // Tampered DLEQ data - 'e' and 's' values have been modified to wrong (but valid) values
+        let tampered_sig = r#"{"amount":8,"id":"00882760bfa2eb41","C_":"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2","dleq":{"e":"0000000000000000000000000000000000000000000000000000000000000001","s":"0000000000000000000000000000000000000000000000000000000000000002"}}"#;
+
+        let blinded: BlindSignature = serde_json::from_str(tampered_sig).unwrap();
+
+        let secret_key =
+            SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
+                .unwrap();
+
+        let blinded_secret = PublicKey::from_str(
+            "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+        )
+        .unwrap();
+
+        // Verification should fail with tampered data
+        assert!(blinded
+            .verify_dleq(secret_key.public_key(), blinded_secret)
+            .is_err());
+    }
+
+    /// Tests that BlindSignature::add_dleq_proof properly generates DLEQ data.
+    ///
+    /// This test ensures that add_dleq_proof actually adds the DLEQ proof and doesn't
+    /// just return Ok(()) without doing anything.
+    ///
+    /// Mutant testing: Catches mutations that replace add_dleq_proof with Ok(())
+    /// without actually adding the proof.
+    #[test]
+    fn test_add_dleq_proof() {
+        use crate::nuts::nut02::Id;
+
+        let secret_key =
+            SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
+                .unwrap();
+
+        let blinded_message = PublicKey::from_str(
+            "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+        )
+        .unwrap();
+
+        let blinded_signature = PublicKey::from_str(
+            "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+        )
+        .unwrap();
+
+        let mut blind_sig = BlindSignature {
+            amount: Amount::from(1),
+            keyset_id: Id::from_str("00882760bfa2eb41").unwrap(),
+            c: blinded_signature,
+            dleq: None,
+        };
+
+        // Initially, DLEQ should be None
+        assert!(blind_sig.dleq.is_none());
+
+        // Add DLEQ proof
+        blind_sig
+            .add_dleq_proof(&blinded_message, &secret_key)
+            .unwrap();
+
+        // After adding, DLEQ should be Some
+        assert!(blind_sig.dleq.is_some());
+
+        // Verify the added DLEQ is valid
+        assert!(blind_sig
+            .verify_dleq(secret_key.public_key(), blinded_message)
+            .is_ok());
+    }
 }

+ 374 - 66
crates/cashu/src/nuts/nut14/mod.rs

@@ -4,8 +4,6 @@
 
 use std::str::FromStr;
 
-use bitcoin::hashes::sha256::Hash as Sha256Hash;
-use bitcoin::hashes::Hash;
 use bitcoin::secp256k1::schnorr::Signature;
 use serde::{Deserialize, Serialize};
 use thiserror::Error;
@@ -14,7 +12,6 @@ use super::nut00::Witness;
 use super::nut10::Secret;
 use super::nut11::valid_signatures;
 use super::{Conditions, Proof};
-use crate::ensure_cdk;
 use crate::util::{hex, unix_time};
 
 pub mod serde_htlc_witness;
@@ -46,6 +43,9 @@ pub enum Error {
     /// Witness Signatures not provided
     #[error("Witness did not provide signatures")]
     SignaturesNotProvided,
+    /// SIG_ALL not supported in this context
+    #[error("SIG_ALL proofs must be verified using a different method")]
+    SigAllNotSupportedHere,
     /// Secp256k1 error
     #[error(transparent)]
     Secp256k1(#[from] bitcoin::secp256k1::Error),
@@ -68,87 +68,109 @@ pub struct HTLCWitness {
     pub signatures: Option<Vec<String>>,
 }
 
+impl HTLCWitness {
+    /// Decode the preimage from hex and verify it's exactly 32 bytes
+    ///
+    /// Returns the 32-byte preimage data if valid, or an error if:
+    /// - The hex decoding fails
+    /// - The decoded data is not exactly 32 bytes
+    pub fn preimage_data(&self) -> Result<[u8; 32], Error> {
+        const REQUIRED_PREIMAGE_BYTES: usize = 32;
+
+        // Decode the 64-character hex string to bytes
+        let preimage_bytes = hex::decode(&self.preimage).map_err(|_| Error::InvalidHexPreimage)?;
+
+        // Verify the preimage is exactly 32 bytes
+        if preimage_bytes.len() != REQUIRED_PREIMAGE_BYTES {
+            return Err(Error::PreimageInvalidSize);
+        }
+
+        // Convert to fixed-size array
+        let mut array = [0u8; 32];
+        array.copy_from_slice(&preimage_bytes);
+        Ok(array)
+    }
+}
+
 impl Proof {
     /// Verify HTLC
     pub fn verify_htlc(&self) -> Result<(), Error> {
         let secret: Secret = self.secret.clone().try_into()?;
-        let conditions: Option<Conditions> = secret
+        let spending_conditions: Conditions = secret
             .secret_data()
             .tags()
-            .and_then(|c| c.clone().try_into().ok());
+            .cloned()
+            .unwrap_or_default()
+            .try_into()?;
 
-        let htlc_witness = match &self.witness {
-            Some(Witness::HTLCWitness(witness)) => witness,
-            _ => return Err(Error::IncorrectSecretKind),
-        };
+        if spending_conditions.sig_flag == super::SigFlag::SigAll {
+            return Err(Error::SigAllNotSupportedHere);
+        }
 
-        const REQUIRED_PREIMAGE_BYTES: usize = 32;
+        if secret.kind() != super::Kind::HTLC {
+            return Err(Error::IncorrectSecretKind);
+        }
 
-        let preimage_bytes =
-            hex::decode(&htlc_witness.preimage).map_err(|_| Error::InvalidHexPreimage)?;
+        // Get the appropriate spending conditions based on locktime
+        let now = unix_time();
+        let requirements =
+            super::nut10::get_pubkeys_and_required_sigs(&secret, now).map_err(Error::NUT11)?;
 
-        if preimage_bytes.len() != REQUIRED_PREIMAGE_BYTES {
-            return Err(Error::PreimageInvalidSize);
-        }
+        // While a Witness is usually needed in a P2PK or HTLC proof, it's not
+        // always needed. If we are past the locktime, and there are no refund
+        // keys, then the proofs are anyone-can-spend:
+        //     NUT-11: "If the tag locktime is the unix time and the mint's local
+        //              clock is greater than locktime, the Proof becomes spendable
+        //              by anyone, except if [there are no refund keys]"
+        // Therefore, this function should not extract any Witness unless it
+        // is needed to get a preimage or signatures.
+
+        // If preimage is needed (before locktime), verify it
+        if requirements.preimage_needed {
+            // Extract HTLC witness
+            let htlc_witness = match &self.witness {
+                Some(Witness::HTLCWitness(witness)) => witness,
+                _ => return Err(Error::IncorrectSecretKind),
+            };
 
-        if let Some(conditions) = conditions {
-            // Check locktime
-            if let Some(locktime) = conditions.locktime {
-                // If locktime is in passed and no refund keys provided anyone can spend
-                if locktime.lt(&unix_time()) && conditions.refund_keys.is_none() {
-                    return Ok(());
-                }
-
-                // If refund keys are provided verify p2pk signatures
-                if let (Some(refund_key), Some(signatures)) =
-                    (conditions.refund_keys, &self.witness)
-                {
-                    let signatures = signatures
-                        .signatures()
-                        .ok_or(Error::SignaturesNotProvided)?
-                        .iter()
-                        .map(|s| Signature::from_str(s))
-                        .collect::<Result<Vec<Signature>, _>>()?;
-
-                    // If secret includes refund keys check that there is a valid signature
-                    if valid_signatures(self.secret.as_bytes(), &refund_key, &signatures)?.ge(&1) {
-                        return Ok(());
-                    }
-                }
-            }
-            // If pubkeys are present check there is a valid signature
-            if let Some(pubkey) = conditions.pubkeys {
-                let req_sigs = conditions.num_sigs.unwrap_or(1);
-
-                let signatures = htlc_witness
-                    .signatures
-                    .as_ref()
-                    .ok_or(Error::SignaturesNotProvided)?;
-
-                let signatures = signatures
-                    .iter()
-                    .map(|s| Signature::from_str(s))
-                    .collect::<Result<Vec<Signature>, _>>()?;
-
-                let valid_sigs = valid_signatures(self.secret.as_bytes(), &pubkey, &signatures)?;
-                ensure_cdk!(valid_sigs >= req_sigs, Error::IncorrectSecretKind);
-            }
+            // Verify preimage using shared function
+            super::nut10::verify_htlc_preimage(htlc_witness, &secret)?;
         }
 
-        if secret.kind().ne(&super::Kind::HTLC) {
-            return Err(Error::IncorrectSecretKind);
+        if requirements.required_sigs == 0 {
+            return Ok(());
         }
 
-        let hash_lock =
-            Sha256Hash::from_str(secret.secret_data().data()).map_err(|_| Error::InvalidHash)?;
+        // if we get here, the preimage check (if it was needed) has been done
+        // and we know that at least one signature is required. So, we extract
+        // the witness.signatures and count them:
 
-        let preimage_hash = Sha256Hash::hash(&preimage_bytes);
+        // Extract witness signatures
+        let htlc_witness = match &self.witness {
+            Some(Witness::HTLCWitness(witness)) => witness,
+            _ => return Err(Error::IncorrectSecretKind),
+        };
+        let witness_signatures = htlc_witness
+            .signatures
+            .as_ref()
+            .ok_or(Error::SignaturesNotProvided)?;
 
-        if hash_lock.ne(&preimage_hash) {
-            return Err(Error::Preimage);
-        }
+        // Convert signatures from strings
+        let signatures: Vec<Signature> = witness_signatures
+            .iter()
+            .map(|s| Signature::from_str(s))
+            .collect::<Result<Vec<_>, _>>()?;
+
+        // Count valid signatures using relevant_pubkeys
+        let msg: &[u8] = self.secret.as_bytes();
+        let valid_sig_count = valid_signatures(msg, &requirements.pubkeys, &signatures)?;
 
-        Ok(())
+        // Check if we have enough valid signatures
+        if valid_sig_count >= requirements.required_sigs {
+            Ok(())
+        } else {
+            Err(Error::NUT11(super::nut11::Error::SpendConditionsNotMet))
+        }
     }
 
     /// Add Preimage
@@ -166,3 +188,289 @@ impl Proof {
         }))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use bitcoin::hashes::sha256::Hash as Sha256Hash;
+    use bitcoin::hashes::Hash;
+
+    use super::*;
+    use crate::nuts::nut00::Witness;
+    use crate::nuts::nut10::Kind;
+    use crate::nuts::Nut10Secret;
+    use crate::secret::Secret as SecretString;
+
+    /// Tests that verify_htlc correctly accepts a valid HTLC with the correct preimage.
+    ///
+    /// This test ensures that a properly formed HTLC proof with the correct preimage
+    /// passes verification.
+    ///
+    /// Mutant testing: Combined with negative tests, this catches mutations that
+    /// replace verify_htlc with Ok(()) since the negative tests will fail.
+    #[test]
+    fn test_verify_htlc_valid() {
+        // Create a valid HTLC secret with a known preimage (32 bytes)
+        let preimage_bytes = [42u8; 32]; // 32-byte preimage
+        let hash = Sha256Hash::hash(&preimage_bytes);
+        let hash_str = hash.to_string();
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::<Vec<Vec<String>>>);
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        let htlc_witness = HTLCWitness {
+            preimage: hex::encode(&preimage_bytes),
+            signatures: None,
+        };
+
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::HTLCWitness(htlc_witness)),
+            dleq: None,
+        };
+
+        // Valid HTLC should verify successfully
+        assert!(proof.verify_htlc().is_ok());
+    }
+
+    /// Tests that verify_htlc correctly rejects an HTLC with a wrong preimage.
+    ///
+    /// This test is critical for security - if the verification function doesn't properly
+    /// check the preimage against the hash, an attacker could spend HTLC-locked funds
+    /// without knowing the correct preimage.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or remove
+    /// the preimage verification logic.
+    #[test]
+    fn test_verify_htlc_wrong_preimage() {
+        // Create an HTLC secret with a specific hash (32 bytes)
+        let correct_preimage_bytes = [42u8; 32];
+        let hash = Sha256Hash::hash(&correct_preimage_bytes);
+        let hash_str = hash.to_string();
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::<Vec<Vec<String>>>);
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        // Use a different preimage in the witness
+        let wrong_preimage_bytes = [99u8; 32]; // Different from correct preimage
+        let htlc_witness = HTLCWitness {
+            preimage: hex::encode(&wrong_preimage_bytes),
+            signatures: None,
+        };
+
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::HTLCWitness(htlc_witness)),
+            dleq: None,
+        };
+
+        // Verification should fail with wrong preimage
+        let result = proof.verify_htlc();
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::Preimage));
+    }
+
+    /// Tests that verify_htlc correctly rejects an HTLC with an invalid hash format.
+    ///
+    /// This test ensures that the verification function properly validates that the
+    /// hash in the secret data is a valid SHA256 hash.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or
+    /// remove the hash validation logic.
+    #[test]
+    fn test_verify_htlc_invalid_hash() {
+        // Create an HTLC secret with an invalid hash (not a valid hex string)
+        let invalid_hash = "not_a_valid_hash";
+
+        let nut10_secret = Nut10Secret::new(
+            Kind::HTLC,
+            invalid_hash.to_string(),
+            None::<Vec<Vec<String>>>,
+        );
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        let preimage_bytes = [42u8; 32]; // Valid 32-byte preimage
+        let htlc_witness = HTLCWitness {
+            preimage: hex::encode(&preimage_bytes),
+            signatures: None,
+        };
+
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::HTLCWitness(htlc_witness)),
+            dleq: None,
+        };
+
+        // Verification should fail with invalid hash
+        let result = proof.verify_htlc();
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidHash));
+    }
+
+    /// Tests that verify_htlc correctly rejects an HTLC with the wrong witness type.
+    ///
+    /// This test ensures that the verification function checks that the witness is
+    /// of the correct type (HTLCWitness) and not some other witness type.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or
+    /// remove the witness type check.
+    #[test]
+    fn test_verify_htlc_wrong_witness_type() {
+        // Create an HTLC secret
+        let preimage = "test_preimage";
+        let hash = Sha256Hash::hash(preimage.as_bytes());
+        let hash_str = hash.to_string();
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::<Vec<Vec<String>>>);
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        // Create proof with wrong witness type (P2PKWitness instead of HTLCWitness)
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::P2PKWitness(super::super::nut11::P2PKWitness {
+                signatures: vec![],
+            })),
+            dleq: None,
+        };
+
+        // Verification should fail with wrong witness type
+        let result = proof.verify_htlc();
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::IncorrectSecretKind));
+    }
+
+    /// Tests that add_preimage correctly adds a preimage to the proof.
+    ///
+    /// This test ensures that add_preimage actually modifies the witness and doesn't
+    /// just return without doing anything.
+    ///
+    /// Mutant testing: Catches mutations that replace add_preimage with () without
+    /// actually adding the preimage.
+    #[test]
+    fn test_add_preimage() {
+        let preimage_bytes = [42u8; 32]; // 32-byte preimage
+        let hash = Sha256Hash::hash(&preimage_bytes);
+        let hash_str = hash.to_string();
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::<Vec<Vec<String>>>);
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        let mut proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: None,
+            dleq: None,
+        };
+
+        // Initially, witness should be None
+        assert!(proof.witness.is_none());
+
+        // Add preimage (hex-encoded)
+        let preimage_hex = hex::encode(&preimage_bytes);
+        proof.add_preimage(preimage_hex.clone());
+
+        // After adding, witness should be Some with HTLCWitness
+        assert!(proof.witness.is_some());
+        if let Some(Witness::HTLCWitness(witness)) = &proof.witness {
+            assert_eq!(witness.preimage, preimage_hex);
+        } else {
+            panic!("Expected HTLCWitness");
+        }
+
+        // The proof with added preimage should verify successfully
+        assert!(proof.verify_htlc().is_ok());
+    }
+
+    /// Tests that verify_htlc requires BOTH locktime expired AND no refund keys for "anyone can spend".
+    ///
+    /// This test catches the mutation that replaces `&&` with `||` at line 83.
+    /// The logic should be: (locktime expired AND no refund keys) → anyone can spend.
+    /// If mutated to OR, it would allow spending when locktime passed even if refund keys exist.
+    ///
+    /// Mutant testing: Catches mutations that replace `&&` with `||` in the locktime check.
+    #[test]
+    fn test_htlc_locktime_and_refund_keys_logic() {
+        use crate::nuts::nut01::PublicKey;
+        use crate::nuts::nut11::Conditions;
+
+        let preimage_bytes = [42u8; 32]; // 32-byte preimage
+        let hash = Sha256Hash::hash(&preimage_bytes);
+        let hash_str = hash.to_string();
+
+        // Test: Locktime has passed (locktime=1) but refund keys ARE present
+        // With correct logic (&&): Since refund_keys.is_none() is false, the "anyone can spend"
+        //                          path is NOT taken, so signature is required
+        // With mutation (||): Since locktime.lt(&unix_time()) is true, it WOULD take the
+        //                     "anyone can spend" path immediately - WRONG!
+        let refund_pubkey = PublicKey::from_hex(
+            "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+        )
+        .unwrap();
+
+        let conditions_with_refund = Conditions {
+            locktime: Some(1), // Locktime in past (current time is much larger)
+            pubkeys: None,
+            refund_keys: Some(vec![refund_pubkey]), // Refund key present
+            num_sigs: None,
+            sig_flag: crate::nuts::nut11::SigFlag::default(),
+            num_sigs_refund: None,
+        };
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, Some(conditions_with_refund));
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        let htlc_witness = HTLCWitness {
+            preimage: hex::encode(&preimage_bytes),
+            signatures: None, // No signature provided
+        };
+
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::HTLCWitness(htlc_witness)),
+            dleq: None,
+        };
+
+        // Should FAIL because even though locktime passed, refund keys are present
+        // so the "anyone can spend" shortcut shouldn't apply. A signature is required.
+        // With && this correctly fails. With || it would incorrectly pass.
+        let result = proof.verify_htlc();
+        assert!(
+            result.is_err(),
+            "Should fail when locktime passed but refund keys present without signature"
+        );
+    }
+}

+ 96 - 3
crates/cdk-axum/src/router_handlers.rs

@@ -1,6 +1,7 @@
 use anyhow::Result;
 use axum::extract::ws::WebSocketUpgrade;
-use axum::extract::{Json, Path, State};
+use axum::extract::{FromRequestParts, Json, Path, State};
+use axum::http::request::Parts;
 use axum::http::StatusCode;
 use axum::response::{IntoResponse, Response};
 use cdk::error::{ErrorCode, ErrorResponse};
@@ -22,6 +23,46 @@ use crate::auth::AuthHeader;
 use crate::ws::main_websocket;
 use crate::MintState;
 
+const PREFER_HEADER_KEY: &str = "Prefer";
+
+/// Header extractor for the Prefer header
+///
+/// This extractor checks for the `Prefer: respond-async` header
+/// to determine if the client wants asynchronous processing
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PreferHeader {
+    pub respond_async: bool,
+}
+
+impl<S> FromRequestParts<S> for PreferHeader
+where
+    S: Send + Sync,
+{
+    type Rejection = (StatusCode, String);
+
+    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
+        // Check for Prefer header
+        if let Some(prefer_value) = parts.headers.get(PREFER_HEADER_KEY) {
+            let value = prefer_value.to_str().map_err(|_| {
+                (
+                    StatusCode::BAD_REQUEST,
+                    "Invalid Prefer header value".to_string(),
+                )
+            })?;
+
+            // Check if it contains "respond-async"
+            let respond_async = value.to_lowercase().contains("respond-async");
+
+            return Ok(PreferHeader { respond_async });
+        }
+
+        // No Prefer header found - default to synchronous processing
+        Ok(PreferHeader {
+            respond_async: false,
+        })
+    }
+}
+
 /// Macro to add cache to endpoint
 #[macro_export]
 macro_rules! post_cache_wrapper {
@@ -61,9 +102,50 @@ macro_rules! post_cache_wrapper {
     };
 }
 
+/// Macro to add cache to endpoint with prefer header support (for async operations)
+#[macro_export]
+macro_rules! post_cache_wrapper_with_prefer {
+    ($handler:ident, $request_type:ty, $response_type:ty) => {
+        paste! {
+            /// Cache wrapper function for $handler with PreferHeader support:
+            /// Wrap $handler into a function that caches responses using the request as key
+            pub async fn [<cache_ $handler>](
+                #[cfg(feature = "auth")] auth: AuthHeader,
+                prefer: PreferHeader,
+                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
+                        #[cfg(feature = "auth")]
+                        return $handler(auth, prefer, state, payload).await;
+                        #[cfg(not(feature = "auth"))]
+                        return $handler(prefer, 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, prefer, state, payload).await?;
+                #[cfg(not(feature = "auth"))]
+                let response = $handler(prefer, state, payload).await?;
+                mint_state.cache.set(cache_key, &response.deref()).await;
+                Ok(response)
+            }
+        }
+    };
+}
+
 post_cache_wrapper!(post_swap, SwapRequest, SwapResponse);
 post_cache_wrapper!(post_mint_bolt11, MintRequest<QuoteId>, MintResponse);
-post_cache_wrapper!(
+post_cache_wrapper_with_prefer!(
     post_melt_bolt11,
     MeltRequest<QuoteId>,
     MeltQuoteBolt11Response<QuoteId>
@@ -382,6 +464,7 @@ pub(crate) async fn get_check_melt_bolt11_quote(
 #[instrument(skip_all)]
 pub(crate) async fn post_melt_bolt11(
     #[cfg(feature = "auth")] auth: AuthHeader,
+    prefer: PreferHeader,
     State(state): State<MintState>,
     Json(payload): Json<MeltRequest<QuoteId>>,
 ) -> Result<Json<MeltQuoteBolt11Response<QuoteId>>, Response> {
@@ -397,7 +480,17 @@ pub(crate) async fn post_melt_bolt11(
             .map_err(into_response)?;
     }
 
-    let res = state.mint.melt(&payload).await.map_err(into_response)?;
+    let res = if prefer.respond_async {
+        // Asynchronous processing - return immediately after setup
+        state
+            .mint
+            .melt_async(&payload)
+            .await
+            .map_err(into_response)?
+    } else {
+        // Synchronous processing - wait for completion
+        state.mint.melt(&payload).await.map_err(into_response)?
+    };
 
     Ok(Json(res))
 }

+ 37 - 0
crates/cdk-cln/README.md

@@ -17,4 +17,41 @@ Add this to your `Cargo.toml`:
 cdk-cln = "*"
 ```
 
+## Configuration for cdk-mintd
+
+### Config File
+
+```toml
+[ln]
+ln_backend = "cln"
+
+[cln]
+rpc_path = "/path/to/.lightning/bitcoin/lightning-rpc"
+bolt12 = true            # Optional, defaults to true
+fee_percent = 0.02       # Optional, defaults to 2%
+reserve_fee_min = 2      # Optional, defaults to 2 sats
+```
+
+### Environment Variables
+
+All configuration can be set via environment variables:
+
+| Variable | Description | Required |
+|----------|-------------|----------|
+| `CDK_MINTD_LN_BACKEND` | Set to `cln` | Yes |
+| `CDK_MINTD_CLN_RPC_PATH` | Path to CLN RPC socket | Yes |
+| `CDK_MINTD_CLN_BOLT12` | Enable BOLT12 support (default: `true`) | No |
+| `CDK_MINTD_CLN_FEE_PERCENT` | Fee percentage (default: `0.02`) | No |
+| `CDK_MINTD_CLN_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No |
+
+### Example
+
+```bash
+export CDK_MINTD_LN_BACKEND=cln
+export CDK_MINTD_CLN_RPC_PATH=/home/user/.lightning/bitcoin/lightning-rpc
+cdk-mintd
+```
+
+## License
+
 This project is licensed under the [MIT License](../../LICENSE).

+ 10 - 0
crates/cdk-common/src/error.rs

@@ -110,6 +110,9 @@ pub enum Error {
     /// Could not parse bolt12
     #[error("Could not parse bolt12")]
     Bolt12parse,
+    /// Could not parse invoice (bolt11 or bolt12)
+    #[error("Could not parse invoice")]
+    InvalidInvoice,
 
     /// BIP353 address parsing error
     #[error("Failed to parse BIP353 address: {0}")]
@@ -126,6 +129,13 @@ pub enum Error {
     #[error("No Lightning offer found in BIP353 payment instructions")]
     Bip353NoLightningOffer,
 
+    /// Lightning Address parsing error
+    #[error("Failed to parse Lightning address: {0}")]
+    LightningAddressParse(String),
+    /// Lightning Address request error
+    #[error("Failed to request invoice from Lightning address service: {0}")]
+    LightningAddressRequest(String),
+
     /// Internal Error - Send error
     #[error("Internal send error: {0}")]
     SendError(String),

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

@@ -8,6 +8,8 @@
 #![warn(missing_docs)]
 #![warn(rustdoc::bare_urls)]
 
+pub mod task;
+
 pub mod common;
 pub mod database;
 pub mod error;
@@ -24,6 +26,7 @@ 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};

+ 3 - 3
crates/cdk-common/src/mint.rs

@@ -49,7 +49,7 @@ impl FromStr for OperationKind {
             "swap" => Ok(OperationKind::Swap),
             "mint" => Ok(OperationKind::Mint),
             "melt" => Ok(OperationKind::Melt),
-            _ => Err(Error::Custom(format!("Invalid operation kind: {}", value))),
+            _ => Err(Error::Custom(format!("Invalid operation kind: {value}"))),
         }
     }
 }
@@ -80,7 +80,7 @@ impl FromStr for SwapSagaState {
         match value.as_str() {
             "setup_complete" => Ok(SwapSagaState::SetupComplete),
             "signed" => Ok(SwapSagaState::Signed),
-            _ => Err(Error::Custom(format!("Invalid swap saga state: {}", value))),
+            _ => Err(Error::Custom(format!("Invalid swap saga state: {value}"))),
         }
     }
 }
@@ -279,7 +279,7 @@ impl Operation {
             "mint" => Ok(Self::Mint(uuid)),
             "melt" => Ok(Self::Melt(uuid)),
             "swap" => Ok(Self::Swap(uuid)),
-            _ => Err(Error::Custom(format!("Invalid operation kind: {}", kind))),
+            _ => Err(Error::Custom(format!("Invalid operation kind: {kind}"))),
         }
     }
 }

+ 3 - 14
crates/cdk-common/src/pub_sub/pubsub.rs

@@ -10,6 +10,7 @@ use tokio::sync::mpsc;
 
 use super::subscriber::{ActiveSubscription, SubscriptionRequest};
 use super::{Error, Event, Spec, Subscriber};
+use crate::task::spawn;
 
 /// Default channel size for subscription buffering
 pub const DEFAULT_CHANNEL_SIZE: usize = 10_000;
@@ -92,13 +93,7 @@ where
         let topics = self.listeners_topics.clone();
         let event = event.into();
 
-        #[cfg(not(target_arch = "wasm32"))]
-        tokio::spawn(async move {
-            let _ = Self::publish_internal(event, &topics);
-        });
-
-        #[cfg(target_arch = "wasm32")]
-        wasm_bindgen_futures::spawn_local(async move {
+        spawn(async move {
             let _ = Self::publish_internal(event, &topics);
         });
     }
@@ -150,17 +145,11 @@ where
         let inner = self.inner.clone();
         let subscribed_to_for_spawn = subscribed_to.clone();
 
-        #[cfg(not(target_arch = "wasm32"))]
-        tokio::spawn(async move {
+        spawn(async move {
             // TODO: Ignore topics broadcasted from fetch_events _if_ any real time has been broadcasted already.
             inner.fetch_events(subscribed_to_for_spawn, sender).await;
         });
 
-        #[cfg(target_arch = "wasm32")]
-        wasm_bindgen_futures::spawn_local(async move {
-            inner.fetch_events(subscribed_to_for_spawn, sender).await;
-        });
-
         Ok(ActiveSubscription::new(
             subscription_internal_id,
             subscription_name,

+ 2 - 8
crates/cdk-common/src/pub_sub/remote_consumer.rs

@@ -12,6 +12,7 @@ use tokio::time::{sleep, Instant};
 
 use super::subscriber::{ActiveSubscription, SubscriptionRequest};
 use super::{Error, Event, Pubsub, Spec};
+use crate::task::spawn;
 
 const STREAM_CONNECTION_BACKOFF: Duration = Duration::from_millis(2_000);
 
@@ -21,9 +22,6 @@ const INTERNAL_POLL_SIZE: usize = 1_000;
 
 const POLL_SLEEP: Duration = Duration::from_millis(2_000);
 
-#[cfg(target_arch = "wasm32")]
-use wasm_bindgen_futures;
-
 struct UniqueSubscription<S>
 where
     S: Spec,
@@ -157,11 +155,7 @@ where
             still_running: true.into(),
         });
 
-        #[cfg(not(target_arch = "wasm32"))]
-        tokio::spawn(Self::stream(this.clone()));
-
-        #[cfg(target_arch = "wasm32")]
-        wasm_bindgen_futures::spawn_local(Self::stream(this.clone()));
+        spawn(Self::stream(this.clone()));
 
         this
     }

+ 25 - 0
crates/cdk-common/src/task.rs

@@ -0,0 +1,25 @@
+//! Thin wrapper for spawn and spawn_local for native and wasm.
+
+use std::future::Future;
+
+use tokio::task::JoinHandle;
+
+/// Spawns a new asynchronous task returning nothing
+#[cfg(not(target_arch = "wasm32"))]
+pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
+where
+    F: Future + Send + 'static,
+    F::Output: Send + 'static,
+{
+    tokio::spawn(future)
+}
+
+/// Spawns a new asynchronous task returning nothing
+#[cfg(target_arch = "wasm32")]
+pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
+where
+    F: Future + 'static,
+    F::Output: 'static,
+{
+    tokio::task::spawn_local(future)
+}

+ 96 - 0
crates/cdk-ffi/src/types/invoice.rs

@@ -0,0 +1,96 @@
+//! Invoice decoding FFI types and functions
+
+use serde::{Deserialize, Serialize};
+
+use crate::error::FfiError;
+
+/// Type of Lightning payment request
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Enum)]
+pub enum PaymentType {
+    /// Bolt11 invoice
+    Bolt11,
+    /// Bolt12 offer
+    Bolt12,
+}
+
+impl From<cdk::invoice::PaymentType> for PaymentType {
+    fn from(payment_type: cdk::invoice::PaymentType) -> Self {
+        match payment_type {
+            cdk::invoice::PaymentType::Bolt11 => Self::Bolt11,
+            cdk::invoice::PaymentType::Bolt12 => Self::Bolt12,
+        }
+    }
+}
+
+impl From<PaymentType> for cdk::invoice::PaymentType {
+    fn from(payment_type: PaymentType) -> Self {
+        match payment_type {
+            PaymentType::Bolt11 => Self::Bolt11,
+            PaymentType::Bolt12 => Self::Bolt12,
+        }
+    }
+}
+
+/// Decoded invoice or offer information
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
+pub struct DecodedInvoice {
+    /// Type of payment request (Bolt11 or Bolt12)
+    pub payment_type: PaymentType,
+    /// Amount in millisatoshis, if specified
+    pub amount_msat: Option<u64>,
+    /// Expiry timestamp (Unix timestamp), if specified
+    pub expiry: Option<u64>,
+    /// Description or offer description, if specified
+    pub description: Option<String>,
+}
+
+impl From<cdk::invoice::DecodedInvoice> for DecodedInvoice {
+    fn from(decoded: cdk::invoice::DecodedInvoice) -> Self {
+        Self {
+            payment_type: decoded.payment_type.into(),
+            amount_msat: decoded.amount_msat,
+            expiry: decoded.expiry,
+            description: decoded.description,
+        }
+    }
+}
+
+impl From<DecodedInvoice> for cdk::invoice::DecodedInvoice {
+    fn from(decoded: DecodedInvoice) -> Self {
+        Self {
+            payment_type: decoded.payment_type.into(),
+            amount_msat: decoded.amount_msat,
+            expiry: decoded.expiry,
+            description: decoded.description,
+        }
+    }
+}
+
+/// Decode a bolt11 invoice or bolt12 offer from a string
+///
+/// This function attempts to parse the input as a bolt11 invoice first,
+/// then as a bolt12 offer if bolt11 parsing fails.
+///
+/// # Arguments
+///
+/// * `invoice_str` - The invoice or offer string to decode
+///
+/// # Returns
+///
+/// * `Ok(DecodedInvoice)` - Successfully decoded invoice/offer information
+/// * `Err(FfiError)` - Failed to parse as either bolt11 or bolt12
+///
+/// # Example
+///
+/// ```kotlin
+/// val decoded = decodeInvoice("lnbc...")
+/// when (decoded.paymentType) {
+///     PaymentType.BOLT11 -> println("Bolt11 invoice")
+///     PaymentType.BOLT12 -> println("Bolt12 offer")
+/// }
+/// ```
+#[uniffi::export]
+pub fn decode_invoice(invoice_str: String) -> Result<DecodedInvoice, FfiError> {
+    let decoded = cdk::invoice::decode_invoice(&invoice_str)?;
+    Ok(decoded.into())
+}

+ 4 - 4
crates/cdk-ffi/src/types/mint.rs

@@ -739,12 +739,12 @@ mod tests {
         let ffi_nuts: Nuts = cdk_nuts.clone().into();
 
         // Verify NUT04 settings
-        assert_eq!(ffi_nuts.nut04.disabled, false);
+        assert!(!ffi_nuts.nut04.disabled);
         assert_eq!(ffi_nuts.nut04.methods.len(), 1);
         assert_eq!(ffi_nuts.nut04.methods[0].description, Some(true));
 
         // Verify NUT05 settings
-        assert_eq!(ffi_nuts.nut05.disabled, false);
+        assert!(!ffi_nuts.nut05.disabled);
         assert_eq!(ffi_nuts.nut05.methods.len(), 1);
         assert_eq!(ffi_nuts.nut05.methods[0].amountless, Some(true));
 
@@ -969,8 +969,8 @@ mod tests {
         let ffi_nuts: Nuts = cdk_nuts.into();
 
         // Should have collected multiple units
-        assert!(ffi_nuts.mint_units.len() >= 1);
-        assert!(ffi_nuts.melt_units.len() >= 1);
+        assert!(!ffi_nuts.mint_units.is_empty());
+        assert!(!ffi_nuts.melt_units.is_empty());
     }
 
     #[test]

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

@@ -5,6 +5,7 @@
 
 // Module declarations
 pub mod amount;
+pub mod invoice;
 pub mod keys;
 pub mod mint;
 pub mod proof;
@@ -15,6 +16,7 @@ pub mod wallet;
 
 // Re-export all types for convenient access
 pub use amount::*;
+pub use invoice::*;
 pub use keys::*;
 pub use mint::*;
 pub use proof::*;

+ 39 - 0
crates/cdk-ffi/src/wallet.rs

@@ -423,6 +423,45 @@ impl Wallet {
             .await?;
         Ok(quote.into())
     }
+
+    /// Get a quote for a Lightning address melt
+    ///
+    /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice
+    /// and then creates a melt quote for that invoice.
+    pub async fn melt_lightning_address_quote(
+        &self,
+        lightning_address: String,
+        amount_msat: Amount,
+    ) -> Result<MeltQuote, FfiError> {
+        let cdk_amount: cdk::Amount = amount_msat.into();
+        let quote = self
+            .inner
+            .melt_lightning_address_quote(&lightning_address, cdk_amount)
+            .await?;
+        Ok(quote.into())
+    }
+
+    /// Get a quote for a human-readable address melt
+    ///
+    /// This method accepts a human-readable address that could be either a BIP353 address
+    /// or a Lightning address. It intelligently determines which to try based on mint support:
+    ///
+    /// 1. If the mint supports Bolt12, it tries BIP353 first
+    /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails
+    /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address
+    /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly
+    pub async fn melt_human_readable(
+        &self,
+        address: String,
+        amount_msat: Amount,
+    ) -> Result<MeltQuote, FfiError> {
+        let cdk_amount: cdk::Amount = amount_msat.into();
+        let quote = self
+            .inner
+            .melt_human_readable_quote(&address, cdk_amount)
+            .await?;
+        Ok(quote.into())
+    }
 }
 
 /// Auth methods for Wallet

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

@@ -59,6 +59,20 @@ impl MintConnector for DirectMintConnection {
         panic!("Not implemented");
     }
 
+    async fn fetch_lnurl_pay_request(
+        &self,
+        _url: &str,
+    ) -> Result<cdk::wallet::LnurlPayResponse, Error> {
+        unimplemented!("Lightning address not supported in DirectMintConnection")
+    }
+
+    async fn fetch_lnurl_invoice(
+        &self,
+        _url: &str,
+    ) -> Result<cdk::wallet::LnurlPayInvoiceResponse, Error> {
+        unimplemented!("Lightning address not supported in DirectMintConnection")
+    }
+
     async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {
         Ok(self.mint.pubkeys().keysets)
     }

+ 2 - 2
crates/cdk-integration-tests/src/lib.rs

@@ -64,7 +64,7 @@ pub fn get_second_mint_url_from_env() -> String {
     }
 }
 
-// This is the ln wallet we use to send/receive ln payements as the wallet
+// This is the ln wallet we use to send/receive ln payments as the wallet
 pub async fn init_lnd_client(work_dir: &Path) -> LndClient {
     let lnd_dir = get_lnd_dir(work_dir, "one");
     let cert_file = lnd_dir.join("tls.cert");
@@ -133,7 +133,7 @@ pub async fn create_invoice_for_env(amount_sat: Option<u64>) -> Result<String> {
     }
 }
 
-// This is the ln wallet we use to send/receive ln payements as the wallet
+// This is the ln wallet we use to send/receive ln payments as the wallet
 async fn _get_lnd_client() -> LndClient {
     let temp_dir = get_work_dir();
 

+ 146 - 0
crates/cdk-integration-tests/tests/async_melt.rs

@@ -0,0 +1,146 @@
+//! Async Melt Integration Tests
+//!
+//! This file contains tests for async melt functionality using the Prefer: respond-async header.
+//!
+//! Test Scenarios:
+//! - Async melt returns PENDING state immediately
+//! - Synchronous melt still works correctly (backward compatibility)
+//! - Background task completion
+//! - Quote polling pattern
+
+use std::sync::Arc;
+
+use bip39::Mnemonic;
+use cdk::amount::SplitTarget;
+use cdk::nuts::{CurrencyUnit, MeltQuoteState};
+use cdk::wallet::Wallet;
+use cdk::StreamExt;
+use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
+use cdk_sqlite::wallet::memory;
+
+const MINT_URL: &str = "http://127.0.0.1:8086";
+
+/// Test: Async melt returns PENDING state immediately
+///
+/// This test validates that when calling melt with Prefer: respond-async header,
+/// the mint returns immediately with PENDING state.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_async_melt_returns_pending() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Step 1: Mint some tokens
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let _proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let balance = wallet.total_balance().await.unwrap();
+    assert_eq!(balance, 100.into());
+
+    // Step 2: Create a melt quote
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(
+        50_000, // 50 sats in millisats
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    // Step 3: Call melt (wallet handles proof selection internally)
+    let start_time = std::time::Instant::now();
+
+    // This should complete and return the final state
+    // TODO: Add Prefer: respond-async header support to wallet.melt()
+    let melt_response = wallet.melt(&melt_quote.id).await.unwrap();
+
+    let elapsed = start_time.elapsed();
+
+    // For now, this is synchronous, so it will take longer
+    println!("Melt took {:?}", elapsed);
+
+    // Step 4: Verify the melt completed successfully
+    assert_eq!(
+        melt_response.state,
+        MeltQuoteState::Paid,
+        "Melt should complete with PAID state"
+    );
+}
+
+/// Test: Synchronous melt still works correctly
+///
+/// This test ensures backward compatibility - melt without Prefer header
+/// still blocks until completion and returns the final state.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_sync_melt_completes_fully() {
+    let wallet = Wallet::new(
+        MINT_URL,
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    // Step 1: Mint some tokens
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
+
+    let _proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let balance = wallet.total_balance().await.unwrap();
+    assert_eq!(balance, 100.into());
+
+    // Step 2: Create a melt quote
+    let fake_invoice_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid,
+        check_payment_state: MeltQuoteState::Paid,
+        pay_err: false,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(
+        50_000, // 50 sats in millisats
+        serde_json::to_string(&fake_invoice_description).unwrap(),
+    );
+
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    // Step 3: Call synchronous melt
+    let melt_response = wallet.melt(&melt_quote.id).await.unwrap();
+
+    // Step 5: Verify response shows payment completed
+    assert_eq!(
+        melt_response.state,
+        MeltQuoteState::Paid,
+        "Synchronous melt should return PAID state"
+    );
+
+    // Step 6: Verify the quote is PAID in the mint
+    let quote_state = wallet.melt_quote_status(&melt_quote.id).await.unwrap();
+    assert_eq!(
+        quote_state.state,
+        MeltQuoteState::Paid,
+        "Quote should be PAID"
+    );
+}

+ 8 - 3
crates/cdk-integration-tests/tests/bolt12.rs

@@ -79,12 +79,17 @@ async fn test_regtest_bolt12_mint() {
         .await
         .unwrap();
     cln_client
-        .pay_bolt12_offer(None, mint_quote.request)
+        .pay_bolt12_offer(None, mint_quote.request.clone())
         .await
         .unwrap();
 
     let proofs = wallet
-        .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None)
+        .wait_and_mint_quote(
+            mint_quote.clone(),
+            SplitTarget::default(),
+            None,
+            tokio::time::Duration::from_secs(60),
+        )
         .await
         .unwrap();
 
@@ -385,7 +390,7 @@ async fn test_regtest_bolt12_mint_extra() -> Result<()> {
         Err(err) => match err {
             cdk::Error::TransactionUnbalanced(_, _, _) => (),
             err => {
-                bail!("Wrong mint error returned: {}", err.to_string());
+                bail!("Wrong mint error returned: {}", err);
             }
         },
         Ok(_) => {

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

@@ -861,7 +861,7 @@ async fn test_swap_state_transition_notifications() {
             cashu::NotificationPayload::ProofState(cashu::ProofState { y, state, .. }) => {
                 state_transitions
                     .entry(y.to_string())
-                    .or_insert_with(Vec::new)
+                    .or_default()
                     .push(state);
             }
             _ => panic!("Unexpected notification type"),

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

@@ -19,6 +19,51 @@ Add this to your `Cargo.toml`:
 cdk-lnbits = "*"
 ```
 
+## Configuration for cdk-mintd
+
+### Config File
+
+```toml
+[ln]
+ln_backend = "lnbits"
+
+[lnbits]
+admin_api_key = "your-admin-api-key"
+invoice_api_key = "your-invoice-api-key"
+lnbits_api = "https://your-lnbits-instance.com/api/v1"
+fee_percent = 0.02       # Optional, defaults to 2%
+reserve_fee_min = 2      # Optional, defaults to 2 sats
+```
+
+### Environment Variables
+
+All configuration can be set via environment variables:
+
+| Variable | Description | Required |
+|----------|-------------|----------|
+| `CDK_MINTD_LN_BACKEND` | Set to `lnbits` | Yes |
+| `CDK_MINTD_LNBITS_ADMIN_API_KEY` | LNBits admin API key | Yes |
+| `CDK_MINTD_LNBITS_INVOICE_API_KEY` | LNBits invoice API key | Yes |
+| `CDK_MINTD_LNBITS_LNBITS_API` | LNBits API URL | Yes |
+| `CDK_MINTD_LNBITS_FEE_PERCENT` | Fee percentage (default: `0.02`) | No |
+| `CDK_MINTD_LNBITS_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No |
+
+### Example
+
+```bash
+export CDK_MINTD_LN_BACKEND=lnbits
+export CDK_MINTD_LNBITS_ADMIN_API_KEY=your-admin-api-key
+export CDK_MINTD_LNBITS_INVOICE_API_KEY=your-invoice-api-key
+export CDK_MINTD_LNBITS_LNBITS_API=https://your-lnbits-instance.com/api/v1
+cdk-mintd
+```
+
+### Getting API Keys
+
+1. Log in to your LNBits instance
+2. Go to your wallet
+3. Click on "API Info" to find your admin and invoice API keys
+
 ## License
 
 This project is licensed under the [MIT License](../../LICENSE).

+ 38 - 0
crates/cdk-lnd/README.md

@@ -17,6 +17,44 @@ Add this to your `Cargo.toml`:
 cdk-lnd = "*"
 ```
 
+## Configuration for cdk-mintd
+
+### Config File
+
+```toml
+[ln]
+ln_backend = "lnd"
+
+[lnd]
+address = "https://localhost:10009"
+cert_file = "/path/to/.lnd/tls.cert"
+macaroon_file = "/path/to/.lnd/data/chain/bitcoin/mainnet/admin.macaroon"
+fee_percent = 0.02       # Optional, defaults to 2%
+reserve_fee_min = 2      # Optional, defaults to 2 sats
+```
+
+### Environment Variables
+
+All configuration can be set via environment variables:
+
+| Variable | Description | Required |
+|----------|-------------|----------|
+| `CDK_MINTD_LN_BACKEND` | Set to `lnd` | Yes |
+| `CDK_MINTD_LND_ADDRESS` | LND gRPC address (e.g., `https://localhost:10009`) | Yes |
+| `CDK_MINTD_LND_CERT_FILE` | Path to LND TLS certificate | Yes |
+| `CDK_MINTD_LND_MACAROON_FILE` | Path to LND macaroon file | Yes |
+| `CDK_MINTD_LND_FEE_PERCENT` | Fee percentage (default: `0.02`) | No |
+| `CDK_MINTD_LND_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No |
+
+### Example
+
+```bash
+export CDK_MINTD_LN_BACKEND=lnd
+export CDK_MINTD_LND_ADDRESS=https://127.0.0.1:10009
+export CDK_MINTD_LND_CERT_FILE=/home/user/.lnd/tls.cert
+export CDK_MINTD_LND_MACAROON_FILE=/home/user/.lnd/data/chain/bitcoin/mainnet/admin.macaroon
+cdk-mintd
+```
 
 ## Minimum Supported Rust Version (MSRV)
 

+ 13 - 5
crates/cdk-mintd/README.md

@@ -12,11 +12,19 @@ Cashu mint daemon implementation for the Cashu Development Kit (CDK). This binar
 ## Features
 
 - **Multiple Database Backends**: SQLite, PostgreSQL, and ReDB
-- **Lightning Network Integration**: Support for CLN, LND, LNbits, LDK Node, and test backends  
+- **Lightning Network Integration**: Support for CLN, LND, LNbits, LDK Node, and test backends
 - **Authentication**: Optional user authentication with OpenID Connect
 - **Management RPC**: gRPC interface for mint management
 - **Docker Support**: Ready-to-use Docker configurations
 
+## Lightning Backend Documentation
+
+For detailed configuration of each Lightning backend, see:
+
+- **[LND](../cdk-lnd/README.md)** - Lightning Network Daemon
+- **[CLN](../cdk-cln/README.md)** - Core Lightning
+- **[LNbits](../cdk-lnbits/README.md)** - LNbits API integration
+
 ## Installation
 
 ### Option 1: Download Pre-built Binary
@@ -100,8 +108,8 @@ ln_backend = "cln"
 
 [cln]
 rpc_path = "/home/bitcoin/.lightning/bitcoin/lightning-rpc"
-fee_percent = 0.01
-reserve_fee_min = 10
+# fee_percent = 0.02      # Optional, defaults to 2%
+# reserve_fee_min = 2     # Optional, defaults to 2 sats
 ```
 
 ### With LND Lightning Backend
@@ -113,8 +121,8 @@ ln_backend = "lnd"
 address = "https://localhost:10009"
 macaroon_file = "/home/bitcoin/.lnd/data/chain/bitcoin/mainnet/admin.macaroon"
 cert_file = "/home/bitcoin/.lnd/tls.cert"
-fee_percent = 0.01
-reserve_fee_min = 10
+# fee_percent = 0.02      # Optional, defaults to 2%
+# reserve_fee_min = 2     # Optional, defaults to 2 sats
 ```
 
 ### With PostgreSQL Database

+ 14 - 14
crates/cdk-mintd/example.config.toml

@@ -94,30 +94,30 @@ ln_backend = "fakewallet"
 # min_melt=1
 # max_melt=500000
 
-[cln]
-rpc_path = ""
-fee_percent = 0.04
-reserve_fee_min = 4
+# [cln]
+# rpc_path = "/path/to/.lightning/bitcoin/lightning-rpc"
+# bolt12 = true              # Optional, defaults to true
+# fee_percent = 0.02         # Optional, defaults to 2%
+# reserve_fee_min = 2        # Optional, defaults to 2 sats
 
 # [lnbits]
 # admin_api_key = ""
 # invoice_api_key = ""
 # lnbits_api = ""
-# fee_percent = 0.04
-# # Fee in sats
-# reserve_fee_min = 4
+# fee_percent = 0.02         # Optional, defaults to 2%
+# reserve_fee_min = 2        # Optional, defaults to 2 sats
 # Note: Only LNBits v1 API is supported (websocket-based)
 
 # [lnd]
-# address = "https://domain:port"
-# macaroon_file = ""
-# cert_file = ""
-# fee_percent=0.04
-# reserve_fee_min=4
+# address = "https://localhost:10009"
+# cert_file = "/path/to/.lnd/tls.cert"
+# macaroon_file = "/path/to/.lnd/data/chain/bitcoin/mainnet/admin.macaroon"
+# fee_percent = 0.02         # Optional, defaults to 2%
+# reserve_fee_min = 2        # Optional, defaults to 2 sats
 
 # [ldk_node]
-# fee_percent = 0.04
-# reserve_fee_min = 4
+# fee_percent = 0.02         # Optional, defaults to 2%
+# reserve_fee_min = 2        # Optional, defaults to 2 sats
 # bitcoin_network = "signet"  # mainnet, testnet, signet, regtest
 # chain_source_type = "esplora"  # esplora, bitcoinrpc  
 # 

+ 456 - 45
crates/cdk-mintd/src/config.rs

@@ -186,35 +186,84 @@ impl Default for Ln {
 }
 
 #[cfg(feature = "lnbits")]
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct LNbits {
     pub admin_api_key: String,
     pub invoice_api_key: String,
     pub lnbits_api: String,
+    #[serde(default = "default_fee_percent")]
     pub fee_percent: f32,
+    #[serde(default = "default_reserve_fee_min")]
     pub reserve_fee_min: Amount,
 }
 
+#[cfg(feature = "lnbits")]
+impl Default for LNbits {
+    fn default() -> Self {
+        Self {
+            admin_api_key: String::new(),
+            invoice_api_key: String::new(),
+            lnbits_api: String::new(),
+            fee_percent: 0.02,
+            reserve_fee_min: 2.into(),
+        }
+    }
+}
+
 #[cfg(feature = "cln")]
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Cln {
     pub rpc_path: PathBuf,
-    #[serde(default)]
+    #[serde(default = "default_cln_bolt12")]
     pub bolt12: bool,
+    #[serde(default = "default_fee_percent")]
     pub fee_percent: f32,
+    #[serde(default = "default_reserve_fee_min")]
     pub reserve_fee_min: Amount,
 }
 
+#[cfg(feature = "cln")]
+impl Default for Cln {
+    fn default() -> Self {
+        Self {
+            rpc_path: PathBuf::new(),
+            bolt12: true,
+            fee_percent: 0.02,
+            reserve_fee_min: 2.into(),
+        }
+    }
+}
+
+#[cfg(feature = "cln")]
+fn default_cln_bolt12() -> bool {
+    true
+}
+
 #[cfg(feature = "lnd")]
-#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+#[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct Lnd {
     pub address: String,
     pub cert_file: PathBuf,
     pub macaroon_file: PathBuf,
+    #[serde(default = "default_fee_percent")]
     pub fee_percent: f32,
+    #[serde(default = "default_reserve_fee_min")]
     pub reserve_fee_min: Amount,
 }
 
+#[cfg(feature = "lnd")]
+impl Default for Lnd {
+    fn default() -> Self {
+        Self {
+            address: String::new(),
+            cert_file: PathBuf::new(),
+            macaroon_file: PathBuf::new(),
+            fee_percent: 0.02,
+            reserve_fee_min: 2.into(),
+        }
+    }
+}
+
 #[cfg(feature = "ldk-node")]
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct LdkNode {
@@ -323,6 +372,15 @@ impl Default for FakeWallet {
 }
 
 // Helper functions to provide default values
+// Common fee defaults for all backends
+fn default_fee_percent() -> f32 {
+    0.02
+}
+
+fn default_reserve_fee_min() -> Amount {
+    2.into()
+}
+
 #[cfg(feature = "fakewallet")]
 fn default_min_delay_time() -> u64 {
     1
@@ -333,14 +391,37 @@ fn default_max_delay_time() -> u64 {
     3
 }
 
-#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
 pub struct GrpcProcessor {
+    #[serde(default)]
     pub supported_units: Vec<CurrencyUnit>,
+    #[serde(default = "default_grpc_addr")]
     pub addr: String,
+    #[serde(default = "default_grpc_port")]
     pub port: u16,
+    #[serde(default)]
     pub tls_dir: Option<PathBuf>,
 }
 
+impl Default for GrpcProcessor {
+    fn default() -> Self {
+        Self {
+            supported_units: Vec::new(),
+            addr: default_grpc_addr(),
+            port: default_grpc_port(),
+            tls_dir: None,
+        }
+    }
+}
+
+fn default_grpc_addr() -> String {
+    "127.0.0.1".to_string()
+}
+
+fn default_grpc_port() -> u16 {
+    50051
+}
+
 #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
 #[serde(rename_all = "lowercase")]
 pub enum DatabaseEngine {
@@ -579,46 +660,6 @@ impl Settings {
             .build()?;
         let settings: Settings = config.try_deserialize()?;
 
-        match settings.ln.ln_backend {
-            LnBackend::None => panic!("Ln backend must be set"),
-            #[cfg(feature = "cln")]
-            LnBackend::Cln => assert!(
-                settings.cln.is_some(),
-                "CLN backend requires a valid config."
-            ),
-            #[cfg(feature = "lnbits")]
-            LnBackend::LNbits => assert!(
-                settings.lnbits.is_some(),
-                "LNbits backend requires a valid config"
-            ),
-            #[cfg(feature = "lnd")]
-            LnBackend::Lnd => {
-                assert!(
-                    settings.lnd.is_some(),
-                    "LND backend requires a valid config."
-                )
-            }
-            #[cfg(feature = "ldk-node")]
-            LnBackend::LdkNode => {
-                assert!(
-                    settings.ldk_node.is_some(),
-                    "LDK Node backend requires a valid config."
-                )
-            }
-            #[cfg(feature = "fakewallet")]
-            LnBackend::FakeWallet => assert!(
-                settings.fake_wallet.is_some(),
-                "FakeWallet backend requires a valid config."
-            ),
-            #[cfg(feature = "grpc-processor")]
-            LnBackend::GrpcProcessor => {
-                assert!(
-                    settings.grpc_processor.is_some(),
-                    "GRPC backend requires a valid config."
-                )
-            }
-        }
-
         Ok(settings)
     }
 }
@@ -692,4 +733,374 @@ mod tests {
         assert!(!debug_output.contains("特殊字符 !@#$%^&*()"));
         assert!(debug_output.contains("<hashed: "));
     }
+
+    /// Test that configuration can be loaded purely from environment variables
+    /// without requiring a config.toml file with backend sections.
+    ///
+    /// This test runs sequentially for all enabled backends to avoid env var interference.
+    #[test]
+    fn test_env_var_only_config_all_backends() {
+        // Run each backend test sequentially
+        #[cfg(feature = "lnd")]
+        test_lnd_env_config();
+
+        #[cfg(feature = "cln")]
+        test_cln_env_config();
+
+        #[cfg(feature = "lnbits")]
+        test_lnbits_env_config();
+
+        #[cfg(feature = "fakewallet")]
+        test_fakewallet_env_config();
+
+        #[cfg(feature = "grpc-processor")]
+        test_grpc_processor_env_config();
+
+        #[cfg(feature = "ldk-node")]
+        test_ldk_node_env_config();
+    }
+
+    #[cfg(feature = "lnd")]
+    fn test_lnd_env_config() {
+        use std::path::PathBuf;
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [lnd] section
+        let config_content = r#"
+[ln]
+backend = "lnd"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for LND configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "lnd");
+        env::set_var(crate::env_vars::ENV_LND_ADDRESS, "https://localhost:10009");
+        env::set_var(crate::env_vars::ENV_LND_CERT_FILE, "/tmp/test_tls.cert");
+        env::set_var(
+            crate::env_vars::ENV_LND_MACAROON_FILE,
+            "/tmp/test_admin.macaroon",
+        );
+        env::set_var(crate::env_vars::ENV_LND_FEE_PERCENT, "0.01");
+        env::set_var(crate::env_vars::ENV_LND_RESERVE_FEE_MIN, "4");
+
+        // Load settings and apply environment variables (same as production code)
+        let mut settings = Settings::new(Some(&config_path));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.lnd.is_some());
+        let lnd_config = settings.lnd.as_ref().unwrap();
+        assert_eq!(lnd_config.address, "https://localhost:10009");
+        assert_eq!(lnd_config.cert_file, PathBuf::from("/tmp/test_tls.cert"));
+        assert_eq!(
+            lnd_config.macaroon_file,
+            PathBuf::from("/tmp/test_admin.macaroon")
+        );
+        assert_eq!(lnd_config.fee_percent, 0.01);
+        let reserve_fee_u64: u64 = lnd_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 4);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_LND_ADDRESS);
+        env::remove_var(crate::env_vars::ENV_LND_CERT_FILE);
+        env::remove_var(crate::env_vars::ENV_LND_MACAROON_FILE);
+        env::remove_var(crate::env_vars::ENV_LND_FEE_PERCENT);
+        env::remove_var(crate::env_vars::ENV_LND_RESERVE_FEE_MIN);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "cln")]
+    fn test_cln_env_config() {
+        use std::path::PathBuf;
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_cln");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [cln] section
+        let config_content = r#"
+[ln]
+backend = "cln"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for CLN configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "cln");
+        env::set_var(crate::env_vars::ENV_CLN_RPC_PATH, "/tmp/lightning-rpc");
+        env::set_var(crate::env_vars::ENV_CLN_BOLT12, "false");
+        env::set_var(crate::env_vars::ENV_CLN_FEE_PERCENT, "0.01");
+        env::set_var(crate::env_vars::ENV_CLN_RESERVE_FEE_MIN, "4");
+
+        // Load settings and apply environment variables (same as production code)
+        let mut settings = Settings::new(Some(&config_path));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.cln.is_some());
+        let cln_config = settings.cln.as_ref().unwrap();
+        assert_eq!(cln_config.rpc_path, PathBuf::from("/tmp/lightning-rpc"));
+        assert_eq!(cln_config.bolt12, false);
+        assert_eq!(cln_config.fee_percent, 0.01);
+        let reserve_fee_u64: u64 = cln_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 4);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_CLN_RPC_PATH);
+        env::remove_var(crate::env_vars::ENV_CLN_BOLT12);
+        env::remove_var(crate::env_vars::ENV_CLN_FEE_PERCENT);
+        env::remove_var(crate::env_vars::ENV_CLN_RESERVE_FEE_MIN);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "lnbits")]
+    fn test_lnbits_env_config() {
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_lnbits");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [lnbits] section
+        let config_content = r#"
+[ln]
+backend = "lnbits"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for LNbits configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "lnbits");
+        env::set_var(crate::env_vars::ENV_LNBITS_ADMIN_API_KEY, "test_admin_key");
+        env::set_var(
+            crate::env_vars::ENV_LNBITS_INVOICE_API_KEY,
+            "test_invoice_key",
+        );
+        env::set_var(
+            crate::env_vars::ENV_LNBITS_API,
+            "https://lnbits.example.com",
+        );
+        env::set_var(crate::env_vars::ENV_LNBITS_FEE_PERCENT, "0.02");
+        env::set_var(crate::env_vars::ENV_LNBITS_RESERVE_FEE_MIN, "5");
+
+        // Load settings and apply environment variables (same as production code)
+        let mut settings = Settings::new(Some(&config_path));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.lnbits.is_some());
+        let lnbits_config = settings.lnbits.as_ref().unwrap();
+        assert_eq!(lnbits_config.admin_api_key, "test_admin_key");
+        assert_eq!(lnbits_config.invoice_api_key, "test_invoice_key");
+        assert_eq!(lnbits_config.lnbits_api, "https://lnbits.example.com");
+        assert_eq!(lnbits_config.fee_percent, 0.02);
+        let reserve_fee_u64: u64 = lnbits_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 5);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_LNBITS_ADMIN_API_KEY);
+        env::remove_var(crate::env_vars::ENV_LNBITS_INVOICE_API_KEY);
+        env::remove_var(crate::env_vars::ENV_LNBITS_API);
+        env::remove_var(crate::env_vars::ENV_LNBITS_FEE_PERCENT);
+        env::remove_var(crate::env_vars::ENV_LNBITS_RESERVE_FEE_MIN);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "fakewallet")]
+    fn test_fakewallet_env_config() {
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_fakewallet");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [fake_wallet] section
+        let config_content = r#"
+[ln]
+backend = "fakewallet"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for FakeWallet configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "fakewallet");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_SUPPORTED_UNITS, "sat,msat");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_FEE_PERCENT, "0.0");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_RESERVE_FEE_MIN, "0");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_MIN_DELAY, "0");
+        env::set_var(crate::env_vars::ENV_FAKE_WALLET_MAX_DELAY, "5");
+
+        // Load settings and apply environment variables (same as production code)
+        let mut settings = Settings::new(Some(&config_path));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.fake_wallet.is_some());
+        let fakewallet_config = settings.fake_wallet.as_ref().unwrap();
+        assert_eq!(fakewallet_config.fee_percent, 0.0);
+        let reserve_fee_u64: u64 = fakewallet_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 0);
+        assert_eq!(fakewallet_config.min_delay_time, 0);
+        assert_eq!(fakewallet_config.max_delay_time, 5);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_SUPPORTED_UNITS);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_FEE_PERCENT);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_RESERVE_FEE_MIN);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_MIN_DELAY);
+        env::remove_var(crate::env_vars::ENV_FAKE_WALLET_MAX_DELAY);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "grpc-processor")]
+    fn test_grpc_processor_env_config() {
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_grpc");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [grpc_processor] section
+        let config_content = r#"
+[ln]
+backend = "grpcprocessor"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for GRPC Processor configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "grpcprocessor");
+        env::set_var(
+            crate::env_vars::ENV_GRPC_PROCESSOR_SUPPORTED_UNITS,
+            "sat,msat",
+        );
+        env::set_var(crate::env_vars::ENV_GRPC_PROCESSOR_ADDRESS, "localhost");
+        env::set_var(crate::env_vars::ENV_GRPC_PROCESSOR_PORT, "50051");
+
+        // Load settings and apply environment variables (same as production code)
+        let mut settings = Settings::new(Some(&config_path));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.grpc_processor.is_some());
+        let grpc_config = settings.grpc_processor.as_ref().unwrap();
+        assert_eq!(grpc_config.addr, "localhost");
+        assert_eq!(grpc_config.port, 50051);
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::ENV_GRPC_PROCESSOR_SUPPORTED_UNITS);
+        env::remove_var(crate::env_vars::ENV_GRPC_PROCESSOR_ADDRESS);
+        env::remove_var(crate::env_vars::ENV_GRPC_PROCESSOR_PORT);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
+
+    #[cfg(feature = "ldk-node")]
+    fn test_ldk_node_env_config() {
+        use std::{env, fs};
+
+        // Create a temporary directory for config file
+        let temp_dir = env::temp_dir().join("cdk_test_env_vars_ldk");
+        fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
+        let config_path = temp_dir.join("config.toml");
+
+        // Create a minimal config.toml with backend set but NO [ldk_node] section
+        let config_content = r#"
+[ln]
+backend = "ldknode"
+min_mint = 1
+max_mint = 500000
+min_melt = 1
+max_melt = 500000
+"#;
+        fs::write(&config_path, config_content).expect("Failed to write config file");
+
+        // Set environment variables for LDK Node configuration
+        env::set_var(crate::env_vars::ENV_LN_BACKEND, "ldknode");
+        env::set_var(crate::env_vars::LDK_NODE_FEE_PERCENT_ENV_VAR, "0.01");
+        env::set_var(crate::env_vars::LDK_NODE_RESERVE_FEE_MIN_ENV_VAR, "4");
+        env::set_var(crate::env_vars::LDK_NODE_BITCOIN_NETWORK_ENV_VAR, "regtest");
+        env::set_var(
+            crate::env_vars::LDK_NODE_CHAIN_SOURCE_TYPE_ENV_VAR,
+            "esplora",
+        );
+        env::set_var(
+            crate::env_vars::LDK_NODE_ESPLORA_URL_ENV_VAR,
+            "http://localhost:3000",
+        );
+        env::set_var(
+            crate::env_vars::LDK_NODE_STORAGE_DIR_PATH_ENV_VAR,
+            "/tmp/ldk",
+        );
+
+        // Load settings and apply environment variables (same as production code)
+        let mut settings = Settings::new(Some(&config_path));
+        settings.from_env().expect("Failed to apply env vars");
+
+        // Verify that settings were populated from env vars
+        assert!(settings.ldk_node.is_some());
+        let ldk_config = settings.ldk_node.as_ref().unwrap();
+        assert_eq!(ldk_config.fee_percent, 0.01);
+        let reserve_fee_u64: u64 = ldk_config.reserve_fee_min.into();
+        assert_eq!(reserve_fee_u64, 4);
+        assert_eq!(ldk_config.bitcoin_network, Some("regtest".to_string()));
+        assert_eq!(ldk_config.chain_source_type, Some("esplora".to_string()));
+        assert_eq!(
+            ldk_config.esplora_url,
+            Some("http://localhost:3000".to_string())
+        );
+        assert_eq!(ldk_config.storage_dir_path, Some("/tmp/ldk".to_string()));
+
+        // Cleanup env vars
+        env::remove_var(crate::env_vars::ENV_LN_BACKEND);
+        env::remove_var(crate::env_vars::LDK_NODE_FEE_PERCENT_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_RESERVE_FEE_MIN_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_BITCOIN_NETWORK_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_CHAIN_SOURCE_TYPE_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_ESPLORA_URL_ENV_VAR);
+        env::remove_var(crate::env_vars::LDK_NODE_STORAGE_DIR_PATH_ENV_VAR);
+
+        // Cleanup test file
+        let _ = fs::remove_dir_all(&temp_dir);
+    }
 }

+ 10 - 5
crates/cdk-mintd/src/lib.rs

@@ -886,12 +886,17 @@ async fn start_services_with_shutdown(
 
                 let tls_dir = rpc_settings.tls_dir_path.unwrap_or(work_dir.join("tls"));
 
-                if !tls_dir.exists() {
-                    tracing::error!("TLS directory does not exist: {}", tls_dir.display());
-                    bail!("Cannot start RPC server: TLS directory does not exist");
-                }
+                let tls_dir = if tls_dir.exists() {
+                    Some(tls_dir)
+                } else {
+                    tracing::warn!(
+                        "TLS directory does not exist: {}. Starting RPC server in INSECURE mode without TLS encryption",
+                        tls_dir.display()
+                    );
+                    None
+                };
 
-                mint_rpc.start(Some(tls_dir)).await?;
+                mint_rpc.start(tls_dir).await?;
 
                 rpc_server = Some(mint_rpc);
 

+ 35 - 0
crates/cdk-mintd/src/setup.rs

@@ -7,6 +7,8 @@ use std::sync::Arc;
 
 #[cfg(feature = "cln")]
 use anyhow::anyhow;
+#[cfg(any(feature = "lnbits", feature = "lnd"))]
+use anyhow::bail;
 use async_trait::async_trait;
 #[cfg(feature = "fakewallet")]
 use bip39::rand::{thread_rng, Rng};
@@ -49,6 +51,13 @@ impl LnBackendSetup for config::Cln {
         _work_dir: &Path,
         kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_cln::Cln> {
+        // Validate required connection field
+        if self.rpc_path.as_os_str().is_empty() {
+            return Err(anyhow!(
+                "CLN rpc_path must be set via config or CDK_MINTD_CLN_RPC_PATH env var"
+            ));
+        }
+
         let cln_socket = expand_path(
             self.rpc_path
                 .to_str()
@@ -83,6 +92,19 @@ impl LnBackendSetup for config::LNbits {
         _work_dir: &Path,
         _kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_lnbits::LNbits> {
+        // Validate required connection fields
+        if self.admin_api_key.is_empty() {
+            bail!("LNbits admin_api_key must be set via config or CDK_MINTD_LNBITS_ADMIN_API_KEY env var");
+        }
+        if self.invoice_api_key.is_empty() {
+            bail!("LNbits invoice_api_key must be set via config or CDK_MINTD_LNBITS_INVOICE_API_KEY env var");
+        }
+        if self.lnbits_api.is_empty() {
+            bail!(
+                "LNbits lnbits_api must be set via config or CDK_MINTD_LNBITS_LNBITS_API env var"
+            );
+        }
+
         let admin_api_key = &self.admin_api_key;
         let invoice_api_key = &self.invoice_api_key;
 
@@ -117,6 +139,19 @@ impl LnBackendSetup for config::Lnd {
         _work_dir: &Path,
         kv_store: Option<Arc<dyn MintKVStore<Err = cdk::cdk_database::Error> + Send + Sync>>,
     ) -> anyhow::Result<cdk_lnd::Lnd> {
+        // Validate required connection fields
+        if self.address.is_empty() {
+            bail!("LND address must be set via config or CDK_MINTD_LND_ADDRESS env var");
+        }
+        if self.cert_file.as_os_str().is_empty() {
+            bail!("LND cert_file must be set via config or CDK_MINTD_LND_CERT_FILE env var");
+        }
+        if self.macaroon_file.as_os_str().is_empty() {
+            bail!(
+                "LND macaroon_file must be set via config or CDK_MINTD_LND_MACAROON_FILE env var"
+            );
+        }
+
         let address = &self.address;
         let cert_file = &self.cert_file;
         let macaroon_file = &self.macaroon_file;

+ 2 - 2
crates/cdk-redb/src/wallet/mod.rs

@@ -63,7 +63,7 @@ impl WalletRedbDatabase {
                 if !parent.exists() {
                     return Err(Error::Io(std::io::Error::new(
                         std::io::ErrorKind::NotFound,
-                        format!("Parent directory does not exist: {:?}", parent),
+                        format!("Parent directory does not exist: {parent:?}"),
                     )));
                 }
             }
@@ -171,7 +171,7 @@ impl WalletRedbDatabase {
             if !parent.exists() {
                 return Err(Error::Io(std::io::Error::new(
                     std::io::ErrorKind::NotFound,
-                    format!("Parent directory does not exist: {:?}", parent),
+                    format!("Parent directory does not exist: {parent:?}"),
                 )));
             }
         }

+ 1 - 1
crates/cdk-signatory/src/signatory.rs

@@ -148,7 +148,7 @@ pub trait Signatory {
         blinded_messages: Vec<BlindedMessage>,
     ) -> Result<Vec<BlindSignature>, Error>;
 
-    /// Verify [`Proof`] meets conditions and is signed
+    /// Verify [`Proof`] meets conditions and is signed by the mint (ignores P2PK/HTLC signatures"
     async fn verify_proofs(&self, proofs: Vec<Proof>) -> Result<(), Error>;
 
     /// Retrieve the list of all mint keysets

+ 2 - 3
crates/cdk-sql-common/build.rs

@@ -23,7 +23,7 @@ fn main() {
             .unwrap_or("default")
             .replace("/", "_")
             .replace("\\", "_");
-        let dest_path = out_dir.join(format!("migrations_{}.rs", migration_name));
+        let dest_path = out_dir.join(format!("migrations_{migration_name}.rs"));
         let mut out_file = File::create(&dest_path).expect("Failed to create migrations.rs");
 
         let skip_name = migration_path.to_str().unwrap_or_default().len();
@@ -115,8 +115,7 @@ fn main() {
             let relative_to_out_dir = relative_path.to_str().unwrap().replace("\\", "/");
             writeln!(
                 out_file,
-                "    (\"{prefix}\", \"{rel_name}\", include_str!(r#\"{}\"#)),",
-                relative_to_out_dir
+                "    (\"{prefix}\", \"{rel_name}\", include_str!(r#\"{relative_to_out_dir}\"#)),"
             )
             .unwrap();
             println!("cargo:rerun-if-changed={}", path.display());

+ 7 - 7
crates/cdk-sql-common/src/mint/mod.rs

@@ -2248,10 +2248,10 @@ where
         let current_time = unix_time();
 
         let blinded_secrets_json = serde_json::to_string(&saga.blinded_secrets)
-            .map_err(|e| Error::Internal(format!("Failed to serialize blinded_secrets: {}", e)))?;
+            .map_err(|e| Error::Internal(format!("Failed to serialize blinded_secrets: {e}")))?;
 
         let input_ys_json = serde_json::to_string(&saga.input_ys)
-            .map_err(|e| Error::Internal(format!("Failed to serialize input_ys: {}", e)))?;
+            .map_err(|e| Error::Internal(format!("Failed to serialize input_ys: {e}")))?;
 
         query(
             r#"
@@ -2645,23 +2645,23 @@ fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
 
     let operation_id_str = column_as_string!(&operation_id);
     let operation_id = uuid::Uuid::parse_str(&operation_id_str)
-        .map_err(|e| Error::Internal(format!("Invalid operation_id UUID: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Invalid operation_id UUID: {e}")))?;
 
     let operation_kind_str = column_as_string!(&operation_kind);
     let operation_kind = mint::OperationKind::from_str(&operation_kind_str)
-        .map_err(|e| Error::Internal(format!("Invalid operation kind: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Invalid operation kind: {e}")))?;
 
     let state_str = column_as_string!(&state);
     let state = mint::SagaStateEnum::new(operation_kind, &state_str)
-        .map_err(|e| Error::Internal(format!("Invalid saga state: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Invalid saga state: {e}")))?;
 
     let blinded_secrets_str = column_as_string!(&blinded_secrets);
     let blinded_secrets: Vec<PublicKey> = serde_json::from_str(&blinded_secrets_str)
-        .map_err(|e| Error::Internal(format!("Failed to deserialize blinded_secrets: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Failed to deserialize blinded_secrets: {e}")))?;
 
     let input_ys_str = column_as_string!(&input_ys);
     let input_ys: Vec<PublicKey> = serde_json::from_str(&input_ys_str)
-        .map_err(|e| Error::Internal(format!("Failed to deserialize input_ys: {}", e)))?;
+        .map_err(|e| Error::Internal(format!("Failed to deserialize input_ys: {e}")))?;
 
     let quote_id = match &quote_id {
         Column::Text(s) => {

+ 12 - 0
crates/cdk-sql-common/src/stmt.rs

@@ -104,6 +104,18 @@ pub fn split_sql_parts(input: &str) -> Result<Vec<SqlPart>, SqlParseError> {
                 }
             }
 
+            '-' => {
+                current.push(chars.next().unwrap());
+                if chars.peek() == Some(&'-') {
+                    while let Some(&next) = chars.peek() {
+                        current.push(chars.next().unwrap());
+                        if next == '\n' {
+                            break;
+                        }
+                    }
+                }
+            }
+
             ':' => {
                 // Flush current raw SQL
                 if !current.is_empty() {

+ 15 - 0
crates/cdk-sql-common/src/wallet/migrations/postgres/20251111000000_keyset_counter_table.sql

@@ -0,0 +1,15 @@
+-- Create dedicated keyset_counter table without foreign keys
+-- This table tracks the counter for each keyset independently
+CREATE TABLE IF NOT EXISTS keyset_counter (
+    keyset_id TEXT PRIMARY KEY,
+    counter INTEGER NOT NULL DEFAULT 0
+);
+
+-- Migrate existing counter values from keyset table
+INSERT INTO keyset_counter (keyset_id, counter)
+SELECT id, counter
+FROM keyset
+WHERE counter > 0;
+
+-- Drop the counter column from keyset table
+ALTER TABLE keyset DROP COLUMN counter;

+ 36 - 0
crates/cdk-sql-common/src/wallet/migrations/sqlite/20251111000000_keyset_counter_table.sql

@@ -0,0 +1,36 @@
+-- Create dedicated keyset_counter table without foreign keys
+-- This table tracks the counter for each keyset independently
+CREATE TABLE IF NOT EXISTS keyset_counter (
+    keyset_id TEXT PRIMARY KEY,
+    counter INTEGER NOT NULL DEFAULT 0
+);
+
+-- Migrate existing counter values from keyset table
+INSERT INTO keyset_counter (keyset_id, counter)
+SELECT id, counter
+FROM keyset
+WHERE counter > 0;
+
+-- Drop the counter column from keyset table (SQLite requires table recreation)
+-- Step 1: Create new keyset table without counter column
+CREATE TABLE keyset_new (
+    id TEXT PRIMARY KEY,
+    mint_url TEXT NOT NULL,
+    keyset_u32 INTEGER,
+    unit TEXT NOT NULL,
+    active BOOL NOT NULL,
+    input_fee_ppk INTEGER,
+    final_expiry INTEGER DEFAULT NULL,
+    FOREIGN KEY(mint_url) REFERENCES mint(mint_url) ON UPDATE CASCADE ON DELETE CASCADE
+);
+
+-- Step 2: Copy data from old keyset table (excluding counter)
+INSERT INTO keyset_new (id, keyset_u32, mint_url, unit, active, input_fee_ppk, final_expiry)
+SELECT id, keyset_u32, mint_url, unit, active, input_fee_ppk, final_expiry
+FROM keyset;
+
+-- Step 3: Drop old keyset table
+DROP TABLE keyset;
+
+-- Step 4: Rename new table to keyset
+ALTER TABLE keyset_new RENAME TO keyset;

+ 19 - 17
crates/cdk-sql-common/src/wallet/mod.rs

@@ -779,14 +779,15 @@ ON CONFLICT(id) DO UPDATE SET
             )
             .execute(&tx).await?;
         }
-
-        query(r#"DELETE FROM proof WHERE y IN (:ys)"#)?
-            .bind_vec(
-                "ys",
-                removed_ys.iter().map(|y| y.to_bytes().to_vec()).collect(),
-            )
-            .execute(&tx)
-            .await?;
+        if !removed_ys.is_empty() {
+            query(r#"DELETE FROM proof WHERE y IN (:ys)"#)?
+                .bind_vec(
+                    "ys",
+                    removed_ys.iter().map(|y| y.to_bytes().to_vec()).collect(),
+                )
+                .execute(&tx)
+                .await?;
+        }
 
         tx.commit().await?;
 
@@ -916,16 +917,16 @@ ON CONFLICT(id) DO UPDATE SET
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
         let tx = ConnectionWithTransaction::new(conn).await?;
 
-        // Lock the row and get current counter
+        // Lock the row and get current counter from keyset_counter table
         let current_counter = query(
             r#"
             SELECT counter
-            FROM keyset
-            WHERE id=:id
+            FROM keyset_counter
+            WHERE keyset_id=:keyset_id
             FOR UPDATE
             "#,
         )?
-        .bind("id", keyset_id.to_string())
+        .bind("keyset_id", keyset_id.to_string())
         .pluck(&tx)
         .await?
         .map(|n| Ok::<_, Error>(column_as_number!(n)))
@@ -934,16 +935,17 @@ ON CONFLICT(id) DO UPDATE SET
 
         let new_counter = current_counter + count;
 
-        // Update with the new counter value
+        // Upsert the new counter value
         query(
             r#"
-            UPDATE keyset
-            SET counter=:new_counter
-            WHERE id=:id
+            INSERT INTO keyset_counter (keyset_id, counter)
+            VALUES (:keyset_id, :new_counter)
+            ON CONFLICT(keyset_id) DO UPDATE SET
+                counter = excluded.counter
             "#,
         )?
+        .bind("keyset_id", keyset_id.to_string())
         .bind("new_counter", new_counter)
-        .bind("id", keyset_id.to_string())
         .execute(&tx)
         .await?;
 

+ 4 - 0
crates/cdk/Cargo.toml

@@ -138,6 +138,10 @@ required-features = ["wallet"]
 name = "mint-token-bolt12"
 required-features = ["wallet"]
 
+[[example]]
+name = "human_readable_payment"
+required-features = ["wallet", "bip353"]
+
 [dev-dependencies]
 rand.workspace = true
 cdk-sqlite.workspace = true

+ 300 - 0
crates/cdk/examples/human_readable_payment.rs

@@ -0,0 +1,300 @@
+//! # Human Readable Payment Example
+//!
+//! This example demonstrates how to use both BIP-353 and Lightning Address (LNURL-pay)
+//! with the CDK wallet. Both allow users to share simple email-like addresses instead
+//! of complex Bitcoin addresses or Lightning invoices.
+//!
+//! ## BIP-353 (Bitcoin URI Payment Instructions)
+//!
+//! BIP-353 uses DNS TXT records to resolve human-readable addresses to BOLT12 offers.
+//! 1. Parse a human-readable address like `user@domain.com`
+//! 2. Query DNS TXT records at `user.user._bitcoin-payment.domain.com`
+//! 3. Extract Lightning offers (BOLT12) from the TXT records
+//! 4. Use the offer to create a melt quote
+//!
+//! ## Lightning Address (LNURL-pay)
+//!
+//! Lightning Address uses HTTPS to fetch BOLT11 invoices.
+//! 1. Parse a Lightning address like `user@domain.com`
+//! 2. Query HTTPS endpoint at `https://domain.com/.well-known/lnurlp/user`
+//! 3. Get callback URL and amount constraints
+//! 4. Request BOLT11 invoice with the specified amount
+//!
+//! ## Unified API
+//!
+//! The `melt_human_readable_quote()` method automatically tries BIP-353 first
+//! (if the mint supports BOLT12), then falls back to Lightning Address if needed.
+//!
+//! ## Usage
+//!
+//! ```bash
+//! cargo run --example human_readable_payment --features="wallet bip353"
+//! ```
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use cdk::amount::SplitTarget;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::nuts::CurrencyUnit;
+use cdk::wallet::Wallet;
+use cdk::Amount;
+use cdk_sqlite::wallet::memory;
+use rand::random;
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    println!("Human Readable Payment Example");
+    println!("================================\n");
+
+    // Example addresses
+    let bip353_address = "tsk@thesimplekid.com";
+    let lnurl_address =
+        "npub1qjgcmlpkeyl8mdkvp4s0xls4ytcux6my606tgfx9xttut907h0zs76lgjw@npubx.cash";
+
+    // Generate a random seed for the wallet
+    let seed = random::<[u8; 64]>();
+
+    // Mint URL and currency unit
+    let mint_url = "https://fake.thesimplekid.dev";
+    let unit = CurrencyUnit::Sat;
+    let initial_amount = Amount::from(2000); // Start with 2000 sats (enough for both payments)
+
+    // Initialize the memory store
+    let localstore = Arc::new(memory::empty().await?);
+
+    // Create a new wallet
+    let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?;
+
+    println!("Step 1: Funding the wallet");
+    println!("---------------------------");
+
+    // First, we need to fund the wallet
+    println!("Requesting mint quote for {} sats...", initial_amount);
+    let mint_quote = wallet.mint_quote(initial_amount, None).await?;
+    println!(
+        "Pay this invoice to fund the wallet:\n{}",
+        mint_quote.request
+    );
+    println!("\nQuote ID: {}", mint_quote.id);
+
+    // Wait for payment and mint tokens automatically
+    println!("\nWaiting for payment... (in real use, pay the above invoice)");
+    let proofs = wallet
+        .wait_and_mint_quote(
+            mint_quote,
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(300), // 5 minutes timeout
+        )
+        .await?;
+
+    let received_amount = proofs.total_amount()?;
+    println!("✓ Successfully minted {} sats\n", received_amount);
+
+    // ============================================================================
+    // Part 1: BIP-353 Payment
+    // ============================================================================
+
+    println!("\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Part 1: BIP-353 Payment (BOLT12 Offer via DNS)                ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    let bip353_amount_sats = 100; // Example: paying 100 sats
+    println!("BIP-353 Address: {}", bip353_address);
+    println!("Payment Amount: {} sats", bip353_amount_sats);
+    println!("\nHow BIP-353 works:");
+    println!("1. Parse address into user@domain");
+    println!("2. Query DNS TXT records at: tsk.user._bitcoin-payment.thesimplekid.com");
+    println!("3. Extract BOLT12 offer from DNS records");
+    println!("4. Create melt quote with the offer\n");
+
+    // Use the specific BIP353 method
+    println!("Attempting BIP-353 payment...");
+    match wallet
+        .melt_bip353_quote(bip353_address, bip353_amount_sats * 1_000)
+        .await
+    {
+        Ok(melt_quote) => {
+            println!("✓ BIP-353 melt quote received:");
+            println!("  Quote ID: {}", melt_quote.id);
+            println!("  Amount: {} sats", melt_quote.amount);
+            println!("  Fee Reserve: {} sats", melt_quote.fee_reserve);
+            println!("  State: {}", melt_quote.state);
+            println!("  Payment Method: {}", melt_quote.payment_method);
+
+            // Execute the payment
+            println!("\nExecuting payment...");
+            match wallet.melt(&melt_quote.id).await {
+                Ok(melt_result) => {
+                    println!("✓ BIP-353 payment successful!");
+                    println!("  State: {}", melt_result.state);
+                    println!("  Amount paid: {} sats", melt_result.amount);
+                    println!("  Fee paid: {} sats", melt_result.fee_paid);
+
+                    if let Some(preimage) = melt_result.preimage {
+                        println!("  Payment preimage: {}", preimage);
+                    }
+                }
+                Err(e) => {
+                    println!("✗ BIP-353 payment failed: {}", e);
+                }
+            }
+        }
+        Err(e) => {
+            println!("✗ Failed to get BIP-353 melt quote: {}", e);
+            println!("\nPossible reasons:");
+            println!("  • DNS resolution failed or no DNS records found");
+            println!("  • No Lightning offer (BOLT12) in DNS TXT records");
+            println!("  • DNSSEC validation failed");
+            println!("  • Mint doesn't support BOLT12");
+            println!("  • Network connectivity issues");
+        }
+    }
+
+    // ============================================================================
+    // Part 2: Lightning Address (LNURL-pay) Payment
+    // ============================================================================
+
+    println!("\n\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Part 2: Lightning Address Payment (BOLT11 via LNURL-pay)      ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    let lnurl_amount_sats = 100; // Example: paying 100 sats
+    println!("Lightning Address: {}", lnurl_address);
+    println!("Payment Amount: {} sats", lnurl_amount_sats);
+    println!("\nHow Lightning Address works:");
+    println!("1. Parse address into user@domain");
+    println!("2. Query HTTPS: https://npubx.cash/.well-known/lnurlp/npub1qj...");
+    println!("3. Get callback URL and amount constraints");
+    println!("4. Request BOLT11 invoice for the amount");
+    println!("5. Create melt quote with the invoice\n");
+
+    // Use the specific Lightning Address method
+    println!("Attempting Lightning Address payment...");
+    match wallet
+        .melt_lightning_address_quote(lnurl_address, lnurl_amount_sats * 1_000)
+        .await
+    {
+        Ok(melt_quote) => {
+            println!("✓ Lightning Address melt quote received:");
+            println!("  Quote ID: {}", melt_quote.id);
+            println!("  Amount: {} sats", melt_quote.amount);
+            println!("  Fee Reserve: {} sats", melt_quote.fee_reserve);
+            println!("  State: {}", melt_quote.state);
+            println!("  Payment Method: {}", melt_quote.payment_method);
+
+            // Execute the payment
+            println!("\nExecuting payment...");
+            match wallet.melt(&melt_quote.id).await {
+                Ok(melt_result) => {
+                    println!("✓ Lightning Address payment successful!");
+                    println!("  State: {}", melt_result.state);
+                    println!("  Amount paid: {} sats", melt_result.amount);
+                    println!("  Fee paid: {} sats", melt_result.fee_paid);
+
+                    if let Some(preimage) = melt_result.preimage {
+                        println!("  Payment preimage: {}", preimage);
+                    }
+                }
+                Err(e) => {
+                    println!("✗ Lightning Address payment failed: {}", e);
+                }
+            }
+        }
+        Err(e) => {
+            println!("✗ Failed to get Lightning Address melt quote: {}", e);
+            println!("\nPossible reasons:");
+            println!("  • HTTPS request to .well-known/lnurlp failed");
+            println!("  • Invalid Lightning Address format");
+            println!("  • Amount outside min/max constraints");
+            println!("  • Service unavailable or network issues");
+        }
+    }
+
+    // ============================================================================
+    // Part 3: Unified Human Readable API (Smart Fallback)
+    // ============================================================================
+
+    println!("\n\n╔════════════════════════════════════════════════════════════════╗");
+    println!("║ Part 3: Unified API (Automatic BIP-353 → LNURL Fallback)      ║");
+    println!("╚════════════════════════════════════════════════════════════════╝\n");
+
+    println!("The `melt_human_readable_quote()` method intelligently chooses:");
+    println!("1. If mint supports BOLT12 AND address has BIP-353 DNS: Use BIP-353");
+    println!("2. If BIP-353 DNS fails OR address has no DNS: Fall back to LNURL");
+    println!("3. If mint doesn't support BOLT12: Use LNURL directly\n");
+
+    // Test 1: Address with BIP-353 support (has DNS records)
+    let unified_amount_sats = 50;
+    println!("Test 1: Address with BIP-353 DNS support");
+    println!("Address: {}", bip353_address);
+    println!("Payment Amount: {} sats", unified_amount_sats);
+    println!("Expected: BIP-353 (BOLT12) via DNS resolution\n");
+
+    println!("Attempting unified payment...");
+    match wallet
+        .melt_human_readable_quote(bip353_address, unified_amount_sats * 1_000)
+        .await
+    {
+        Ok(melt_quote) => {
+            println!("✓ Unified melt quote received:");
+            println!("  Quote ID: {}", melt_quote.id);
+            println!("  Amount: {} sats", melt_quote.amount);
+            println!("  Fee Reserve: {} sats", melt_quote.fee_reserve);
+            println!("  Payment Method: {}", melt_quote.payment_method);
+
+            let method_str = melt_quote.payment_method.to_string().to_lowercase();
+            let used_method = if method_str.contains("bolt12") {
+                "BIP-353 (BOLT12)"
+            } else if method_str.contains("bolt11") {
+                "Lightning Address (LNURL-pay)"
+            } else {
+                "Unknown method"
+            };
+            println!("\n  → Used: {}", used_method);
+        }
+        Err(e) => {
+            println!("✗ Failed to get unified melt quote: {}", e);
+            println!("  Both BIP-353 and Lightning Address resolution failed");
+        }
+    }
+
+    // Test 2: Address without BIP-353 support (LNURL only)
+    println!("\n\nTest 2: Address without BIP-353 (LNURL-only)");
+    println!("Address: {}", lnurl_address);
+    println!("Payment Amount: {} sats", unified_amount_sats);
+    println!("Expected: Lightning Address (LNURL-pay) fallback\n");
+
+    println!("Attempting unified payment...");
+    match wallet
+        .melt_human_readable_quote(lnurl_address, unified_amount_sats * 1_000)
+        .await
+    {
+        Ok(melt_quote) => {
+            println!("✓ Unified melt quote received:");
+            println!("  Quote ID: {}", melt_quote.id);
+            println!("  Amount: {} sats", melt_quote.amount);
+            println!("  Fee Reserve: {} sats", melt_quote.fee_reserve);
+            println!("  Payment Method: {}", melt_quote.payment_method);
+
+            let method_str = melt_quote.payment_method.to_string().to_lowercase();
+            let used_method = if method_str.contains("bolt12") {
+                "BIP-353 (BOLT12)"
+            } else if method_str.contains("bolt11") {
+                "Lightning Address (LNURL-pay)"
+            } else {
+                "Unknown method"
+            };
+            println!("\n  → Used: {}", used_method);
+            println!("\n  Note: This address doesn't have BIP-353 DNS records,");
+            println!("        so it automatically fell back to LNURL-pay.");
+        }
+        Err(e) => {
+            println!("✗ Failed to get unified melt quote: {}", e);
+            println!("  Both BIP-353 and Lightning Address resolution failed");
+        }
+    }
+
+    Ok(())
+}

+ 1 - 1
crates/cdk/examples/proof-selection.rs

@@ -52,7 +52,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     // Select proofs to send
     let amount = Amount::from(64);
     let active_keyset_ids = wallet
-        .refresh_keysets()
+        .get_mint_keysets()
         .await?
         .active()
         .map(|keyset| keyset.id)

+ 129 - 0
crates/cdk/src/invoice.rs

@@ -0,0 +1,129 @@
+//! Invoice and offer decoding utilities
+//!
+//! Provides standalone functions to decode bolt11 invoices and bolt12 offers
+//! without requiring a wallet instance or creating melt quotes.
+
+use std::str::FromStr;
+
+use lightning::offers::offer::Offer;
+use lightning_invoice::Bolt11Invoice;
+
+use crate::error::Error;
+
+/// Type of Lightning payment request
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum PaymentType {
+    /// Bolt11 invoice
+    Bolt11,
+    /// Bolt12 offer
+    Bolt12,
+}
+
+/// Decoded invoice or offer information
+#[derive(Debug, Clone)]
+pub struct DecodedInvoice {
+    /// Type of payment request (Bolt11 or Bolt12)
+    pub payment_type: PaymentType,
+    /// Amount in millisatoshis, if specified
+    pub amount_msat: Option<u64>,
+    /// Expiry timestamp (Unix timestamp), if specified
+    pub expiry: Option<u64>,
+    /// Description or offer description, if specified
+    pub description: Option<String>,
+}
+
+/// Decode a bolt11 invoice or bolt12 offer from a string
+///
+/// This function attempts to parse the input as a bolt11 invoice first,
+/// then as a bolt12 offer if bolt11 parsing fails.
+///
+/// # Arguments
+///
+/// * `invoice_str` - The invoice or offer string to decode
+///
+/// # Returns
+///
+/// * `Ok(DecodedInvoice)` - Successfully decoded invoice/offer information
+/// * `Err(Error)` - Failed to parse as either bolt11 or bolt12
+///
+/// # Example
+///
+/// ```ignore
+/// let decoded = decode_invoice("lnbc...")?;
+/// match decoded.payment_type {
+///     PaymentType::Bolt11 => println!("Bolt11 invoice"),
+///     PaymentType::Bolt12 => println!("Bolt12 offer"),
+/// }
+/// ```
+pub fn decode_invoice(invoice_str: &str) -> Result<DecodedInvoice, Error> {
+    // Try to parse as Bolt11 first
+    if let Ok(invoice) = Bolt11Invoice::from_str(invoice_str) {
+        let amount_msat = invoice.amount_milli_satoshis();
+
+        let expiry = invoice.expires_at().map(|duration| duration.as_secs());
+
+        let description = match invoice.description() {
+            lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(desc) => Some(desc.to_string()),
+            lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(hash) => {
+                Some(format!("Hash: {}", hash.0))
+            }
+        };
+
+        return Ok(DecodedInvoice {
+            payment_type: PaymentType::Bolt11,
+            amount_msat,
+            expiry,
+            description,
+        });
+    }
+
+    // Try to parse as Bolt12
+    if let Ok(offer) = Offer::from_str(invoice_str) {
+        let amount_msat = offer.amount().and_then(|amount| {
+            // Bolt12 amounts can be in different currencies
+            // For now, we only extract if it's in Bitcoin (millisatoshis)
+            match amount {
+                lightning::offers::offer::Amount::Bitcoin { amount_msats } => Some(amount_msats),
+                _ => None,
+            }
+        });
+
+        let expiry = offer.absolute_expiry().map(|duration| duration.as_secs());
+
+        let description = offer.description().map(|d| d.to_string());
+
+        return Ok(DecodedInvoice {
+            payment_type: PaymentType::Bolt12,
+            amount_msat,
+            expiry,
+            description,
+        });
+    }
+
+    // If both parsing attempts failed
+    Err(Error::InvalidInvoice)
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_decode_bolt11() {
+        // This is a valid bolt11 invoice for 100 sats
+        let bolt11 = "lnbc1u1p53kkd9pp5ve8pd9zr60yjyvs6tn77mndavzrl5lwd2gx5hk934f6q8jwguzgsdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5482y73fxmlvg4t66nupdaph93h7dcmfsg2ud72wajf0cpk3a96rq9qxpqysgqujexd0l89u5dutn8hxnsec0c7jrt8wz0z67rut0eah0g7p6zhycn2vff0ts5vwn2h93kx8zzqy3tzu4gfhkya2zpdmqelg0ceqnjztcqma65pr";
+
+        let result = decode_invoice(bolt11);
+        assert!(result.is_ok());
+
+        let decoded = result.unwrap();
+        assert_eq!(decoded.payment_type, PaymentType::Bolt11);
+        assert_eq!(decoded.amount_msat, Some(100000));
+    }
+
+    #[test]
+    fn test_invalid_invoice() {
+        let result = decode_invoice("invalid_string");
+        assert!(result.is_err());
+    }
+}

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

@@ -26,9 +26,15 @@ pub mod mint;
 #[cfg(feature = "wallet")]
 pub mod wallet;
 
+#[cfg(test)]
+mod test_helpers;
+
 #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
 mod bip353;
 
+#[cfg(feature = "wallet")]
+mod lightning_address;
+
 #[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
 mod oidc_client;
 
@@ -48,9 +54,7 @@ pub use oidc_client::OidcClient;
 #[cfg(any(feature = "wallet", feature = "mint"))]
 pub mod event;
 pub mod fees;
-
-#[cfg(test)]
-pub mod test_helpers;
+pub mod invoice;
 
 #[doc(hidden)]
 pub use bitcoin::secp256k1;

+ 238 - 0
crates/cdk/src/lightning_address.rs

@@ -0,0 +1,238 @@
+//! Lightning Address Implementation
+//!
+//! This module provides functionality for resolving Lightning addresses
+//! to obtain Lightning invoices. Lightning addresses are user-friendly
+//! identifiers that look like email addresses (e.g., user@domain.com).
+//!
+//! Lightning addresses are converted to LNURL-pay endpoints following the spec:
+//! <https://domain.com/.well-known/lnurlp/user>
+
+use std::str::FromStr;
+use std::sync::Arc;
+
+use lightning_invoice::Bolt11Invoice;
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use tracing::instrument;
+use url::Url;
+
+use crate::wallet::MintConnector;
+use crate::Amount;
+
+/// Lightning Address Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Invalid Lightning address format
+    #[error("Invalid Lightning address format: {0}")]
+    InvalidFormat(String),
+    /// Invalid URL
+    #[error("Invalid URL: {0}")]
+    InvalidUrl(#[from] url::ParseError),
+    /// Failed to fetch pay request data
+    #[error("Failed to fetch pay request data: {0}")]
+    FetchPayRequest(#[from] crate::Error),
+    /// Lightning address service error
+    #[error("Lightning address service error: {0}")]
+    Service(String),
+    /// Amount below minimum
+    #[error("Amount {amount} msat is below minimum {min} msat")]
+    AmountBelowMinimum { amount: u64, min: u64 },
+    /// Amount above maximum
+    #[error("Amount {amount} msat is above maximum {max} msat")]
+    AmountAboveMaximum { amount: u64, max: u64 },
+    /// No invoice in response
+    #[error("No invoice in response")]
+    NoInvoice,
+    /// Failed to parse invoice
+    #[error("Failed to parse invoice: {0}")]
+    InvoiceParse(String),
+}
+
+/// Lightning address - represents a user@domain.com address
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct LightningAddress {
+    /// The user part of the address (before @)
+    user: String,
+    /// The domain part of the address (after @)
+    domain: String,
+}
+
+impl LightningAddress {
+    /// Convert the Lightning address to an HTTPS URL for the LNURL-pay endpoint
+    fn to_url(&self) -> Result<Url, Error> {
+        // Lightning address spec: https://domain.com/.well-known/lnurlp/user
+        let url_str = format!("https://{}/.well-known/lnurlp/{}", self.domain, self.user);
+        Ok(Url::parse(&url_str)?)
+    }
+
+    /// Fetch the LNURL-pay metadata from the service
+    #[instrument(skip(client))]
+    async fn fetch_pay_request_data(
+        &self,
+        client: &Arc<dyn MintConnector + Send + Sync>,
+    ) -> Result<LnurlPayResponse, Error> {
+        let url = self.to_url()?;
+
+        tracing::debug!("Fetching Lightning address pay data from: {}", url);
+
+        // Make HTTP GET request to fetch the pay request data
+        let lnurl_response = client.fetch_lnurl_pay_request(url.as_str()).await?;
+
+        // Validate the response
+        if let Some(ref reason) = lnurl_response.reason {
+            return Err(Error::Service(reason.clone()));
+        }
+
+        Ok(lnurl_response)
+    }
+
+    /// Request an invoice from the Lightning address service with a specific amount
+    #[instrument(skip(client))]
+    pub(crate) async fn request_invoice(
+        &self,
+        client: &Arc<dyn MintConnector + Send + Sync>,
+        amount_msat: Amount,
+    ) -> Result<Bolt11Invoice, Error> {
+        let pay_data = self.fetch_pay_request_data(client).await?;
+
+        // Validate amount is within acceptable range
+        let amount_msat_u64: u64 = amount_msat.into();
+        if amount_msat_u64 < pay_data.min_sendable {
+            return Err(Error::AmountBelowMinimum {
+                amount: amount_msat_u64,
+                min: pay_data.min_sendable,
+            });
+        }
+        if amount_msat_u64 > pay_data.max_sendable {
+            return Err(Error::AmountAboveMaximum {
+                amount: amount_msat_u64,
+                max: pay_data.max_sendable,
+            });
+        }
+
+        // Build callback URL with amount parameter
+        let mut callback_url = Url::parse(&pay_data.callback)?;
+
+        callback_url
+            .query_pairs_mut()
+            .append_pair("amount", &amount_msat_u64.to_string());
+
+        tracing::debug!("Requesting invoice from callback: {}", callback_url);
+
+        // Fetch the invoice
+        let invoice_response = client.fetch_lnurl_invoice(callback_url.as_str()).await?;
+
+        // Check for errors
+        if let Some(ref reason) = invoice_response.reason {
+            return Err(Error::Service(reason.clone()));
+        }
+
+        // Parse and return the invoice
+        let pr = invoice_response.pr.ok_or(Error::NoInvoice)?;
+
+        Bolt11Invoice::from_str(&pr).map_err(|e| Error::InvoiceParse(e.to_string()))
+    }
+}
+
+impl FromStr for LightningAddress {
+    type Err = Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let trimmed = s.trim();
+
+        // Parse Lightning address (user@domain)
+        if !trimmed.contains('@') {
+            return Err(Error::InvalidFormat("must contain '@'".to_string()));
+        }
+
+        let parts: Vec<&str> = trimmed.split('@').collect();
+        if parts.len() != 2 {
+            return Err(Error::InvalidFormat("must be user@domain".to_string()));
+        }
+
+        let user = parts[0].trim();
+        let domain = parts[1].trim();
+
+        if user.is_empty() || domain.is_empty() {
+            return Err(Error::InvalidFormat(
+                "user and domain must not be empty".to_string(),
+            ));
+        }
+
+        Ok(LightningAddress {
+            user: user.to_string(),
+            domain: domain.to_string(),
+        })
+    }
+}
+
+impl std::fmt::Display for LightningAddress {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}@{}", self.user, self.domain)
+    }
+}
+
+/// LNURL-pay response from the initial request
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct LnurlPayResponse {
+    /// Callback URL for requesting invoice
+    pub callback: String,
+    /// Minimum amount in millisatoshis
+    #[serde(rename = "minSendable")]
+    pub min_sendable: u64,
+    /// Maximum amount in millisatoshis
+    #[serde(rename = "maxSendable")]
+    pub max_sendable: u64,
+    /// Metadata string (JSON stringified)
+    pub metadata: String,
+    /// Short description tag (should be "payRequest")
+    pub tag: Option<String>,
+    /// Optional error reason
+    pub reason: Option<String>,
+}
+
+/// LNURL-pay invoice response from the callback
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct LnurlPayInvoiceResponse {
+    /// The BOLT11 payment request (invoice)
+    pub pr: Option<String>,
+    /// Optional success action
+    pub success_action: Option<serde_json::Value>,
+    /// Optional routes (deprecated)
+    pub routes: Option<Vec<serde_json::Value>>,
+    /// Optional error reason
+    pub reason: Option<String>,
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_lightning_address_parsing() {
+        let addr = LightningAddress::from_str("satoshi@bitcoin.org").unwrap();
+        assert_eq!(addr.user, "satoshi");
+        assert_eq!(addr.domain, "bitcoin.org");
+        assert_eq!(addr.to_string(), "satoshi@bitcoin.org");
+    }
+
+    #[test]
+    fn test_lightning_address_to_url() {
+        let addr = LightningAddress {
+            user: "alice".to_string(),
+            domain: "example.com".to_string(),
+        };
+
+        let url = addr.to_url().unwrap();
+        assert_eq!(url.as_str(), "https://example.com/.well-known/lnurlp/alice");
+    }
+
+    #[test]
+    fn test_invalid_lightning_address() {
+        assert!(LightningAddress::from_str("invalid").is_err());
+        assert!(LightningAddress::from_str("@example.com").is_err());
+        assert!(LightningAddress::from_str("user@").is_err());
+        assert!(LightningAddress::from_str("user").is_err());
+    }
+}

+ 50 - 0
crates/cdk/src/mint/builder.rs

@@ -49,6 +49,7 @@ impl MintBuilder {
                 .nut10(true)
                 .nut11(true)
                 .nut12(true)
+                .nut14(true)
                 .nut20(true),
             ..Default::default()
         };
@@ -382,3 +383,52 @@ impl MintMeltLimits {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use std::sync::Arc;
+
+    use cdk_sqlite::mint::memory;
+
+    use super::*;
+
+    #[tokio::test]
+    async fn test_mint_builder_default_nuts_support() {
+        let localstore = Arc::new(memory::empty().await.unwrap());
+        let builder = MintBuilder::new(localstore);
+        let mint_info = builder.current_mint_info();
+
+        assert!(
+            mint_info.nuts.nut07.supported,
+            "NUT-07 should be supported by default"
+        );
+        assert!(
+            mint_info.nuts.nut08.supported,
+            "NUT-08 should be supported by default"
+        );
+        assert!(
+            mint_info.nuts.nut09.supported,
+            "NUT-09 should be supported by default"
+        );
+        assert!(
+            mint_info.nuts.nut10.supported,
+            "NUT-10 should be supported by default"
+        );
+        assert!(
+            mint_info.nuts.nut11.supported,
+            "NUT-11 should be supported by default"
+        );
+        assert!(
+            mint_info.nuts.nut12.supported,
+            "NUT-12 should be supported by default"
+        );
+        assert!(
+            mint_info.nuts.nut14.supported,
+            "NUT-14 (HTLC) should be supported by default"
+        );
+        assert!(
+            mint_info.nuts.nut20.supported,
+            "NUT-20 should be supported by default"
+        );
+    }
+}

+ 0 - 2
crates/cdk/src/mint/melt/melt_saga/tests.rs

@@ -8,8 +8,6 @@
 //! - Concurrent operations
 //! - Failure handling
 
-#![cfg(test)]
-
 use cdk_common::mint::{MeltSagaState, OperationKind, Saga};
 use cdk_common::nuts::MeltQuoteState;
 use cdk_common::{Amount, ProofsMethods, State};

+ 113 - 5
crates/cdk/src/mint/melt.rs → crates/cdk/src/mint/melt/mod.rs

@@ -8,7 +8,7 @@ use cdk_common::payment::{
     Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, OutgoingPaymentOptions,
 };
 use cdk_common::quote_id::QuoteId;
-use cdk_common::{MeltOptions, MeltQuoteBolt12Request};
+use cdk_common::{MeltOptions, MeltQuoteBolt12Request, SpendingConditionVerification};
 #[cfg(feature = "prometheus")]
 use cdk_prometheus::METRICS;
 use lightning::offers::offer::Offer;
@@ -24,8 +24,11 @@ use crate::types::PaymentProcessorKey;
 use crate::util::unix_time;
 use crate::{ensure_cdk, Amount, Error};
 
-mod melt_saga;
-pub(super) mod shared;
+pub(crate) mod melt_saga;
+pub(crate) mod shared;
+
+#[cfg(test)]
+mod tests;
 
 use melt_saga::MeltSaga;
 
@@ -176,7 +179,7 @@ impl Mint {
                     METRICS.record_mint_operation("get_melt_bolt11_quote", false);
                     METRICS.record_error();
                 }
-                Error::UnsupportedUnit
+                err
             })?;
 
         if &payment_quote.unit != unit {
@@ -282,7 +285,7 @@ impl Mint {
                     err
                 );
 
-                Error::UnsupportedUnit
+                err
             })?;
 
         if &payment_quote.unit != unit {
@@ -422,6 +425,13 @@ impl Mint {
         &self,
         melt_request: &MeltRequest<QuoteId>,
     ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        // Verify spending conditions (NUT-10/NUT-11/NUT-14), i.e. P2PK
+        // and HTLC (including SIGALL)
+        melt_request.verify_spending_conditions()?;
+
+        // We don't need to check P2PK or HTLC again. It has all been checked above
+        // and the code doesn't reach here unless such verifications were satisfactory
+
         let verification = self.verify_inputs(melt_request.inputs()).await?;
 
         let init_saga = MeltSaga::new(
@@ -443,4 +453,102 @@ impl Mint {
         // Step 4: Finalize (TX2 - marks spent, issues change)
         payment_saga.finalize().await
     }
+
+    /// Process melt asynchronously - returns immediately after setup with PENDING state
+    ///
+    /// This method is called when the client includes the `Prefer: respond-async` header.
+    /// It performs the setup phase (TX1) to validate and reserve proofs, then spawns a
+    /// background task to complete the payment and finalization phases.
+    pub async fn melt_async(
+        &self,
+        melt_request: &MeltRequest<QuoteId>,
+    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        let verification = self.verify_inputs(melt_request.inputs()).await?;
+
+        let init_saga = MeltSaga::new(
+            std::sync::Arc::new(self.clone()),
+            self.localstore.clone(),
+            std::sync::Arc::clone(&self.pubsub_manager),
+        );
+
+        let setup_saga = init_saga.setup_melt(melt_request, verification).await?;
+
+        // Get the quote to return with PENDING state
+        let quote_id = melt_request.quote().clone();
+        let quote = self
+            .localstore
+            .get_melt_quote(&quote_id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
+        // Spawn background task to complete the melt operation
+        let melt_request_clone = melt_request.clone();
+        let quote_id_clone = quote_id.clone();
+        tokio::spawn(async move {
+            tracing::debug!(
+                "Starting background melt completion for quote: {}",
+                quote_id_clone
+            );
+
+            // Step 2: Attempt internal settlement
+            match setup_saga
+                .attempt_internal_settlement(&melt_request_clone)
+                .await
+            {
+                Ok((setup_saga, settlement)) => {
+                    // Step 3: Make payment
+                    match setup_saga.make_payment(settlement).await {
+                        Ok(payment_saga) => {
+                            // Step 4: Finalize
+                            match payment_saga.finalize().await {
+                                Ok(_) => {
+                                    tracing::info!(
+                                        "Background melt completed successfully for quote: {}",
+                                        quote_id_clone
+                                    );
+                                }
+                                Err(e) => {
+                                    tracing::error!(
+                                        "Failed to finalize melt for quote {}: {}",
+                                        quote_id_clone,
+                                        e
+                                    );
+                                }
+                            }
+                        }
+                        Err(e) => {
+                            tracing::error!(
+                                "Failed to make payment for quote {}: {}",
+                                quote_id_clone,
+                                e
+                            );
+                        }
+                    }
+                }
+                Err(e) => {
+                    tracing::error!(
+                        "Failed internal settlement for quote {}: {}",
+                        quote_id_clone,
+                        e
+                    );
+                }
+            }
+        });
+
+        debug_assert!(quote.state == MeltQuoteState::Pending);
+
+        // Return immediately with the quote in PENDING state
+        Ok(MeltQuoteBolt11Response {
+            quote: quote_id,
+            amount: quote.amount,
+            fee_reserve: quote.fee_reserve,
+            state: quote.state,
+            paid: Some(false),
+            expiry: quote.expiry,
+            payment_preimage: None,
+            change: None,
+            request: Some(quote.request.to_string()),
+            unit: Some(quote.unit),
+        })
+    }
 }

+ 180 - 0
crates/cdk/src/mint/melt/tests/htlc_sigall_spending_conditions_tests.rs

@@ -0,0 +1,180 @@
+//! HTLC SIG_ALL tests for melt functionality
+//!
+//! These tests verify that the mint correctly enforces SIG_ALL flag behavior for HTLC
+//! during melt operations.
+
+use std::str::FromStr;
+
+use cdk_common::dhke::construct_proofs;
+use cdk_common::melt::MeltQuoteRequest;
+use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
+use cdk_common::{Amount, SpendingConditionVerification};
+
+use crate::test_helpers::nut10::{
+    create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper,
+};
+
+/// Test: HTLC SIG_ALL requiring preimage and one signature
+///
+/// Creates HTLC-locked proofs with SIG_ALL flag and verifies:
+/// 1. Melting with only preimage fails (signature required)
+/// 2. Melting with only SIG_INPUTS signatures fails (SIG_ALL required)
+/// 3. Melting with both preimage and SIG_ALL signature succeeds
+#[tokio::test]
+async fn test_htlc_sig_all_requiring_preimage_and_one_signature() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for Alice
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+
+    // Create hash and preimage
+    let (hash, preimage) = create_test_hash_and_preimage();
+
+    println!("Alice pubkey: {}", alice_pubkey);
+    println!("Hash: {}", hash);
+    println!("Preimage: {}", preimage);
+
+    // Step 1: Mint regular proofs (enough to cover invoice + fees)
+    let input_amount = Amount::from(20);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create HTLC spending conditions with SIG_ALL flag (hash locked to Alice's key)
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: None,
+            num_sigs: None,            // Default (1)
+            sig_flag: SigFlag::SigAll, // <-- SIG_ALL flag
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+    println!("Created HTLC spending conditions with SIG_ALL flag");
+
+    // Step 3: Create HTLC blinded messages (outputs)
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+    println!(
+        "Created {} HTLC outputs locked to alice with hash",
+        htlc_outputs.len()
+    );
+
+    // Step 4: Swap regular proofs for HTLC proofs (no signature needed on inputs)
+    let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for HTLC proofs");
+    println!("Swap successful! Got BlindSignatures for our HTLC outputs");
+
+    // Step 5: Construct the HTLC proofs
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = htlc_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} HTLC proof(s) [{}]",
+        htlc_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 6: Create a real melt quote that we'll use for all tests
+    let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
+    let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap();
+
+    let melt_quote_request = cdk_common::MeltQuoteBolt11Request {
+        request: bolt11,
+        unit: cdk_common::CurrencyUnit::Sat,
+        options: None,
+    };
+
+    let melt_quote = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request))
+        .await
+        .unwrap();
+    println!("Created melt quote: {}", melt_quote.quote);
+
+    // Step 7: Try to melt with only preimage (should fail - signature required)
+    let mut proofs_preimage_only = htlc_proofs.clone();
+    // Add only preimage to first proof (no signature)
+    proofs_preimage_only[0].add_preimage(preimage.clone());
+
+    let melt_request_preimage_only =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_preimage_only.into(), None);
+
+    let result = melt_request_preimage_only.verify_spending_conditions();
+    assert!(
+        result.is_err(),
+        "Should fail with only preimage (no signature)"
+    );
+    println!("✓ Melting with ONLY preimage failed verification as expected");
+
+    let melt_result = mint.melt(&melt_request_preimage_only).await;
+    assert!(
+        melt_result.is_err(),
+        "Actual melt should also fail with only preimage"
+    );
+    println!("✓ Actual melt with ONLY preimage also failed as expected");
+
+    // Step 8: Try to melt with SIG_INPUTS signatures (should fail - SIG_ALL required)
+    let mut melt_request_sig_inputs =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), htlc_proofs.clone().into(), None);
+
+    // Add preimage to first proof
+    melt_request_sig_inputs.inputs_mut()[0].add_preimage(preimage.clone());
+
+    // Sign each proof individually (SIG_INPUTS mode) - this should fail for SIG_ALL
+    for proof in melt_request_sig_inputs.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = melt_request_sig_inputs.verify_spending_conditions();
+    assert!(
+        result.is_err(),
+        "Should fail - SIG_INPUTS signatures not valid for SIG_ALL"
+    );
+    println!("✓ Melting with SIG_INPUTS signatures failed verification as expected");
+
+    let melt_result = mint.melt(&melt_request_sig_inputs).await;
+    assert!(
+        melt_result.is_err(),
+        "Actual melt should also fail with SIG_INPUTS signatures"
+    );
+    println!("✓ Actual melt with SIG_INPUTS signatures also failed as expected");
+
+    // Step 9: Now melt with correct preimage + SIG_ALL signature
+    let mut melt_request =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), htlc_proofs.clone().into(), None);
+
+    // Add preimage to first proof
+    melt_request.inputs_mut()[0].add_preimage(preimage.clone());
+
+    // Use sign_sig_all to sign the transaction (signature goes on first proof's witness)
+    melt_request.sign_sig_all(alice_secret.clone()).unwrap();
+
+    // Verify spending conditions pass
+    melt_request.verify_spending_conditions().unwrap();
+    println!("✓ HTLC SIG_ALL spending conditions verified successfully");
+
+    // Perform the actual melt - this also verifies spending conditions internally
+    let melt_response = mint.melt(&melt_request).await.unwrap();
+    println!("✓ Melt operation completed successfully!");
+    println!("  Quote state: {:?}", melt_response.state);
+    assert_eq!(melt_response.quote, melt_quote.quote);
+}

+ 191 - 0
crates/cdk/src/mint/melt/tests/htlc_spending_conditions_tests.rs

@@ -0,0 +1,191 @@
+//! HTLC (NUT-14) tests for melt functionality
+//!
+//! These tests verify that the mint correctly validates HTLC spending conditions
+//! during melt operations, including:
+//! - Hash preimage verification
+//! - Signature validation
+
+use cdk_common::dhke::construct_proofs;
+use cdk_common::melt::MeltQuoteRequest;
+use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
+use cdk_common::Amount;
+
+use crate::test_helpers::nut10::{
+    create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper,
+};
+
+/// Test: HTLC requiring preimage and one signature
+///
+/// Creates HTLC-locked proofs and verifies:
+/// 1. Melting with only preimage fails (signature required)
+/// 2. Melting with only signature fails (preimage required)
+/// 3. Melting with both preimage and signature succeeds
+#[tokio::test]
+async fn test_htlc_requiring_preimage_and_one_signature() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for Alice
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+
+    // Create hash and preimage
+    let (hash, preimage) = create_test_hash_and_preimage();
+
+    println!("Alice pubkey: {}", alice_pubkey);
+    println!("Hash: {}", hash);
+    println!("Preimage: {}", preimage);
+
+    // Step 1: Mint regular proofs (enough to cover the invoice amount + fees)
+    // Invoice is 10 sats, fee reserve is 100% (10 sats), so we need 20 sats total
+    let input_amount = Amount::from(20);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create HTLC spending conditions (hash locked to Alice's key)
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: None,
+            num_sigs: None, // Default (1)
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+    println!("Created HTLC spending conditions");
+
+    // Step 3: Create HTLC blinded messages (outputs)
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+    println!(
+        "Created {} HTLC outputs locked to alice with hash",
+        htlc_outputs.len()
+    );
+
+    // Step 4: Swap regular proofs for HTLC proofs (no signature needed on inputs)
+    let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for HTLC proofs");
+    println!("Swap successful! Got BlindSignatures for our HTLC outputs");
+
+    // Step 5: Construct the HTLC proofs
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = htlc_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} HTLC proof(s) [{}]",
+        htlc_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 6: Create a real melt quote that we'll use for all tests
+    use std::str::FromStr;
+
+    use cdk_common::SpendingConditionVerification;
+    let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
+    let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap();
+
+    let melt_quote_request = cdk_common::MeltQuoteBolt11Request {
+        request: bolt11,
+        unit: cdk_common::CurrencyUnit::Sat,
+        options: None,
+    };
+
+    let melt_quote = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request))
+        .await
+        .unwrap();
+    println!("Created melt quote: {}", melt_quote.quote);
+
+    // Step 7: Try to melt with only preimage (should fail - signature required)
+
+    let mut proofs_preimage_only = htlc_proofs.clone();
+
+    // Add only preimage (no signature)
+    for proof in proofs_preimage_only.iter_mut() {
+        proof.add_preimage(preimage.clone());
+    }
+
+    let melt_request_preimage_only =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_preimage_only.into(), None);
+
+    let result = melt_request_preimage_only.verify_spending_conditions();
+    assert!(
+        result.is_err(),
+        "Should fail with only preimage (no signature)"
+    );
+    println!("✓ Melting with ONLY preimage failed verification as expected");
+
+    // Also verify the actual melt fails
+    let melt_result = mint.melt(&melt_request_preimage_only).await;
+    assert!(
+        melt_result.is_err(),
+        "Actual melt should also fail with only preimage"
+    );
+    println!("✓ Actual melt with ONLY preimage also failed as expected");
+
+    // Step 8: Try to melt with only signature (should fail - preimage required)
+    let mut proofs_signature_only = htlc_proofs.clone();
+
+    // Add only signature (no preimage)
+    for proof in proofs_signature_only.iter_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let melt_request_signature_only =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_signature_only.into(), None);
+
+    let result = melt_request_signature_only.verify_spending_conditions();
+    assert!(
+        result.is_err(),
+        "Should fail with only signature (no preimage)"
+    );
+    println!("✓ Melting with ONLY signature failed verification as expected");
+
+    // Also verify the actual melt fails
+    let melt_result = mint.melt(&melt_request_signature_only).await;
+    assert!(
+        melt_result.is_err(),
+        "Actual melt should also fail with only signature"
+    );
+    println!("✓ Actual melt with ONLY signature also failed as expected");
+
+    // Step 9: Now melt with correct preimage + signature
+    let mut proofs_both = htlc_proofs.clone();
+
+    // Add preimage and sign all proofs
+    for proof in proofs_both.iter_mut() {
+        proof.add_preimage(preimage.clone());
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let melt_request =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_both.into(), None);
+
+    // Verify spending conditions pass
+    melt_request.verify_spending_conditions().unwrap();
+    println!("✓ HTLC spending conditions verified successfully");
+
+    // Perform the actual melt - this also verifies spending conditions internally
+    let melt_response = mint.melt(&melt_request).await.unwrap();
+    println!("✓ Melt operation completed successfully!");
+    println!("  Quote state: {:?}", melt_response.state);
+    assert_eq!(melt_response.quote, melt_quote.quote);
+}

+ 276 - 0
crates/cdk/src/mint/melt/tests/locktime_spending_conditions_tests.rs

@@ -0,0 +1,276 @@
+//! Locktime tests for melt functionality
+//!
+//! These tests verify that the mint correctly validates locktime spending conditions
+//! during melt operations, including spending after locktime expiry.
+
+use std::str::FromStr;
+
+use cdk_common::dhke::construct_proofs;
+use cdk_common::melt::MeltQuoteRequest;
+use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
+use cdk_common::{Amount, SpendingConditionVerification};
+
+use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
+use crate::util::unix_time;
+
+/// Test: P2PK with locktime - spending after expiry
+///
+/// Creates P2PK proofs with locktime and verifies:
+/// 1. Melting before locktime with wrong key fails
+/// 2. Melting after locktime with any key succeeds (anyone-can-spend)
+#[tokio::test]
+async fn test_p2pk_post_locktime_anyone_can_spend() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypairs
+    let (_alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, _bob_pubkey) = create_test_keypair();
+
+    println!("Alice pubkey: {}", alice_pubkey);
+
+    // Step 1: Create regular unencumbered proofs
+    let input_amount = Amount::from(20);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create P2PK spending conditions with locktime in the past (already expired)
+    // Locktime is 1 hour ago - so it's already expired
+    let locktime = unix_time() - 3600;
+
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime),     // Locktime in the past (expired)
+            pubkeys: None,                // no additional pubkeys
+            refund_keys: None,            // NO refund keys - anyone can spend!
+            num_sigs: None,               // default (1)
+            sig_flag: SigFlag::SigInputs, // SIG_INPUTS flag
+            num_sigs_refund: None,        // default (1)
+        }),
+    );
+    println!(
+        "Created P2PK spending conditions with expired locktime: {}",
+        locktime
+    );
+
+    // Split the input amount
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    // Create blinded messages
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+    println!("Created {} P2PK outputs with locktime", p2pk_outputs.len());
+
+    // Step 3: Swap for P2PK proofs
+    let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for P2PK proofs");
+    println!("Swap successful! Got BlindSignatures");
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} P2PK proof(s) [{}]",
+        p2pk_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 5: Create a real melt quote
+    let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
+    let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap();
+
+    let melt_quote_request = cdk_common::MeltQuoteBolt11Request {
+        request: bolt11,
+        unit: cdk_common::CurrencyUnit::Sat,
+        options: None,
+    };
+
+    let melt_quote = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request))
+        .await
+        .unwrap();
+    println!("Created melt quote: {}", melt_quote.quote);
+
+    // Step 6: Try to melt with Bob's signature (wrong key, but locktime expired so should work)
+    let mut proofs_bob_signed = p2pk_proofs.clone();
+
+    // Sign with Bob's key (not Alice's)
+    for proof in proofs_bob_signed.iter_mut() {
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let melt_request_bob =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_bob_signed.into(), None);
+
+    // After locktime expiry, anyone can spend (signature verification is skipped)
+    melt_request_bob.verify_spending_conditions().unwrap();
+    println!("✓ Post-locktime spending conditions verified successfully (anyone-can-spend)");
+
+    // Perform the actual melt
+    let melt_response = mint.melt(&melt_request_bob).await.unwrap();
+    println!("✓ Melt operation completed successfully with Bob's key after locktime!");
+    println!("  Quote state: {:?}", melt_response.state);
+    assert_eq!(melt_response.quote, melt_quote.quote);
+}
+
+/// Test: P2PK with future locktime - must use correct key before expiry
+///
+/// Creates P2PK proofs with future locktime and verifies:
+/// 1. Melting with wrong key before locktime fails
+/// 2. Melting with correct key before locktime succeeds
+#[tokio::test]
+async fn test_p2pk_before_locktime_requires_correct_key() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypairs
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, _bob_pubkey) = create_test_keypair();
+
+    println!("Alice pubkey: {}", alice_pubkey);
+
+    // Step 1: Create regular unencumbered proofs
+    let input_amount = Amount::from(20);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create P2PK spending conditions with locktime FAR in the future
+    // Locktime is 1 year from now - definitely not expired yet
+    let locktime = unix_time() + 365 * 24 * 60 * 60;
+
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                Some(locktime),           // Locktime in the future
+                None,                     // no additional pubkeys
+                None,                     // no refund keys
+                None,                     // default num_sigs (1)
+                Some(SigFlag::SigInputs), // SIG_INPUTS flag
+                None,                     // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+    println!(
+        "Created P2PK spending conditions with future locktime: {}",
+        locktime
+    );
+
+    // Split the input amount
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    // Create blinded messages
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+    println!("Created {} P2PK outputs with locktime", p2pk_outputs.len());
+
+    // Step 3: Swap for P2PK proofs
+    let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for P2PK proofs");
+    println!("Swap successful! Got BlindSignatures");
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} P2PK proof(s) [{}]",
+        p2pk_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 5: Create a real melt quote
+    let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
+    let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap();
+
+    let melt_quote_request = cdk_common::MeltQuoteBolt11Request {
+        request: bolt11,
+        unit: cdk_common::CurrencyUnit::Sat,
+        options: None,
+    };
+
+    let melt_quote = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request))
+        .await
+        .unwrap();
+    println!("Created melt quote: {}", melt_quote.quote);
+
+    // Step 6: Try to melt with Bob's signature (wrong key, locktime not expired)
+    let mut proofs_bob_signed = p2pk_proofs.clone();
+
+    // Sign with Bob's key (not Alice's)
+    for proof in proofs_bob_signed.iter_mut() {
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let melt_request_bob =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_bob_signed.into(), None);
+
+    // Before locktime expiry, wrong key should fail
+    let result = melt_request_bob.verify_spending_conditions();
+    assert!(
+        result.is_err(),
+        "Should fail with wrong key before locktime"
+    );
+    println!("✓ Melting with Bob's key before locktime failed verification as expected");
+
+    // Also verify the actual melt fails
+    let melt_result = mint.melt(&melt_request_bob).await;
+    assert!(
+        melt_result.is_err(),
+        "Actual melt should also fail with wrong key"
+    );
+    println!("✓ Actual melt with Bob's key before locktime also failed as expected");
+
+    // Step 7: Now melt with Alice's signature (correct key)
+    let mut proofs_alice_signed = p2pk_proofs.clone();
+
+    // Sign with Alice's key (correct)
+    for proof in proofs_alice_signed.iter_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let melt_request_alice =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_alice_signed.into(), None);
+
+    // Verify spending conditions pass
+    melt_request_alice.verify_spending_conditions().unwrap();
+    println!("✓ Pre-locktime spending conditions verified successfully with Alice's key");
+
+    // Perform the actual melt
+    let melt_response = mint.melt(&melt_request_alice).await.unwrap();
+    println!("✓ Melt operation completed successfully with Alice's key before locktime!");
+    println!("  Quote state: {:?}", melt_response.state);
+    assert_eq!(melt_response.quote, melt_quote.quote);
+}

+ 5 - 0
crates/cdk/src/mint/melt/tests/mod.rs

@@ -0,0 +1,5 @@
+mod htlc_sigall_spending_conditions_tests;
+mod htlc_spending_conditions_tests;
+mod locktime_spending_conditions_tests;
+mod p2pk_sigall_spending_conditions_tests;
+mod p2pk_spending_conditions_tests;

+ 168 - 0
crates/cdk/src/mint/melt/tests/p2pk_sigall_spending_conditions_tests.rs

@@ -0,0 +1,168 @@
+//! P2PK SIG_ALL tests for melt functionality
+//!
+//! These tests verify that the mint correctly enforces SIG_ALL flag behavior
+//! during melt operations.
+
+use cdk_common::dhke::construct_proofs;
+use cdk_common::melt::MeltQuoteRequest;
+use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
+use cdk_common::Amount;
+
+use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
+
+/// Test: P2PK with SIG_ALL flag requires transaction signature
+///
+/// Creates P2PK proofs with SIG_ALL flag and verifies:
+/// 1. Melting without signature is rejected
+/// 2. Melting with SIG_INPUTS signatures (individual proof signatures) is rejected
+/// 3. Melting with SIG_ALL signature (transaction signature) succeeds
+#[tokio::test]
+async fn test_p2pk_sig_all_requires_transaction_signature() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for P2PK
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    println!("Alice pubkey: {}", alice_pubkey);
+
+    // Step 1: Create regular unencumbered proofs that we'll swap for P2PK proofs
+    // Invoice is 10 sats, fee reserve is 100% (10 sats), so we need 20 sats total
+    let input_amount = Amount::from(20);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create P2PK blinded messages (outputs locked to alice_pubkey) with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                None,                  // no locktime
+                None,                  // no additional pubkeys
+                None,                  // no refund keys
+                None,                  // default num_sigs (1)
+                Some(SigFlag::SigAll), // SIG_ALL flag
+                None,                  // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+    println!("Created P2PK spending conditions with SIG_ALL flag");
+
+    // Split the input amount into power-of-2 denominations
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    // Create blinded messages for each split amount
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    println!(
+        "Created {} P2PK outputs locked to alice",
+        p2pk_outputs.len()
+    );
+
+    // Step 3: Swap regular proofs for P2PK proofs (no signature needed on inputs)
+    let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for P2PK proofs");
+    println!("Swap successful! Got BlindSignatures for our P2PK outputs");
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} P2PK proof(s) [{}]",
+        p2pk_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 5: Create a real melt quote that we'll use for all tests
+    use std::str::FromStr;
+
+    use cdk_common::SpendingConditionVerification;
+    let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
+    let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap();
+
+    let melt_quote_request = cdk_common::MeltQuoteBolt11Request {
+        request: bolt11,
+        unit: cdk_common::CurrencyUnit::Sat,
+        options: None,
+    };
+
+    let melt_quote = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request))
+        .await
+        .unwrap();
+    println!("Created melt quote: {}", melt_quote.quote);
+
+    // Step 6: Try to melt P2PK proof WITHOUT signature (should fail)
+
+    let melt_request_no_sig =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), p2pk_proofs.clone().into(), None);
+
+    let result = melt_request_no_sig.verify_spending_conditions();
+    assert!(result.is_err(), "Should fail without signature");
+    println!("✓ Melting WITHOUT signature failed verification as expected");
+
+    // Also verify the actual melt fails
+    let melt_result = mint.melt(&melt_request_no_sig).await;
+    assert!(
+        melt_result.is_err(),
+        "Actual melt should also fail without signature"
+    );
+    println!("✓ Actual melt WITHOUT signature also failed as expected");
+
+    // Step 7: Sign all proofs individually (SIG_INPUTS way) - should fail for SIG_ALL
+    let mut melt_request_sig_inputs =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), p2pk_proofs.clone().into(), None);
+
+    // Sign each proof individually (SIG_INPUTS mode)
+    for proof in melt_request_sig_inputs.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = melt_request_sig_inputs.verify_spending_conditions();
+    assert!(
+        result.is_err(),
+        "Should fail - SIG_INPUTS signatures not valid for SIG_ALL"
+    );
+    println!("✓ Melting with SIG_INPUTS signatures failed verification as expected");
+
+    // Also verify the actual melt fails
+    let melt_result = mint.melt(&melt_request_sig_inputs).await;
+    assert!(
+        melt_result.is_err(),
+        "Actual melt should also fail with SIG_INPUTS signatures"
+    );
+    println!("✓ Actual melt with SIG_INPUTS signatures also failed as expected");
+
+    // Step 8: Sign the transaction with SIG_ALL and perform the melt
+    let mut melt_request =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), p2pk_proofs.clone().into(), None);
+
+    // Use sign_sig_all to sign the transaction (signature goes on first proof's witness)
+    melt_request.sign_sig_all(alice_secret.clone()).unwrap();
+
+    // Verify spending conditions pass
+    melt_request.verify_spending_conditions().unwrap();
+    println!("✓ P2PK SIG_ALL spending conditions verified successfully");
+
+    // Perform the actual melt - this also verifies spending conditions internally
+    let melt_response = mint.melt(&melt_request).await.unwrap();
+    println!("✓ Melt operation completed successfully!");
+    println!("  Quote state: {:?}", melt_response.state);
+    assert_eq!(melt_response.quote, melt_quote.quote);
+}

+ 134 - 0
crates/cdk/src/mint/melt/tests/p2pk_spending_conditions_tests.rs

@@ -0,0 +1,134 @@
+//! Basic P2PK tests for melt functionality (SIG_INPUTS mode)
+//!
+//! These tests verify that the mint correctly validates basic P2PK spending conditions
+//! during melt operations.
+
+use std::str::FromStr;
+
+use cdk_common::dhke::construct_proofs;
+use cdk_common::melt::MeltQuoteRequest;
+use cdk_common::nuts::SpendingConditions;
+use cdk_common::{Amount, SpendingConditionVerification};
+
+use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
+
+/// Test: Basic P2PK with SIG_INPUTS (default mode)
+///
+/// Creates P2PK proofs with default SIG_INPUTS flag and verifies:
+/// 1. Melting without signatures is rejected
+/// 2. Melting with signatures on all proofs succeeds
+#[tokio::test]
+async fn test_p2pk_basic_sig_inputs() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for P2PK
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    println!("Alice pubkey: {}", alice_pubkey);
+
+    // Step 1: Create regular unencumbered proofs that we'll swap for P2PK proofs
+    let input_amount = Amount::from(20);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create P2PK blinded messages (outputs locked to alice_pubkey) with default SIG_INPUTS
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        None, // No additional conditions - uses default SIG_INPUTS
+    );
+    println!("Created P2PK spending conditions with default SIG_INPUTS flag");
+
+    // Split the input amount into power-of-2 denominations
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    // Create blinded messages for each split amount
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    println!(
+        "Created {} P2PK outputs locked to alice",
+        p2pk_outputs.len()
+    );
+
+    // Step 3: Swap regular proofs for P2PK proofs (no signature needed on inputs)
+    let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for P2PK proofs");
+    println!("Swap successful! Got BlindSignatures for our P2PK outputs");
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} P2PK proof(s) [{}]",
+        p2pk_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 5: Create a real melt quote that we'll use for all tests
+    let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq";
+    let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap();
+
+    let melt_quote_request = cdk_common::MeltQuoteBolt11Request {
+        request: bolt11,
+        unit: cdk_common::CurrencyUnit::Sat,
+        options: None,
+    };
+
+    let melt_quote = mint
+        .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request))
+        .await
+        .unwrap();
+    println!("Created melt quote: {}", melt_quote.quote);
+
+    // Step 6: Try to melt P2PK proof WITHOUT signature (should fail)
+    let melt_request_no_sig =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), p2pk_proofs.clone().into(), None);
+
+    let result = melt_request_no_sig.verify_spending_conditions();
+    assert!(result.is_err(), "Should fail without signature");
+    println!("✓ Melting WITHOUT signature failed verification as expected");
+
+    // Also verify the actual melt fails
+    let melt_result = mint.melt(&melt_request_no_sig).await;
+    assert!(
+        melt_result.is_err(),
+        "Actual melt should also fail without signature"
+    );
+    println!("✓ Actual melt WITHOUT signature also failed as expected");
+
+    // Step 7: Sign all proofs individually (SIG_INPUTS mode) and perform the melt
+    let mut proofs_signed = p2pk_proofs.clone();
+
+    // Sign each proof individually (SIG_INPUTS mode)
+    for proof in proofs_signed.iter_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let melt_request =
+        cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_signed.into(), None);
+
+    // Verify spending conditions pass
+    melt_request.verify_spending_conditions().unwrap();
+    println!("✓ P2PK SIG_INPUTS spending conditions verified successfully");
+
+    // Perform the actual melt - this also verifies spending conditions internally
+    let melt_response = mint.melt(&melt_request).await.unwrap();
+    println!("✓ Melt operation completed successfully!");
+    println!("  Quote state: {:?}", melt_response.state);
+    assert_eq!(melt_response.quote, melt_quote.quote);
+}

+ 4 - 32
crates/cdk/src/mint/mod.rs

@@ -10,10 +10,9 @@ use cdk_common::common::{PaymentProcessorKey, QuoteTTL};
 #[cfg(feature = "auth")]
 use cdk_common::database::DynMintAuthDatabase;
 use cdk_common::database::{self, DynMintDatabase};
-use cdk_common::nuts::{self, BlindSignature, BlindedMessage, CurrencyUnit, Id, Kind};
+use cdk_common::nuts::{BlindSignature, BlindedMessage, CurrencyUnit, Id};
 use cdk_common::payment::{DynMintPayment, WaitPaymentResponse};
 pub use cdk_common::quote_id::QuoteId;
-use cdk_common::secret;
 #[cfg(feature = "prometheus")]
 use cdk_prometheus::global;
 use cdk_signatory::signatory::{Signatory, SignatoryKeySet};
@@ -853,39 +852,12 @@ impl Mint {
     /// Verify [`Proof`] meets conditions and is signed
     #[tracing::instrument(skip_all)]
     pub async fn verify_proofs(&self, proofs: Proofs) -> Result<(), Error> {
+        // This ignore P2PK and HTLC, as all NUT-10 spending conditions are
+        // checked elsewhere.
         #[cfg(feature = "prometheus")]
         global::inc_in_flight_requests("verify_proofs");
 
-        let result = async {
-            proofs
-                .iter()
-                .map(|proof| {
-                    // Check if secret is a nut10 secret with conditions
-                    if let Ok(secret) =
-                        <&secret::Secret as TryInto<nuts::nut10::Secret>>::try_into(&proof.secret)
-                    {
-                        // Checks and verifies known secret kinds.
-                        // If it is an unknown secret kind it will be treated as a normal secret.
-                        // Spending conditions will **not** be check. It is up to the wallet to ensure
-                        // only supported secret kinds are used as there is no way for the mint to
-                        // enforce only signing supported secrets as they are blinded at
-                        // that point.
-                        match secret.kind() {
-                            Kind::P2PK => {
-                                proof.verify_p2pk()?;
-                            }
-                            Kind::HTLC => {
-                                proof.verify_htlc()?;
-                            }
-                        }
-                    }
-                    Ok(())
-                })
-                .collect::<Result<Vec<()>, Error>>()?;
-
-            self.signatory.verify_proofs(proofs).await
-        }
-        .await;
+        let result = self.signatory.verify_proofs(proofs).await;
 
         #[cfg(feature = "prometheus")]
         {

+ 12 - 15
crates/cdk/src/mint/swap/mod.rs

@@ -1,14 +1,17 @@
+use cdk_common::SpendingConditionVerification;
 #[cfg(feature = "prometheus")]
 use cdk_prometheus::METRICS;
 use swap_saga::SwapSaga;
 use tracing::instrument;
 
-use super::nut11::{enforce_sig_flag, EnforceSigFlag};
-use super::{Mint, SigFlag, SwapRequest, SwapResponse};
+use super::{Mint, SwapRequest, SwapResponse};
 use crate::Error;
 
 pub mod swap_saga;
 
+#[cfg(test)]
+mod tests;
+
 impl Mint {
     /// Process Swap
     #[instrument(skip_all)]
@@ -22,6 +25,13 @@ impl Mint {
         swap_request.input_amount()?;
         swap_request.output_amount()?;
 
+        // Verify spending conditions (NUT-10/NUT-11/NUT-14), i.e. P2PK
+        // and HTLC (including SIGALL)
+        swap_request.verify_spending_conditions()?;
+
+        // We don't need to check P2PK or HTLC again. It has all been checked above
+        // and the code doesn't reach here unless such verifications were satisfactory
+
         // Verify inputs (cryptographic verification, no DB needed)
         let input_verification =
             self.verify_inputs(swap_request.inputs())
@@ -34,9 +44,6 @@ impl Mint {
                     err
                 })?;
 
-        // Verify signature flag (no DB needed)
-        self.validate_sig_flag(&swap_request).await?;
-
         // Step 1: Initialize the swap saga
         let init_saga = SwapSaga::new(self, self.localstore.clone(), self.pubsub_manager.clone());
 
@@ -65,16 +72,6 @@ impl Mint {
         Ok(response)
     }
 
-    async fn validate_sig_flag(&self, swap_request: &SwapRequest) -> Result<(), Error> {
-        let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(swap_request.inputs().clone());
-
-        if sig_flag == SigFlag::SigAll {
-            swap_request.verify_sig_all()?;
-        }
-
-        Ok(())
-    }
-
     #[cfg(feature = "prometheus")]
     fn record_swap_failure(&self, operation: &str) {
         METRICS.dec_in_flight_requests(operation);

+ 7 - 8
crates/cdk/src/mint/swap/swap_saga/tests.rs

@@ -1,4 +1,3 @@
-#![cfg(test)]
 //! Unit tests for the swap saga implementation
 //!
 //! These tests verify the swap saga pattern using in-memory mints and databases,
@@ -933,7 +932,7 @@ async fn test_swap_saga_concurrent_swaps() {
     let task1 = tokio::spawn(async move {
         let db = mint1.localstore();
         let pubsub = mint1.pubsub_manager();
-        let saga = SwapSaga::new(&*mint1, db, pubsub);
+        let saga = SwapSaga::new(&mint1, db, pubsub);
 
         let saga = saga
             .setup_swap(&proofs1, &output_blinded_messages_1, None, verification1)
@@ -945,7 +944,7 @@ async fn test_swap_saga_concurrent_swaps() {
     let task2 = tokio::spawn(async move {
         let db = mint2.localstore();
         let pubsub = mint2.pubsub_manager();
-        let saga = SwapSaga::new(&*mint2, db, pubsub);
+        let saga = SwapSaga::new(&mint2, db, pubsub);
 
         let saga = saga
             .setup_swap(&proofs2, &output_blinded_messages_2, None, verification2)
@@ -957,7 +956,7 @@ async fn test_swap_saga_concurrent_swaps() {
     let task3 = tokio::spawn(async move {
         let db = mint3.localstore();
         let pubsub = mint3.pubsub_manager();
-        let saga = SwapSaga::new(&*mint3, db, pubsub);
+        let saga = SwapSaga::new(&mint3, db, pubsub);
 
         let saga = saga
             .setup_swap(&proofs3, &output_blinded_messages_3, None, verification3)
@@ -1976,19 +1975,19 @@ async fn test_operation_id_uniqueness_and_tracking() {
     {
         let pubsub = mint.pubsub_manager();
 
-        let saga_1 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let saga_1 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
         let _saga_1 = saga_1
             .setup_swap(&proofs_1, &outputs_1, None, verification_1)
             .await
             .expect("Swap 1 setup should succeed");
 
-        let saga_2 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let saga_2 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
         let _saga_2 = saga_2
             .setup_swap(&proofs_2, &outputs_2, None, verification_2)
             .await
             .expect("Swap 2 setup should succeed");
 
-        let saga_3 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let saga_3 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
         let _saga_3 = saga_3
             .setup_swap(&proofs_3, &outputs_3, None, verification_3)
             .await
@@ -2033,7 +2032,7 @@ async fn test_operation_id_uniqueness_and_tracking() {
     let verification = create_verification(amount);
 
     let pubsub = mint.pubsub_manager();
-    let new_saga = SwapSaga::new(&*mint, db, pubsub);
+    let new_saga = SwapSaga::new(&mint, db, pubsub);
 
     let result = new_saga
         .setup_swap(&proofs_1, &new_outputs_1, None, verification)

+ 389 - 0
crates/cdk/src/mint/swap/tests/htlc_sigall_spending_conditions_tests.rs

@@ -0,0 +1,389 @@
+//! HTLC SIG_ALL tests for swap functionality
+//!
+//! These tests verify that the mint correctly enforces SIG_ALL flag behavior for HTLC
+
+use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
+use cdk_common::Amount;
+
+use crate::test_helpers::nut10::{
+    create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper,
+};
+
+/// Test: HTLC SIG_ALL requiring preimage and one signature
+///
+/// Creates HTLC-locked proofs with SIG_ALL flag and verifies:
+/// 1. Spending with only preimage fails (signature required)
+/// 2. Spending with only signature fails (preimage required)
+/// 3. Spending with both preimage and SIG_ALL signature succeeds
+#[tokio::test]
+async fn test_htlc_sig_all_requiring_preimage_and_one_signature() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for Alice
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+
+    // Create hash and preimage
+    let (hash, preimage) = create_test_hash_and_preimage();
+
+    println!("Alice pubkey: {}", alice_pubkey);
+    println!("Hash: {}", hash);
+    println!("Preimage: {}", preimage);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create HTLC spending conditions with SIG_ALL flag (hash locked to Alice's key)
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: None,
+            num_sigs: None,            // Default (1)
+            sig_flag: SigFlag::SigAll, // <-- SIG_ALL flag
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+    println!("Created HTLC spending conditions with SIG_ALL flag");
+
+    // Step 3: Create HTLC blinded messages (outputs)
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+    println!(
+        "Created {} HTLC outputs locked to alice with hash",
+        htlc_outputs.len()
+    );
+
+    // Step 4: Swap regular proofs for HTLC proofs (no signature needed on inputs)
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for HTLC proofs");
+    println!("Swap successful! Got BlindSignatures for our HTLC outputs");
+
+    // Step 5: Construct the HTLC proofs
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = htlc_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} HTLC proof(s) [{}]",
+        htlc_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 6: Try to spend with only preimage (should fail - signature required)
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_preimage_only =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Add only preimage to first proof (no signature)
+    swap_request_preimage_only.inputs_mut()[0].add_preimage(preimage.clone());
+
+    let result = mint.process_swap_request(swap_request_preimage_only).await;
+    assert!(
+        result.is_err(),
+        "Should fail with only preimage (no signature)"
+    );
+    println!(
+        "✓ Spending with ONLY preimage failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Try to spend with only signature (should fail - preimage required)
+    let mut swap_request_signature_only =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Add only SIG_ALL signature to first proof (no preimage)
+    // Note: Must create HTLCWitness first, otherwise sign_sig_all creates P2PKWitness
+    swap_request_signature_only.inputs_mut()[0].add_preimage(String::new()); // Empty preimage
+    swap_request_signature_only
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_signature_only).await;
+    assert!(
+        result.is_err(),
+        "Should fail with only signature (no preimage)"
+    );
+    println!(
+        "✓ Spending with ONLY signature failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 8: Now try to spend with both preimage and SIG_ALL signature
+    let mut swap_request_both =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Add preimage to first proof
+    swap_request_both.inputs_mut()[0].add_preimage(preimage.clone());
+    // Add SIG_ALL signature
+    swap_request_both
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_both).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed with correct preimage and SIG_ALL signature: {:?}",
+        result.err()
+    );
+    println!("✓ HTLC SIG_ALL spent successfully with correct preimage AND signature");
+}
+
+/// Test: HTLC SIG_ALL with wrong preimage
+///
+/// Verifies that providing an incorrect preimage fails even with correct SIG_ALL signature
+#[tokio::test]
+async fn test_htlc_sig_all_wrong_preimage() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (hash, _correct_preimage) = create_test_hash_and_preimage();
+
+    // Mint regular proofs and swap for HTLC SIG_ALL proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: None,
+            num_sigs: None,
+            sig_flag: SigFlag::SigAll,
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Try to spend with WRONG preimage (but correct SIG_ALL signature)
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    let wrong_preimage = "this_is_the_wrong_preimage";
+    swap_request.inputs_mut()[0].add_preimage(wrong_preimage.to_string());
+    swap_request.sign_sig_all(alice_secret.clone()).unwrap();
+
+    let result = mint.process_swap_request(swap_request).await;
+    assert!(result.is_err(), "Should fail with wrong preimage");
+    println!(
+        "✓ HTLC SIG_ALL with wrong preimage failed as expected: {:?}",
+        result.err()
+    );
+}
+
+/// Test: HTLC SIG_ALL locktime after expiry (refund path)
+///
+/// Verifies that after locktime expires, refund keys can spend without preimage using SIG_ALL
+#[tokio::test]
+async fn test_htlc_sig_all_locktime_after_expiry() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (_alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (hash, _preimage) = create_test_hash_and_preimage();
+
+    // Create HTLC with locktime in the PAST (already expired) and Bob as refund key
+    let past_locktime = cdk_common::util::unix_time() - 1000;
+
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: Some(past_locktime),
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: Some(vec![bob_pubkey]),
+            num_sigs: None,
+            sig_flag: SigFlag::SigAll,
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // After locktime, Bob (refund key) can spend WITHOUT preimage using SIG_ALL
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Bob signs with SIG_ALL (no preimage needed after locktime)
+    // Note: Must call add_preimage first (even with empty string) to create HTLC witness
+    swap_request.inputs_mut()[0].add_preimage(String::new());
+    swap_request.sign_sig_all(bob_secret.clone()).unwrap();
+
+    let result = mint.process_swap_request(swap_request).await;
+    assert!(
+        result.is_ok(),
+        "Bob should be able to spend after locktime without preimage: {:?}",
+        result.err()
+    );
+    println!("✓ HTLC SIG_ALL spent by refund key after locktime (no preimage needed)");
+}
+
+/// Test: HTLC SIG_ALL with multisig (preimage + 2-of-3 signatures)
+///
+/// Verifies that HTLC SIG_ALL can require preimage AND multiple signatures
+#[tokio::test]
+async fn test_htlc_sig_all_multisig_2of3() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (_charlie_secret, charlie_pubkey) = create_test_keypair();
+    let (hash, preimage) = create_test_hash_and_preimage();
+
+    // Create HTLC requiring preimage + 2-of-3 signatures (Alice, Bob, Charlie) with SIG_ALL
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![alice_pubkey, bob_pubkey, charlie_pubkey]),
+            refund_keys: None,
+            num_sigs: Some(2), // Require 2 of 3
+            sig_flag: SigFlag::SigAll,
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Try with preimage + only 1 SIG_ALL signature (should fail - need 2)
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_one_sig =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    swap_request_one_sig.inputs_mut()[0].add_preimage(preimage.clone());
+    swap_request_one_sig
+        .sign_sig_all(alice_secret.clone())
+        .unwrap(); // Only Alice signs
+
+    let result = mint.process_swap_request(swap_request_one_sig).await;
+    assert!(
+        result.is_err(),
+        "Should fail with only 1 signature (need 2)"
+    );
+    println!("✓ HTLC SIG_ALL with 1-of-3 signatures failed as expected");
+
+    // Now with preimage + 2 SIG_ALL signatures (Alice and Bob) - should succeed
+    let mut swap_request_two_sigs =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    swap_request_two_sigs.inputs_mut()[0].add_preimage(preimage.clone());
+    swap_request_two_sigs
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+    swap_request_two_sigs
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_two_sigs).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed with preimage + 2-of-3 SIG_ALL signatures: {:?}",
+        result.err()
+    );
+    println!("✓ HTLC SIG_ALL spent with preimage + 2-of-3 signatures");
+}

+ 396 - 0
crates/cdk/src/mint/swap/tests/htlc_spending_conditions_tests.rs

@@ -0,0 +1,396 @@
+//! HTLC (NUT-14) tests for swap functionality
+//!
+//! These tests verify that the mint correctly validates HTLC spending conditions
+//! during swap operations, including:
+//! - Hash preimage verification
+//! - Locktime enforcement
+//! - Refund keys
+//! - Signature validation
+
+use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
+use cdk_common::Amount;
+
+use crate::test_helpers::nut10::{
+    create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper,
+};
+
+/// Test: HTLC requiring preimage and one signature
+///
+/// Creates HTLC-locked proofs and verifies:
+/// 1. Spending with only preimage fails (signature required)
+/// 2. Spending with only signature fails (preimage required)
+/// 3. Spending with both preimage and signature succeeds
+#[tokio::test]
+async fn test_htlc_requiring_preimage_and_one_signature() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for Alice
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+
+    // Create hash and preimage
+    let (hash, preimage) = create_test_hash_and_preimage();
+
+    println!("Alice pubkey: {}", alice_pubkey);
+    println!("Hash: {}", hash);
+    println!("Preimage: {}", preimage);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create HTLC spending conditions (hash locked to Alice's key)
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: None,
+            num_sigs: None, // Default (1)
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+    println!("Created HTLC spending conditions");
+
+    // Step 3: Create HTLC blinded messages (outputs)
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+    println!(
+        "Created {} HTLC outputs locked to alice with hash",
+        htlc_outputs.len()
+    );
+
+    // Step 4: Swap regular proofs for HTLC proofs (no signature needed on inputs)
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for HTLC proofs");
+    println!("Swap successful! Got BlindSignatures for our HTLC outputs");
+
+    // Step 5: Construct the HTLC proofs
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = htlc_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} HTLC proof(s) [{}]",
+        htlc_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 6: Try to spend with only preimage (should fail - signature required)
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_preimage_only =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Add only preimage (no signature)
+    for proof in swap_request_preimage_only.inputs_mut() {
+        proof.add_preimage(preimage.clone());
+    }
+
+    let result = mint.process_swap_request(swap_request_preimage_only).await;
+    assert!(
+        result.is_err(),
+        "Should fail with only preimage (no signature)"
+    );
+    println!(
+        "✓ Spending with ONLY preimage failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Try to spend with only signature (should fail - preimage required)
+    let mut swap_request_signature_only =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Add only signature (no preimage)
+    for proof in swap_request_signature_only.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_signature_only).await;
+    assert!(
+        result.is_err(),
+        "Should fail with only signature (no preimage)"
+    );
+    println!(
+        "✓ Spending with ONLY signature failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 8: Now try to spend the HTLC proofs with correct preimage + signature
+    let mut swap_request_both =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Add preimage and sign all proofs
+    for proof in swap_request_both.inputs_mut() {
+        proof.add_preimage(preimage.clone());
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_both).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed with correct preimage and signature: {:?}",
+        result.err()
+    );
+    println!("✓ HTLC spent successfully with correct preimage AND signature");
+}
+
+/// Test: HTLC with wrong preimage
+///
+/// Verifies that providing an incorrect preimage fails even with correct signature
+#[tokio::test]
+async fn test_htlc_wrong_preimage() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (hash, _correct_preimage) = create_test_hash_and_preimage();
+
+    // Mint regular proofs and swap for HTLC proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: None,
+            num_sigs: None,
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Try to spend with WRONG preimage (but correct signature)
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    let wrong_preimage = "this_is_the_wrong_preimage";
+    for proof in swap_request.inputs_mut() {
+        proof.add_preimage(wrong_preimage.to_string());
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request).await;
+    assert!(result.is_err(), "Should fail with wrong preimage");
+    println!(
+        "✓ HTLC with wrong preimage failed as expected: {:?}",
+        result.err()
+    );
+}
+
+/// Test: HTLC locktime after expiry (refund path)
+///
+/// Verifies that after locktime expires, refund keys can spend without preimage
+#[tokio::test]
+async fn test_htlc_locktime_after_expiry() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (_alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (hash, _preimage) = create_test_hash_and_preimage();
+
+    // Create HTLC with locktime in the PAST (already expired) and Bob as refund key
+    let past_locktime = cdk_common::util::unix_time() - 1000;
+
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: Some(past_locktime),
+            pubkeys: Some(vec![alice_pubkey]),
+            refund_keys: Some(vec![bob_pubkey]),
+            num_sigs: None,
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // After locktime, Bob (refund key) can spend WITHOUT preimage
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    // Bob signs (no preimage needed after locktime)
+    // Note: Must call add_preimage first (even with empty string) to create HTLC witness,
+    // otherwise sign_p2pk creates P2PK witness instead
+    for proof in swap_request.inputs_mut() {
+        proof.add_preimage(String::new()); // Empty preimage for refund path
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request).await;
+    assert!(
+        result.is_ok(),
+        "Bob should be able to spend after locktime without preimage: {:?}",
+        result.err()
+    );
+    println!("✓ HTLC spent by refund key after locktime (no preimage needed)");
+}
+
+/// Test: HTLC with multisig (preimage + 2-of-3 signatures)
+///
+/// Verifies that HTLC can require preimage AND multiple signatures
+#[tokio::test]
+async fn test_htlc_multisig_2of3() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (_charlie_secret, charlie_pubkey) = create_test_keypair();
+    let (hash, preimage) = create_test_hash_and_preimage();
+
+    // Create HTLC requiring preimage + 2-of-3 signatures (Alice, Bob, Charlie)
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    let spending_conditions = SpendingConditions::new_htlc_hash(
+        &hash,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![alice_pubkey, bob_pubkey, charlie_pubkey]),
+            refund_keys: None,
+            num_sigs: Some(2), // Require 2 of 3
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: None,
+        }),
+    )
+    .unwrap();
+
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (htlc_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    use cdk_common::dhke::construct_proofs;
+    let htlc_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Try with preimage + only 1 signature (should fail - need 2)
+    use crate::test_helpers::mint::create_test_blinded_messages;
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_one_sig =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    for proof in swap_request_one_sig.inputs_mut() {
+        proof.add_preimage(preimage.clone());
+        proof.sign_p2pk(alice_secret.clone()).unwrap(); // Only Alice signs
+    }
+
+    let result = mint.process_swap_request(swap_request_one_sig).await;
+    assert!(
+        result.is_err(),
+        "Should fail with only 1 signature (need 2)"
+    );
+    println!("✓ HTLC with 1-of-3 signatures failed as expected");
+
+    // Now with preimage + 2 signatures (Alice and Bob) - should succeed
+    let mut swap_request_two_sigs =
+        cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone());
+
+    for proof in swap_request_two_sigs.inputs_mut() {
+        proof.add_preimage(preimage.clone());
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_two_sigs).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed with preimage + 2-of-3 signatures: {:?}",
+        result.err()
+    );
+    println!("✓ HTLC spent with preimage + 2-of-3 signatures");
+}

+ 4 - 0
crates/cdk/src/mint/swap/tests/mod.rs

@@ -0,0 +1,4 @@
+mod htlc_sigall_spending_conditions_tests;
+mod htlc_spending_conditions_tests;
+mod p2pk_sigall_spending_conditions_tests;
+mod p2pk_spending_conditions_tests;

+ 1441 - 0
crates/cdk/src/mint/swap/tests/p2pk_sigall_spending_conditions_tests.rs

@@ -0,0 +1,1441 @@
+//! P2PK SIG_ALL tests for swap functionality
+//!
+//! These tests verify that the mint correctly enforces SIG_ALL flag behavior
+
+use cdk_common::dhke::construct_proofs;
+use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
+use cdk_common::Amount;
+
+use crate::test_helpers::mint::create_test_blinded_messages;
+use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
+use crate::util::unix_time;
+
+/// Test: P2PK with SIG_ALL flag requires transaction signature
+///
+/// Creates P2PK proofs with SIG_ALL flag and verifies:
+/// 1. Spending without signature is rejected
+/// 2. Spending with SIG_INPUTS signatures (individual proof signatures) is rejected
+/// 3. Spending with SIG_ALL signature (transaction signature) succeeds
+#[tokio::test]
+async fn test_p2pk_sig_all_requires_transaction_signature() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for P2PK
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    println!("Alice pubkey: {}", alice_pubkey);
+
+    // Step 1: Create regular unencumbered proofs that we'll swap for P2PK proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create P2PK blinded messages (outputs locked to alice_pubkey) with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                None,                  // no locktime
+                None,                  // no additional pubkeys
+                None,                  // no refund keys
+                None,                  // default num_sigs (1)
+                Some(SigFlag::SigAll), // SIG_ALL flag
+                None,                  // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+    println!("Created P2PK spending conditions with SIG_ALL flag");
+
+    // Split the input amount into power-of-2 denominations
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    // Create blinded messages for each split amount
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    println!(
+        "Created {} P2PK outputs locked to alice",
+        p2pk_outputs.len()
+    );
+
+    // Step 3: Swap regular proofs for P2PK proofs (no signature needed on inputs)
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for P2PK proofs");
+    println!("Swap successful! Got BlindSignatures for our P2PK outputs");
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} P2PK proof(s) [{}]",
+        p2pk_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 5: Try to spend P2PK proof WITHOUT signature (should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let swap_request_no_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    let result = mint.process_swap_request(swap_request_no_sig).await;
+    assert!(result.is_err(), "Should fail without signature");
+    println!(
+        "✓ Spending WITHOUT signature failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 6: Sign all proofs individually (SIG_INPUTS way) - should fail for SIG_ALL
+    let mut swap_request_sig_inputs =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign each proof individually (SIG_INPUTS mode)
+    for proof in swap_request_sig_inputs.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_sig_inputs).await;
+    assert!(
+        result.is_err(),
+        "Should fail - SIG_INPUTS signatures not valid for SIG_ALL"
+    );
+    println!(
+        "✓ Spending with SIG_INPUTS signatures failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Sign the transaction with SIG_ALL (should succeed)
+    let mut swap_request_with_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Use sign_sig_all to sign the transaction (signature goes on first proof's witness)
+    swap_request_with_sig
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_with_sig).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed with valid signature: {:?}",
+        result.err()
+    );
+    println!("✓ Spending WITH ALL signatures (SIG_ALL) succeeded");
+}
+
+/// Test: P2PK multisig (2-of-3) with SIG_ALL
+///
+/// Creates proofs requiring 2 signatures from a set of 3 public keys with SIG_ALL flag and verifies:
+/// 1. Spending with only 1 signature fails (Alice only)
+/// 2. Spending with 2 invalid signatures fails (wrong keys)
+/// 3. Spending with 2 valid signatures succeeds (Alice + Bob)
+#[tokio::test]
+async fn test_p2pk_sig_all_multisig_2of3() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate 3 keypairs for the multisig
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (_carol_secret, carol_pubkey) = create_test_keypair();
+
+    // Generate 2 wrong keypairs (not in the multisig set)
+    let (dave_secret, _dave_pubkey) = create_test_keypair();
+    let (eve_secret, _eve_pubkey) = create_test_keypair();
+
+    println!("Alice: {}", alice_pubkey);
+    println!("Bob: {}", bob_pubkey);
+    println!("Carol: {}", carol_pubkey);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create 2-of-3 multisig conditions with SIG_ALL
+    // Primary key: Alice
+    // Additional keys: Bob, Carol
+    // Requires 2 signatures total
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                None,                                 // no locktime
+                Some(vec![bob_pubkey, carol_pubkey]), // additional pubkeys
+                None,                                 // no refund keys
+                Some(2),                              // require 2 signatures
+                Some(SigFlag::SigAll),                // SIG_ALL flag
+                None,                                 // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+    println!("Created 2-of-3 multisig spending conditions with SIG_ALL (Alice, Bob, Carol)");
+
+    // Step 3: Create P2PK blinded messages with multisig conditions
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK multisig proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+    println!("Created P2PK multisig proofs (2-of-3) with SIG_ALL");
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with only 1 signature (Alice only - should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_one_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with only Alice (SIG_ALL mode)
+    swap_request_one_sig
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_one_sig).await;
+    assert!(
+        result.is_err(),
+        "Should fail with only 1 signature (need 2)"
+    );
+    println!(
+        "✓ Spending with only 1 signature (Alice) failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Try to spend with 2 invalid signatures (Dave + Eve - not in multisig set)
+    let mut swap_request_invalid_sigs =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Dave and Eve (wrong keys!) - add signatures one at a time
+    swap_request_invalid_sigs
+        .sign_sig_all(dave_secret.clone())
+        .unwrap();
+    swap_request_invalid_sigs
+        .sign_sig_all(eve_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_invalid_sigs).await;
+    assert!(result.is_err(), "Should fail with 2 invalid signatures");
+    println!(
+        "✓ Spending with 2 INVALID signatures (Dave + Eve) failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 8: Spend with 2 valid signatures (Alice + Bob - should succeed)
+    let mut swap_request_valid_sigs =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice and Bob - add signatures one at a time
+    swap_request_valid_sigs
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+    swap_request_valid_sigs
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+
+    // print the json serializiation of this final swap. It should succeed
+    // as it has sufficient signatures
+    println!(
+        "{}",
+        serde_json::to_string_pretty(&swap_request_valid_sigs.clone()).unwrap()
+    );
+
+    let result = mint.process_swap_request(swap_request_valid_sigs).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed with 2 valid signatures: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with 2 VALID signatures (Alice + Bob) succeeded");
+}
+
+/// Test: P2PK with SIG_ALL signed by wrong person is rejected
+///
+/// Creates proofs locked to Alice's public key with SIG_ALL flag and verifies that
+/// signing with Bob's key (wrong key) is rejected
+#[tokio::test]
+async fn test_p2pk_sig_all_signed_by_wrong_person() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypairs for Alice and Bob
+    let (_alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, _bob_pubkey) = create_test_keypair();
+    println!("Alice pubkey: {}", alice_pubkey);
+    println!("Bob will try to spend Alice's proofs");
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create P2PK blinded messages locked to Alice's pubkey with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                None,                  // no locktime
+                None,                  // no additional pubkeys
+                None,                  // no refund keys
+                None,                  // default num_sigs (1)
+                Some(SigFlag::SigAll), // SIG_ALL flag
+                None,                  // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 3: Swap for P2PK proofs locked to Alice
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+    println!("Created P2PK proofs locked to Alice with SIG_ALL");
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 5: Try to spend Alice's proofs by signing with Bob's key (wrong key!)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_wrong_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Bob's key instead of Alice's key (SIG_ALL mode)
+    swap_request_wrong_sig
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_wrong_sig).await;
+    assert!(result.is_err(), "Should fail when signed with wrong key");
+    println!(
+        "✓ Spending signed by wrong person failed as expected: {:?}",
+        result.err()
+    );
+}
+
+/// Test: Duplicate signatures are rejected (SIG_ALL)
+///
+/// Verifies that using the same signature twice doesn't count as multiple signers
+/// in a 2-of-2 multisig scenario with SIG_ALL flag
+#[tokio::test]
+async fn test_p2pk_sig_all_duplicate_signatures() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (_bob_secret, bob_pubkey) = create_test_keypair();
+
+    println!("Alice: {}", alice_pubkey);
+    println!("Bob: {}", bob_pubkey);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create 2-of-2 multisig (Alice and Bob, need both) with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                None,                   // no locktime
+                Some(vec![bob_pubkey]), // Bob is additional pubkey
+                None,                   // no refund keys
+                Some(2),                // require 2 signatures (Alice + Bob)
+                Some(SigFlag::SigAll),  // SIG_ALL flag
+                None,                   // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+    println!("Created 2-of-2 multisig (Alice, Bob) with SIG_ALL");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with Alice's signature TWICE (should fail - need Alice + Bob, not Alice + Alice)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_duplicate =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice twice instead of Alice + Bob (SIG_ALL mode)
+    swap_request_duplicate
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+    swap_request_duplicate
+        .sign_sig_all(alice_secret.clone())
+        .unwrap(); // Duplicate!
+
+    let result = mint.process_swap_request(swap_request_duplicate).await;
+    assert!(
+        result.is_err(),
+        "Should fail - duplicate signatures not allowed"
+    );
+    println!(
+        "✓ Spending with duplicate signatures (Alice + Alice) failed as expected: {:?}",
+        result.err()
+    );
+}
+
+/// Test: P2PK with locktime (before expiry) - SIG_ALL
+///
+/// Verifies that before locktime expires with SIG_ALL:
+/// 1. Spending with primary key (Alice) succeeds
+/// 2. Spending with refund key (Bob) fails
+#[tokio::test]
+async fn test_p2pk_sig_all_locktime_before_expiry() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+
+    // Set locktime 1 hour in the future
+    let locktime = unix_time() + 3600;
+
+    println!("Alice (primary): {}", alice_pubkey);
+    println!("Bob (refund): {}", bob_pubkey);
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expires in 1 hour)", locktime);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create conditions with Alice as primary and Bob as refund key with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                Some(locktime),         // locktime in the future
+                None,                   // no additional pubkeys
+                Some(vec![bob_pubkey]), // Bob is refund key
+                None,                   // default num_sigs (1)
+                Some(SigFlag::SigAll),  // SIG_ALL flag
+                None,                   // default num_sigs_refund (1)
+            )
+            .unwrap(),
+        ),
+    );
+    println!("Created P2PK with locktime and refund key with SIG_ALL");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with refund key (Bob) BEFORE locktime expires (should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_refund =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Bob (refund key) using SIG_ALL
+    swap_request_refund
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_refund).await;
+    assert!(
+        result.is_err(),
+        "Should fail - refund key cannot spend before locktime"
+    );
+    println!(
+        "✓ Spending with refund key (Bob) BEFORE locktime failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Spend with primary key (Alice) BEFORE locktime (should succeed)
+    let mut swap_request_primary =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice (primary key) using SIG_ALL
+    swap_request_primary
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_primary).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - primary key can spend before locktime: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with primary key (Alice) BEFORE locktime succeeded");
+}
+
+/// Test: P2PK with locktime (after expiry) - SIG_ALL
+///
+/// Verifies that after locktime expires with SIG_ALL:
+/// 1. Spending with primary key (Alice) fails
+/// 2. Spending with refund key (Bob) succeeds
+#[tokio::test]
+async fn test_p2pk_sig_all_locktime_after_expiry() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+
+    // Set locktime in the past (already expired)
+    let locktime = unix_time() - 3600;
+
+    println!("Alice (primary): {}", alice_pubkey);
+    println!("Bob (refund): {}", bob_pubkey);
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expired 1 hour ago)", locktime);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create conditions with Alice as primary and Bob as refund key with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime),            // locktime in the past (expired)
+            pubkeys: None,                       // no additional pubkeys
+            refund_keys: Some(vec![bob_pubkey]), // Bob is refund key
+            num_sigs: None,                      // default (1)
+            sig_flag: SigFlag::SigAll,           // SIG_ALL flag
+            num_sigs_refund: None,               // default (1)
+        }),
+    );
+    println!("Created P2PK with expired locktime and refund key with SIG_ALL");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with primary key (Alice) AFTER locktime expires (should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_primary =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice (primary key) using SIG_ALL
+    swap_request_primary
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_primary).await;
+    assert!(
+        result.is_err(),
+        "Should fail - primary key cannot spend after locktime expires"
+    );
+    println!(
+        "✓ Spending with primary key (Alice) AFTER locktime failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Spend with refund key (Bob) AFTER locktime (should succeed)
+    let mut swap_request_refund =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Bob (refund key) using SIG_ALL
+    swap_request_refund
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_refund).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - refund key can spend after locktime: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with refund key (Bob) AFTER locktime succeeded");
+}
+
+/// Test: P2PK with locktime after expiry, no refund keys (anyone can spend) - SIG_ALL
+///
+/// Verifies that after locktime expires with NO refund keys configured and SIG_ALL,
+/// anyone can spend the proofs without providing any signatures at all.
+#[tokio::test]
+async fn test_p2pk_sig_all_locktime_after_expiry_no_refund_anyone_can_spend() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (_alice_secret, alice_pubkey) = create_test_keypair();
+
+    // Set locktime in the past (already expired)
+    let locktime = unix_time() - 3600;
+
+    println!("Alice (primary): {}", alice_pubkey);
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expired 1 hour ago)", locktime);
+    println!("No refund keys configured - anyone can spend after locktime");
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create conditions with Alice as primary, NO refund keys, with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime),  // locktime in the past (expired)
+            pubkeys: None,             // no additional pubkeys
+            refund_keys: None,         // NO refund keys - anyone can spend!
+            num_sigs: None,            // default (1)
+            sig_flag: SigFlag::SigAll, // SIG_ALL flag
+            num_sigs_refund: None,     // default (1)
+        }),
+    );
+    println!("Created P2PK with expired locktime, NO refund keys, and SIG_ALL");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Spend WITHOUT any signatures (should succeed - anyone can spend!)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let swap_request_no_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // No signatures added at all!
+
+    let result = mint.process_swap_request(swap_request_no_sig).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - anyone can spend after locktime with no refund keys: {:?}",
+        result.err()
+    );
+    println!("✓ Spending WITHOUT any signatures succeeded (anyone can spend)");
+}
+
+/// Test: P2PK multisig with locktime (2-of-3 before, 1-of-2 after) - SIG_ALL
+///
+/// Complex scenario with SIG_ALL: Different multisig requirements before and after locktime
+/// Before locktime: Need 2-of-3 from (Alice, Bob, Carol)
+/// After locktime: Need 1-of-2 from (Dave, Eve) as refund keys
+#[tokio::test]
+async fn test_p2pk_sig_all_multisig_locktime() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Before locktime: Need 2-of-3 from (Alice, Bob, Carol)
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (_carol_secret, carol_pubkey) = create_test_keypair();
+
+    // After locktime: Need 1-of-2 from (Dave, Eve) as refund keys
+    let (dave_secret, dave_pubkey) = create_test_keypair();
+    let (_eve_secret, eve_pubkey) = create_test_keypair();
+
+    let locktime = unix_time() - 100; // Already expired
+
+    println!("Primary multisig: Alice, Bob, Carol (need 2-of-3)");
+    println!("Refund multisig: Dave, Eve (need 1-of-2)");
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expired)", locktime);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create complex conditions with SIG_ALL
+    // Before locktime: 2-of-3 (Alice, Bob, Carol)
+    // After locktime: 1-of-2 (Dave, Eve)
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime),                         // Already expired
+            pubkeys: Some(vec![bob_pubkey, carol_pubkey]), // Bob and Carol (with Alice = 3 total)
+            refund_keys: Some(vec![dave_pubkey, eve_pubkey]), // Dave and Eve for refund
+            num_sigs: Some(2),                             // Need 2 signatures before locktime
+            sig_flag: SigFlag::SigAll,                     // SIG_ALL flag
+            num_sigs_refund: Some(1),                      // Need 1 signature after locktime
+        }),
+    );
+    println!("Created complex P2PK with SIG_ALL: 2-of-3 before locktime, 1-of-2 after locktime");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with primary keys (Alice + Bob) AFTER locktime (should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_primary =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice + Bob (primary multisig) using SIG_ALL
+    swap_request_primary
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+    swap_request_primary
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_primary).await;
+    assert!(
+        result.is_err(),
+        "Should fail - locktime expired, only refund keys valid"
+    );
+    println!(
+        "✓ Spending with primary keys (Alice + Bob) AFTER locktime failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Spend with refund key (Dave) AFTER locktime (should succeed - only need 1-of-2)
+    let mut swap_request_refund =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Dave only (refund key, need 1-of-2) using SIG_ALL
+    swap_request_refund
+        .sign_sig_all(dave_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_refund).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - refund key can spend after locktime: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with refund key (Dave, 1-of-2) AFTER locktime succeeded");
+}
+
+/// Test: SIG_ALL with mixed proofs (different data) should fail
+///
+/// Per NUT-11, when any proof has SIG_ALL, all proofs must have:
+/// 1. Same kind, 2. SIG_ALL flag, 3. Same data, 4. Same tags
+/// This test verifies that mixing proofs with different pubkeys (different data) is rejected.
+#[tokio::test]
+async fn test_p2pk_sig_all_mixed_proofs_different_data() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Create two different keypairs
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+
+    println!("Alice pubkey: {}", alice_pubkey);
+    println!("Bob pubkey: {}", bob_pubkey);
+
+    // Step 1: Mint regular proofs for Alice
+    let alice_input_amount = Amount::from(10);
+    let alice_input_proofs = test_mint.mint_proofs(alice_input_amount).await.unwrap();
+
+    // Step 2: Create Alice's P2PK spending conditions with SIG_ALL
+    let alice_spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: None,
+            refund_keys: None,
+            num_sigs: None,
+            sig_flag: SigFlag::SigAll,
+            num_sigs_refund: None,
+        }),
+    );
+
+    // Step 3: Swap for Alice's P2PK proofs
+    let alice_split_amounts = test_mint.split_amount(alice_input_amount).unwrap();
+    let (alice_outputs, alice_blinding_factors, alice_secrets) = unzip3(
+        alice_split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &alice_spending_conditions))
+            .collect(),
+    );
+
+    let swap_request_alice =
+        cdk_common::nuts::SwapRequest::new(alice_input_proofs, alice_outputs.clone());
+    let swap_response_alice = mint.process_swap_request(swap_request_alice).await.unwrap();
+
+    let alice_proofs = construct_proofs(
+        swap_response_alice.signatures.clone(),
+        alice_blinding_factors.clone(),
+        alice_secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    println!(
+        "Created {} Alice proofs (locked to Alice with SIG_ALL)",
+        alice_proofs.len()
+    );
+
+    // Step 4: Mint regular proofs for Bob
+    let bob_input_amount = Amount::from(10);
+    let bob_input_proofs = test_mint.mint_proofs(bob_input_amount).await.unwrap();
+
+    // Step 5: Create Bob's P2PK spending conditions with SIG_ALL (different data!)
+    let bob_spending_conditions = SpendingConditions::new_p2pk(
+        bob_pubkey,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: None,
+            refund_keys: None,
+            num_sigs: None,
+            sig_flag: SigFlag::SigAll,
+            num_sigs_refund: None,
+        }),
+    );
+
+    // Step 6: Swap for Bob's P2PK proofs
+    let bob_split_amounts = test_mint.split_amount(bob_input_amount).unwrap();
+    let (bob_outputs, bob_blinding_factors, bob_secrets) = unzip3(
+        bob_split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &bob_spending_conditions))
+            .collect(),
+    );
+
+    let swap_request_bob =
+        cdk_common::nuts::SwapRequest::new(bob_input_proofs, bob_outputs.clone());
+    let swap_response_bob = mint.process_swap_request(swap_request_bob).await.unwrap();
+
+    let bob_proofs = construct_proofs(
+        swap_response_bob.signatures.clone(),
+        bob_blinding_factors.clone(),
+        bob_secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    println!(
+        "Created {} Bob proofs (locked to Bob with SIG_ALL)",
+        bob_proofs.len()
+    );
+
+    // Step 7: Try to spend Alice's and Bob's proofs together in one transaction (should FAIL!)
+    // This violates NUT-11 requirement that all SIG_ALL proofs must have same data
+    let total_amount = alice_input_amount + bob_input_amount;
+    let (new_outputs, _) = create_test_blinded_messages(mint, total_amount)
+        .await
+        .unwrap();
+
+    let mut mixed_proofs = alice_proofs.clone();
+    mixed_proofs.extend(bob_proofs.clone());
+
+    let mut swap_request_mixed =
+        cdk_common::nuts::SwapRequest::new(mixed_proofs, new_outputs.clone());
+
+    // Sign with both Alice's and Bob's keys (no client-side validation, so this succeeds)
+    swap_request_mixed
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+    swap_request_mixed.sign_sig_all(bob_secret.clone()).unwrap();
+
+    // But the mint should reject it due to mismatched data, even though both signed
+    let result = mint.process_swap_request(swap_request_mixed).await;
+    assert!(result.is_err(), "Should fail - cannot mix proofs with different data in SIG_ALL transaction, even with both signatures");
+
+    let error_msg = format!("{:?}", result.err().unwrap());
+    println!(
+        "✓ Mixing Alice and Bob proofs in SIG_ALL transaction failed at mint verification: {}",
+        error_msg
+    );
+
+    // Step 8: Alice should be able to spend her proofs alone (should succeed)
+    let (alice_new_outputs, _) = create_test_blinded_messages(mint, alice_input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_alice_only =
+        cdk_common::nuts::SwapRequest::new(alice_proofs.clone(), alice_new_outputs.clone());
+    swap_request_alice_only
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_alice_only).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - Alice spending her own proofs: {:?}",
+        result.err()
+    );
+    println!("✓ Alice successfully spent her own proofs separately");
+
+    // Step 9: Bob should be able to spend his proofs alone (should succeed)
+    let (bob_new_outputs, _) = create_test_blinded_messages(mint, bob_input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_bob_only =
+        cdk_common::nuts::SwapRequest::new(bob_proofs.clone(), bob_new_outputs.clone());
+    swap_request_bob_only
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_bob_only).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - Bob spending his own proofs: {:?}",
+        result.err()
+    );
+    println!("✓ Bob successfully spent his own proofs separately");
+}
+
+/// Test: P2PK multisig BEFORE locktime expires (2-of-3) - SIG_ALL
+///
+/// Tests that a 2-of-3 multisig with SIG_ALL works correctly BEFORE locktime expires.
+/// This complements the existing test that verifies refund keys work AFTER locktime.
+#[tokio::test]
+async fn test_p2pk_sig_all_multisig_before_locktime() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Create 3 keypairs for primary multisig (Alice, Bob, Carol)
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (_carol_secret, carol_pubkey) = create_test_keypair();
+
+    // Create refund keys (Dave, Eve) - won't be used since we're before locktime
+    let (_dave_secret, dave_pubkey) = create_test_keypair();
+    let (_eve_secret, eve_pubkey) = create_test_keypair();
+
+    let locktime = unix_time() + 3600; // Locktime is 1 hour in the future
+
+    println!("Primary multisig: Alice, Bob, Carol (need 2-of-3)");
+    println!("Refund multisig: Dave, Eve (need 1-of-2)");
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expires in 1 hour)", locktime);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create complex conditions with SIG_ALL
+    // Before locktime: 2-of-3 (Alice, Bob, Carol)
+    // After locktime: 1-of-2 (Dave, Eve)
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime),                         // 1 hour in the future
+            pubkeys: Some(vec![bob_pubkey, carol_pubkey]), // Bob and Carol (with Alice = 3 total)
+            refund_keys: Some(vec![dave_pubkey, eve_pubkey]), // Dave and Eve for refund
+            num_sigs: Some(2),                             // Need 2 signatures before locktime
+            sig_flag: SigFlag::SigAll,                     // SIG_ALL flag
+            num_sigs_refund: Some(1),                      // Need 1 signature after locktime
+        }),
+    );
+    println!("Created complex P2PK with SIG_ALL: 2-of-3 before locktime, 1-of-2 after locktime");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with only 1 signature (Alice) BEFORE locktime (should fail - need 2-of-3)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_one_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice only (need 2-of-3)
+    swap_request_one_sig
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_one_sig).await;
+    assert!(
+        result.is_err(),
+        "Should fail - need 2-of-3 signatures before locktime"
+    );
+    println!(
+        "✓ Spending with only 1 signature (Alice) BEFORE locktime failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Spend with 2 signatures (Alice + Bob) BEFORE locktime (should succeed - 2-of-3)
+    let mut swap_request_two_sigs =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice + Bob (2-of-3, should succeed)
+    swap_request_two_sigs
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+    swap_request_two_sigs
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_two_sigs).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - 2-of-3 signatures before locktime: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with 2 signatures (Alice + Bob, 2-of-3) BEFORE locktime succeeded");
+}
+
+/// Test: P2PK with more signatures than required - SIG_ALL
+///
+/// Tests that providing MORE valid signatures than required succeeds.
+/// For example, 3 valid signatures for a 2-of-3 multisig should work fine.
+#[tokio::test]
+async fn test_p2pk_sig_all_more_signatures_than_required() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Create 3 keypairs for multisig (Alice, Bob, Carol)
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (carol_secret, carol_pubkey) = create_test_keypair();
+
+    println!("Multisig: Alice, Bob, Carol (need 2-of-3)");
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create 2-of-3 multisig conditions with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: None,
+            pubkeys: Some(vec![bob_pubkey, carol_pubkey]), // Bob and Carol (with Alice = 3 total)
+            refund_keys: None,
+            num_sigs: Some(2), // Need 2 signatures (but we'll provide 3)
+            sig_flag: SigFlag::SigAll,
+            num_sigs_refund: None,
+        }),
+    );
+    println!("Created 2-of-3 multisig with SIG_ALL");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Spend with ALL 3 signatures (Alice + Bob + Carol) even though only 2 required
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_all_sigs =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with all 3 keys (more than the required 2-of-3)
+    swap_request_all_sigs
+        .sign_sig_all(alice_secret.clone())
+        .unwrap();
+    swap_request_all_sigs
+        .sign_sig_all(bob_secret.clone())
+        .unwrap();
+    swap_request_all_sigs
+        .sign_sig_all(carol_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_all_sigs).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - 3 valid signatures when only 2-of-3 required: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with 3 signatures (all of Alice, Bob, Carol) when only 2-of-3 required succeeded");
+}
+
+/// Test: P2PK with 2-of-2 refund multisig after locktime - SIG_ALL
+///
+/// Tests that after locktime expires, BOTH refund signatures are required (2-of-2).
+/// Verifies that 1-of-2 fails and 2-of-2 succeeds.
+#[tokio::test]
+async fn test_p2pk_sig_all_refund_multisig_2of2() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Primary key (Alice)
+    let (_alice_secret, alice_pubkey) = create_test_keypair();
+
+    // Refund keys (Dave, Eve) - need both after locktime
+    let (dave_secret, dave_pubkey) = create_test_keypair();
+    let (eve_secret, eve_pubkey) = create_test_keypair();
+
+    let locktime = unix_time() - 3600; // Already expired (1 hour ago)
+
+    println!("Alice (primary)");
+    println!("Dave, Eve (refund, need 2-of-2)");
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expired 1 hour ago)", locktime);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create conditions with 2-of-2 refund multisig and SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime), // Already expired
+            pubkeys: None,
+            refund_keys: Some(vec![dave_pubkey, eve_pubkey]), // Dave and Eve for refund
+            num_sigs: None,                                   // Default (1) for primary
+            sig_flag: SigFlag::SigAll,
+            num_sigs_refund: Some(2), // Need BOTH refund signatures (2-of-2)
+        }),
+    );
+    println!("Created P2PK with SIG_ALL: 2-of-2 refund multisig after locktime");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with only Dave's signature (1-of-2, should fail - need 2-of-2)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_one_refund =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Dave only (need both Dave and Eve)
+    swap_request_one_refund
+        .sign_sig_all(dave_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_one_refund).await;
+    assert!(
+        result.is_err(),
+        "Should fail - need 2-of-2 refund signatures"
+    );
+    println!(
+        "✓ Spending with only 1 refund signature (Dave) AFTER locktime failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Spend with both Dave and Eve (2-of-2, should succeed)
+    let mut swap_request_both_refunds =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with both Dave and Eve (2-of-2 refund multisig)
+    swap_request_both_refunds
+        .sign_sig_all(dave_secret.clone())
+        .unwrap();
+    swap_request_both_refunds
+        .sign_sig_all(eve_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_both_refunds).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - 2-of-2 refund signatures after locktime: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with 2-of-2 refund signatures (Dave + Eve) AFTER locktime succeeded");
+}
+
+/// Test: SIG_ALL should reject if output amounts are swapped
+///
+/// Creates two P2PK proofs (8+2 sats) with SIG_ALL flag, swaps the output amounts
+/// after signing, and verifies that the mint should reject this (but currently doesn't).
+#[tokio::test]
+async fn test_sig_all_should_reject_if_the_output_amounts_are_swapped() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for Alice
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    println!("Alice pubkey: {}", alice_pubkey);
+
+    // Step 1: Mint regular proofs (10 sats = 8+2)
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+    println!("Minted {} sats", input_amount);
+
+    // Step 2: Create P2PK spending conditions with SIG_ALL
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                None,                  // no locktime
+                None,                  // no additional pubkeys
+                None,                  // no refund keys
+                None,                  // default num_sigs (1)
+                Some(SigFlag::SigAll), // SIG_ALL flag
+                None,                  // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+
+    // Step 3: Swap for P2PK proofs with SIG_ALL
+    let split_amounts = vec![Amount::from(8), Amount::from(2)];
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    let swap_request = cdk_common::nuts::SwapRequest::new(input_proofs, p2pk_outputs);
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures,
+        blinding_factors,
+        secrets,
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    println!("Created {} P2PK proofs with SIG_ALL", p2pk_proofs.len());
+    assert_eq!(p2pk_proofs.len(), 2, "Should have 2 proofs (8+2)");
+
+    // Step 5: Create new swap request and sign with SIG_ALL
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request = cdk_common::nuts::SwapRequest::new(p2pk_proofs, new_outputs);
+
+    // Inspect the outputs
+    println!("Outputs in swap request:");
+    for (i, output) in swap_request.outputs().iter().enumerate() {
+        println!(
+            "  Output {}: amount={}, blinded_secret={}",
+            i,
+            output.amount,
+            output.blinded_secret.to_hex()
+        );
+    }
+
+    // Sign the transaction with SIG_ALL
+    swap_request.sign_sig_all(alice_secret).unwrap();
+
+    // Swap the amounts of the two outputs
+    let outputs = swap_request.outputs_mut();
+    let temp_amount = outputs[0].amount;
+    outputs[0].amount = outputs[1].amount;
+    outputs[1].amount = temp_amount;
+
+    // Print outputs after swapping amounts
+    println!("Outputs after swapping amounts:");
+    for (i, output) in swap_request.outputs().iter().enumerate() {
+        println!(
+            "  Output {}: amount={}, blinded_secret={}",
+            i,
+            output.amount,
+            output.blinded_secret.to_hex()
+        );
+    }
+
+    // Step 6: Try to execute the swap - should now FAIL because the signature is invalid
+    let result = mint.process_swap_request(swap_request.clone()).await;
+    assert!(
+        result.is_err(),
+        "Swap should fail - amounts were tampered with after signing"
+    );
+    println!("✓ Swap correctly rejected after output amounts were swapped!");
+    println!("  Error: {:?}", result.err());
+
+    // Step 7: Swap the amounts back to original and verify it succeeds
+    let outputs = swap_request.outputs_mut();
+    let temp_amount = outputs[0].amount;
+    outputs[0].amount = outputs[1].amount;
+    outputs[1].amount = temp_amount;
+
+    println!("Outputs after swapping back to original:");
+    for (i, output) in swap_request.outputs().iter().enumerate() {
+        println!(
+            "  Output {}: amount={}, blinded_secret={}",
+            i,
+            output.amount,
+            output.blinded_secret.to_hex()
+        );
+    }
+
+    let result = mint.process_swap_request(swap_request).await;
+    assert!(
+        result.is_ok(),
+        "Swap should succeed with original amounts: {:?}",
+        result.err()
+    );
+    println!("✓ Swap succeeded after restoring original amounts!");
+}

+ 804 - 0
crates/cdk/src/mint/swap/tests/p2pk_spending_conditions_tests.rs

@@ -0,0 +1,804 @@
+//! P2PK (NUT-11) tests for swap functionality
+//!
+//! These tests verify that the mint correctly validates P2PK spending conditions
+//! during swap operations, including:
+//! - Single signature P2PK
+//! - Multisig (m-of-n)
+//! - Locktime enforcement
+//! - Refund keys
+//! - Signature validation
+
+use cdk_common::dhke::construct_proofs;
+use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions};
+use cdk_common::Amount;
+
+use crate::test_helpers::mint::create_test_blinded_messages;
+use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper};
+use crate::util::unix_time;
+
+/// Test: P2PK with single pubkey requires all proofs signed
+///
+/// Creates proofs locked to a single public key and verifies:
+/// 1. Spending without any signatures is rejected
+/// 2. Spending with partial signatures (only some proofs signed) is rejected
+/// 3. Spending with all proofs signed succeeds
+#[tokio::test]
+async fn test_p2pk_single_pubkey_requires_all_proofs_signed() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypair for P2PK
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    println!("Alice pubkey: {}", alice_pubkey);
+
+    // Step 1: Create regular unencumbered proofs that we'll swap for P2PK proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create P2PK blinded messages (outputs locked to alice_pubkey)
+    let spending_conditions = SpendingConditions::new_p2pk(alice_pubkey, None);
+
+    // Split the input amount into power-of-2 denominations
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+    println!("Split {} into [{}]", input_amount, split_display.join("+"));
+
+    // Create blinded messages for each split amount
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    println!(
+        "Created {} P2PK outputs locked to alice",
+        p2pk_outputs.len()
+    );
+
+    // Step 3: Swap regular proofs for P2PK proofs (no signature needed on inputs)
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Failed to swap for P2PK proofs");
+    println!("Swap successful! Got BlindSignatures for our P2PK outputs");
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    let proof_amounts: Vec<String> = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect();
+    println!(
+        "Constructed {} P2PK proof(s) [{}]",
+        p2pk_proofs.len(),
+        proof_amounts.join("+")
+    );
+
+    // Step 5: Try to spend P2PK proof WITHOUT signature (should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let swap_request_no_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    let result = mint.process_swap_request(swap_request_no_sig).await;
+    assert!(result.is_err(), "Should fail without signature");
+    println!(
+        "✓ Spending WITHOUT signature failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 6: Sign only ONE of the proofs and try (should fail - need all signatures)
+    let mut swap_request_partial_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign only the first proof
+    swap_request_partial_sig.inputs_mut()[0]
+        .sign_p2pk(alice_secret.clone())
+        .unwrap();
+
+    let result = mint.process_swap_request(swap_request_partial_sig).await;
+    assert!(result.is_err(), "Should fail with only partial signatures");
+    println!(
+        "✓ Spending with PARTIAL signatures failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Now sign ALL the proofs and try again (should succeed)
+    let mut swap_request_with_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign all the P2PK proofs with Alice's key
+    for proof in swap_request_with_sig.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_with_sig).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed with valid signature: {:?}",
+        result.err()
+    );
+    println!("✓ Spending WITH ALL signatures succeeded");
+}
+
+/// Test: P2PK multisig (2-of-3)
+///
+/// Creates proofs requiring 2 signatures from a set of 3 public keys and verifies:
+/// 1. Spending with only 1 valid signature fails (Alice only)
+/// 2. Spending with 2 invalid signatures fails (wrong keys)
+/// 3. Spending with 2 valid signatures succeeds (Alice + Bob)
+#[tokio::test]
+async fn test_p2pk_multisig_2of3() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate 3 keypairs for the multisig
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (_carol_secret, carol_pubkey) = create_test_keypair();
+
+    // Generate 2 wrong keypairs (not in the multisig set)
+    let (dave_secret, _dave_pubkey) = create_test_keypair();
+    let (eve_secret, _eve_pubkey) = create_test_keypair();
+
+    println!("Alice: {}", alice_pubkey);
+    println!("Bob: {}", bob_pubkey);
+    println!("Carol: {}", carol_pubkey);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create 2-of-3 multisig conditions
+    // Primary key: Alice
+    // Additional keys: Bob, Carol
+    // Requires 2 signatures total
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                None,                                 // no locktime
+                Some(vec![bob_pubkey, carol_pubkey]), // additional pubkeys
+                None,                                 // no refund keys
+                Some(2),                              // require 2 signatures
+                None,                                 // default sig_flag
+                None,                                 // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+    println!("Created 2-of-3 multisig spending conditions (Alice, Bob, Carol)");
+
+    // Step 3: Create P2PK blinded messages with multisig conditions
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK multisig proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+    println!("Created P2PK multisig proofs (2-of-3)");
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with only 1 signature (Alice only - should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_one_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with only Alice
+    for proof in swap_request_one_sig.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_one_sig).await;
+    assert!(
+        result.is_err(),
+        "Should fail with only 1 signature (need 2)"
+    );
+    println!(
+        "✓ Spending with only 1 signature (Alice) failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Try to spend with 2 invalid signatures (Dave + Eve - not in multisig set)
+    let mut swap_request_invalid_sigs =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Dave and Eve (wrong keys!)
+    for proof in swap_request_invalid_sigs.inputs_mut() {
+        proof.sign_p2pk(dave_secret.clone()).unwrap();
+        proof.sign_p2pk(eve_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_invalid_sigs).await;
+    assert!(result.is_err(), "Should fail with 2 invalid signatures");
+    println!(
+        "✓ Spending with 2 INVALID signatures (Dave + Eve) failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 8: Spend with 2 valid signatures (Alice + Bob - should succeed)
+    let mut swap_request_valid_sigs =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice and Bob
+    for proof in swap_request_valid_sigs.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_valid_sigs).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed with 2 valid signatures: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with 2 VALID signatures (Alice + Bob) succeeded");
+}
+
+/// Test: P2PK with locktime (before expiry)
+///
+/// Verifies that before locktime expires:
+/// 1. Spending with primary key (Alice) succeeds
+/// 2. Spending with refund key (Bob) fails
+#[tokio::test]
+async fn test_p2pk_locktime_before_expiry() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+
+    // Set locktime 1 hour in the future
+    let locktime = unix_time() + 3600;
+
+    println!("Alice (primary): {}", alice_pubkey);
+    println!("Bob (refund): {}", bob_pubkey);
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expires in 1 hour)", locktime);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create conditions with Alice as primary and Bob as refund key
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                Some(locktime),         // locktime in the future
+                None,                   // no additional pubkeys
+                Some(vec![bob_pubkey]), // Bob is refund key
+                None,                   // default num_sigs (1)
+                None,                   // default sig_flag
+                None,                   // default num_sigs_refund (1)
+            )
+            .unwrap(),
+        ),
+    );
+    println!("Created P2PK with locktime and refund key");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with refund key (Bob) BEFORE locktime expires (should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_refund =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Bob (refund key)
+    for proof in swap_request_refund.inputs_mut() {
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_refund).await;
+    assert!(
+        result.is_err(),
+        "Should fail - refund key cannot spend before locktime"
+    );
+    println!(
+        "✓ Spending with refund key (Bob) BEFORE locktime failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Spend with primary key (Alice) BEFORE locktime (should succeed)
+    let mut swap_request_primary =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice (primary key)
+    for proof in swap_request_primary.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_primary).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - primary key can spend before locktime: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with primary key (Alice) BEFORE locktime succeeded");
+}
+
+/// Test: P2PK with locktime (after expiry)
+///
+/// Verifies that after locktime expires:
+/// 1. Spending with refund key (Bob) succeeds
+/// 2. Spending with primary key (Alice) fails
+#[tokio::test]
+async fn test_p2pk_locktime_after_expiry() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+
+    // Set locktime in the past (already expired)
+    let locktime = unix_time() - 3600;
+
+    println!("Alice (primary): {}", alice_pubkey);
+    println!("Bob (refund): {}", bob_pubkey);
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expired 1 hour ago)", locktime);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create conditions with Alice as primary and Bob as refund key
+    // Note: We create the Conditions struct directly to bypass the validation
+    // that rejects locktimes in the past (since we're testing the expired case)
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime),            // locktime in the past (expired)
+            pubkeys: None,                       // no additional pubkeys
+            refund_keys: Some(vec![bob_pubkey]), // Bob is refund key
+            num_sigs: None,                      // default (1)
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: None, // default (1)
+        }),
+    );
+    println!("Created P2PK with expired locktime and refund key");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with primary key (Alice) AFTER locktime expires (should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_primary =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice (primary key)
+    for proof in swap_request_primary.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_primary).await;
+    assert!(
+        result.is_err(),
+        "Should fail - primary key cannot spend after locktime expires"
+    );
+    println!(
+        "✓ Spending with primary key (Alice) AFTER locktime failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Spend with refund key (Bob) AFTER locktime (should succeed)
+    let mut swap_request_refund =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Bob (refund key)
+    for proof in swap_request_refund.inputs_mut() {
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_refund).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - refund key can spend after locktime: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with refund key (Bob) AFTER locktime succeeded");
+}
+
+/// Test: P2PK with locktime after expiry, no refund keys (anyone can spend)
+///
+/// Verifies that after locktime expires with NO refund keys configured,
+/// anyone can spend the proofs without providing any signatures at all.
+#[tokio::test]
+async fn test_p2pk_locktime_after_expiry_no_refund_anyone_can_spend() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (_alice_secret, alice_pubkey) = create_test_keypair();
+
+    // Set locktime in the past (already expired)
+    let locktime = unix_time() - 3600;
+
+    println!("Alice (primary): {}", alice_pubkey);
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expired 1 hour ago)", locktime);
+    println!("No refund keys configured - anyone can spend after locktime");
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create conditions with Alice as primary, NO refund keys
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime), // locktime in the past (expired)
+            pubkeys: None,            // no additional pubkeys
+            refund_keys: None,        // NO refund keys - anyone can spend!
+            num_sigs: None,           // default (1)
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: None, // default (1)
+        }),
+    );
+    println!("Created P2PK with expired locktime and NO refund keys");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Spend WITHOUT any signatures (should succeed - anyone can spend!)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let swap_request_no_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // No signatures added at all!
+
+    let result = mint.process_swap_request(swap_request_no_sig).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - anyone can spend after locktime with no refund keys: {:?}",
+        result.err()
+    );
+    println!("✓ Spending WITHOUT any signatures succeeded (anyone can spend)");
+}
+
+/// Test: P2PK multisig with locktime (2-of-3 before, 1-of-2 after)
+///
+/// Complex scenario: Different multisig requirements before and after locktime
+/// Before locktime: Need 2-of-3 from (Alice, Bob, Carol)
+/// After locktime: Need 1-of-2 from (Dave, Eve) as refund keys
+#[tokio::test]
+async fn test_p2pk_multisig_locktime() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Before locktime: Need 2-of-3 from (Alice, Bob, Carol)
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, bob_pubkey) = create_test_keypair();
+    let (_carol_secret, carol_pubkey) = create_test_keypair();
+
+    // After locktime: Need 1-of-2 from (Dave, Eve) as refund keys
+    let (dave_secret, dave_pubkey) = create_test_keypair();
+    let (_eve_secret, eve_pubkey) = create_test_keypair();
+
+    let locktime = unix_time() - 100; // Already expired
+
+    println!("Primary multisig: Alice, Bob, Carol (need 2-of-3)");
+    println!("Refund multisig: Dave, Eve (need 1-of-2)");
+    println!("Current time: {}", unix_time());
+    println!("Locktime: {} (expired)", locktime);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create complex conditions
+    // Before locktime: 2-of-3 (Alice, Bob, Carol)
+    // After locktime: 1-of-2 (Dave, Eve)
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(Conditions {
+            locktime: Some(locktime),                         // Already expired
+            pubkeys: Some(vec![bob_pubkey, carol_pubkey]), // Bob and Carol (with Alice = 3 total)
+            refund_keys: Some(vec![dave_pubkey, eve_pubkey]), // Dave and Eve for refund
+            num_sigs: Some(2),                             // Need 2 signatures before locktime
+            sig_flag: SigFlag::default(),
+            num_sigs_refund: Some(1), // Need 1 signature after locktime
+        }),
+    );
+    println!("Created complex P2PK: 2-of-3 before locktime, 1-of-2 after locktime");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with primary keys (Alice + Bob) AFTER locktime (should fail)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_primary =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice + Bob (primary multisig)
+    for proof in swap_request_primary.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_primary).await;
+    assert!(
+        result.is_err(),
+        "Should fail - locktime expired, only refund keys valid"
+    );
+    println!(
+        "✓ Spending with primary keys (Alice + Bob) AFTER locktime failed as expected: {:?}",
+        result.err()
+    );
+
+    // Step 7: Spend with refund key (Dave) AFTER locktime (should succeed - only need 1-of-2)
+    let mut swap_request_refund =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Dave only (refund key, need 1-of-2)
+    for proof in swap_request_refund.inputs_mut() {
+        proof.sign_p2pk(dave_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_refund).await;
+    assert!(
+        result.is_ok(),
+        "Should succeed - refund key can spend after locktime: {:?}",
+        result.err()
+    );
+    println!("✓ Spending with refund key (Dave, 1-of-2) AFTER locktime succeeded");
+}
+
+/// Test: P2PK signed by wrong person is rejected
+///
+/// Creates proofs locked to Alice's public key and verifies that
+/// signing with Bob's key (wrong key) is rejected
+#[tokio::test]
+async fn test_p2pk_signed_by_wrong_person() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    // Generate keypairs for Alice and Bob
+    let (_alice_secret, alice_pubkey) = create_test_keypair();
+    let (bob_secret, _bob_pubkey) = create_test_keypair();
+    println!("Alice pubkey: {}", alice_pubkey);
+    println!("Bob will try to spend Alice's proofs");
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create P2PK blinded messages locked to Alice's pubkey
+    let spending_conditions = SpendingConditions::new_p2pk(alice_pubkey, None);
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 3: Swap for P2PK proofs locked to Alice
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+    println!("Created P2PK proofs locked to Alice");
+
+    // Step 4: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 5: Try to spend Alice's proofs by signing with Bob's key (wrong key!)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_wrong_sig =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Bob's key instead of Alice's key
+    for proof in swap_request_wrong_sig.inputs_mut() {
+        proof.sign_p2pk(bob_secret.clone()).unwrap();
+    }
+
+    let result = mint.process_swap_request(swap_request_wrong_sig).await;
+    assert!(result.is_err(), "Should fail when signed with wrong key");
+    println!(
+        "✓ Spending signed by wrong person failed as expected: {:?}",
+        result.err()
+    );
+}
+
+/// Test: Duplicate signatures are rejected
+///
+/// Verifies that using the same signature twice doesn't count as multiple signers
+/// in a 2-of-2 multisig scenario
+#[tokio::test]
+async fn test_p2pk_duplicate_signatures() {
+    let test_mint = TestMintHelper::new().await.unwrap();
+    let mint = test_mint.mint();
+
+    let (alice_secret, alice_pubkey) = create_test_keypair();
+    let (_bob_secret, bob_pubkey) = create_test_keypair();
+
+    println!("Alice: {}", alice_pubkey);
+    println!("Bob: {}", bob_pubkey);
+
+    // Step 1: Mint regular proofs
+    let input_amount = Amount::from(10);
+    let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap();
+
+    // Step 2: Create 2-of-2 multisig (Alice and Bob, need both)
+    let spending_conditions = SpendingConditions::new_p2pk(
+        alice_pubkey,
+        Some(
+            Conditions::new(
+                None,                   // no locktime
+                Some(vec![bob_pubkey]), // Bob is additional pubkey
+                None,                   // no refund keys
+                Some(2),                // require 2 signatures (Alice + Bob)
+                None,                   // default sig_flag
+                None,                   // no num_sigs_refund
+            )
+            .unwrap(),
+        ),
+    );
+    println!("Created 2-of-2 multisig (Alice, Bob)");
+
+    // Step 3: Create P2PK blinded messages
+    let split_amounts = test_mint.split_amount(input_amount).unwrap();
+    let (p2pk_outputs, blinding_factors, secrets) = unzip3(
+        split_amounts
+            .iter()
+            .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions))
+            .collect(),
+    );
+
+    // Step 4: Swap for P2PK proofs
+    let swap_request =
+        cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone());
+    let swap_response = mint.process_swap_request(swap_request).await.unwrap();
+
+    // Step 5: Construct the P2PK proofs
+    let p2pk_proofs = construct_proofs(
+        swap_response.signatures.clone(),
+        blinding_factors.clone(),
+        secrets.clone(),
+        &test_mint.public_keys_of_the_active_sat_keyset,
+    )
+    .unwrap();
+
+    // Step 6: Try to spend with Alice's signature TWICE (should fail - need Alice + Bob, not Alice + Alice)
+    let (new_outputs, _) = create_test_blinded_messages(mint, input_amount)
+        .await
+        .unwrap();
+    let mut swap_request_duplicate =
+        cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone());
+
+    // Sign with Alice twice instead of Alice + Bob
+    for proof in swap_request_duplicate.inputs_mut() {
+        proof.sign_p2pk(alice_secret.clone()).unwrap();
+        proof.sign_p2pk(alice_secret.clone()).unwrap(); // Duplicate!
+    }
+
+    let result = mint.process_swap_request(swap_request_duplicate).await;
+    assert!(
+        result.is_err(),
+        "Should fail - duplicate signatures not allowed"
+    );
+    println!(
+        "✓ Spending with duplicate signatures (Alice + Alice) failed as expected: {:?}",
+        result.err()
+    );
+}

+ 2 - 9
crates/cdk/src/test_helpers/mint.rs

@@ -137,11 +137,7 @@ pub async fn mint_test_proofs(mint: &Mint, amount: Amount) -> Result<Proofs, Err
         sleep(Duration::from_secs(1)).await;
     }
 
-    let keysets = mint
-        .get_active_keysets()
-        .get(&CurrencyUnit::Sat)
-        .unwrap()
-        .clone();
+    let keysets = *mint.get_active_keysets().get(&CurrencyUnit::Sat).unwrap();
 
     let keys = mint
         .keyset_pubkeys(&keysets)?
@@ -151,10 +147,7 @@ pub async fn mint_test_proofs(mint: &Mint, amount: Amount) -> Result<Proofs, Err
         .keys
         .clone();
 
-    let fees: (u64, Vec<u64>) = (
-        0,
-        keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>().into(),
-    );
+    let fees: (u64, Vec<u64>) = (0, keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>());
 
     let premint_secrets =
         PreMintSecrets::random(keysets, amount, &SplitTarget::None, &fees.into()).unwrap();

+ 2 - 1
crates/cdk/src/test_helpers/mod.rs

@@ -1,4 +1,3 @@
-#![cfg(test)]
 //! Test helper utilities for CDK unit tests
 //!
 //! This module provides shared test utilities for creating test mints, wallets,
@@ -8,3 +7,5 @@
 
 #[cfg(feature = "mint")]
 pub mod mint;
+#[cfg(feature = "mint")]
+pub mod nut10;

+ 145 - 0
crates/cdk/src/test_helpers/nut10.rs

@@ -0,0 +1,145 @@
+#![cfg(test)]
+//! Shared test helpers for spending condition tests (P2PK, HTLC, etc.)
+
+use cdk_common::dhke::blind_message;
+use cdk_common::nuts::nut10::Secret as Nut10Secret;
+use cdk_common::nuts::{
+    BlindedMessage, CurrencyUnit, Id, Keys, PublicKey, SecretKey, SpendingConditions,
+};
+use cdk_common::Amount;
+
+use crate::mint::Mint;
+use crate::secret::Secret;
+use crate::test_helpers::mint::{create_test_mint, mint_test_proofs};
+use crate::Error;
+
+/// Test mint wrapper with convenient access to common keyset info
+pub struct TestMintHelper {
+    pub mint: Mint,
+    pub active_sat_keyset_id: Id,
+    pub public_keys_of_the_active_sat_keyset: Keys,
+    /// Available denominations sorted largest first (e.g., [2147483648, 1073741824, ..., 2, 1])
+    pub available_amounts_sorted: Vec<u64>,
+}
+
+impl TestMintHelper {
+    pub async fn new() -> Result<Self, Error> {
+        let mint = create_test_mint().await?;
+
+        // Get the active SAT keyset ID
+        let active_sat_keyset_id = mint
+            .get_active_keysets()
+            .get(&CurrencyUnit::Sat)
+            .cloned()
+            .ok_or(Error::Internal)?;
+
+        // Get the active SAT keyset keys
+        let lookup_by_that_id = mint.keyset_pubkeys(&active_sat_keyset_id)?;
+        let active_sat_keyset = lookup_by_that_id.keysets.first().ok_or(Error::Internal)?;
+        assert_eq!(
+            active_sat_keyset.id, active_sat_keyset_id,
+            "Keyset ID mismatch"
+        );
+        let public_keys_of_the_active_sat_keyset = active_sat_keyset.keys.clone();
+
+        // Get the available denominations from the keyset, sorted largest first
+        let mut available_amounts_sorted: Vec<u64> = public_keys_of_the_active_sat_keyset
+            .iter()
+            .map(|(amt, _)| amt.to_u64())
+            .collect();
+        available_amounts_sorted.sort_by(|a, b| b.cmp(a)); // Sort descending (largest first)
+
+        Ok(TestMintHelper {
+            mint,
+            active_sat_keyset_id,
+            public_keys_of_the_active_sat_keyset,
+            available_amounts_sorted,
+        })
+    }
+
+    /// Get a reference to the underlying mint
+    pub fn mint(&self) -> &Mint {
+        &self.mint
+    }
+
+    /// Split an amount into power-of-2 denominations
+    /// Returns the amounts that sum to the total (e.g., 10 -> [8, 2])
+    pub fn split_amount(&self, amount: Amount) -> Result<Vec<Amount>, Error> {
+        // Simple greedy algorithm: start from largest and work down
+        let mut result = Vec::new();
+        let mut remaining = amount.to_u64();
+
+        for &amt in &self.available_amounts_sorted {
+            if remaining >= amt {
+                result.push(Amount::from(amt));
+                remaining -= amt;
+            }
+        }
+
+        if remaining != 0 {
+            return Err(Error::Internal);
+        }
+
+        Ok(result)
+    }
+
+    /// Mint proofs for the given amount
+    /// Prints a message like "Minted 10 sats [8+2]"
+    pub async fn mint_proofs(&self, amount: Amount) -> Result<cdk_common::Proofs, Error> {
+        let proofs = mint_test_proofs(&self.mint, amount).await?;
+
+        // Build the split display string (e.g., "8+2")
+        let split_amounts = self.split_amount(amount)?;
+        let split_display: Vec<String> = split_amounts.iter().map(|a| a.to_string()).collect();
+        println!("Minted {} sats [{}]", amount, split_display.join("+"));
+
+        Ok(proofs)
+    }
+
+    /// Create a single blinded message with spending conditions for the given amount
+    /// Returns (blinded_message, blinding_factor, secret)
+    pub fn create_blinded_message(
+        &self,
+        amount: Amount,
+        spending_conditions: &SpendingConditions,
+    ) -> (BlindedMessage, SecretKey, Secret) {
+        let nut10_secret: Nut10Secret = spending_conditions.clone().into();
+        let secret: Secret = nut10_secret.try_into().unwrap();
+        let (blinded_point, blinding_factor) = blind_message(&secret.to_bytes(), None).unwrap();
+        let blinded_msg = BlindedMessage::new(amount, self.active_sat_keyset_id, blinded_point);
+        (blinded_msg, blinding_factor, secret)
+    }
+}
+
+/// Helper: Create a keypair for testing
+pub fn create_test_keypair() -> (SecretKey, PublicKey) {
+    let secret = SecretKey::generate();
+    let pubkey = secret.public_key();
+    (secret, pubkey)
+}
+
+/// Helper: Create a hash and preimage for testing
+/// Returns (hash_hex_string, preimage_hex_string)
+pub fn create_test_hash_and_preimage() -> (String, String) {
+    use bitcoin::hashes::sha256::Hash as Sha256Hash;
+    use bitcoin::hashes::Hash;
+
+    // Create a 32-byte preimage
+    let preimage_bytes = [0x42u8; 32];
+    let hash = Sha256Hash::hash(&preimage_bytes);
+    // Return hex-encoded hash and hex-encoded preimage
+    (hash.to_string(), crate::util::hex::encode(preimage_bytes))
+}
+
+/// Helper: Unzip a vector of 3-tuples into 3 separate vectors
+pub fn unzip3<A, B, C>(vec: Vec<(A, B, C)>) -> (Vec<A>, Vec<B>, Vec<C>) {
+    let mut vec_a = Vec::new();
+    let mut vec_b = Vec::new();
+    let mut vec_c = Vec::new();
+    for (a, b, c) in vec {
+        vec_a.push(a);
+        vec_b.push(b);
+        vec_c.push(c);
+    }
+    (vec_a, vec_b, vec_c)
+}

+ 62 - 74
crates/cdk/src/wallet/auth/auth_wallet.rs

@@ -3,7 +3,6 @@ use std::sync::Arc;
 
 use cdk_common::database::{self, WalletDatabase};
 use cdk_common::mint_url::MintUrl;
-use cdk_common::nut02::KeySetInfosMethods;
 use cdk_common::{AuthProof, Id, Keys, MintInfo};
 use serde::{Deserialize, Serialize};
 use tokio::sync::RwLock;
@@ -19,6 +18,7 @@ use crate::nuts::{
 };
 use crate::types::ProofInfo;
 use crate::wallet::mint_connector::AuthHttpClient;
+use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::{Amount, Error, OidcClient};
 
 /// JWT Claims structure for decoding tokens
@@ -40,11 +40,13 @@ pub struct AuthWallet {
     pub mint_url: MintUrl,
     /// Storage backend
     pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+    /// Mint metadata cache (lock-free cached access to keys, keysets, and mint info)
+    pub metadata_cache: Arc<MintMetadataCache>,
     /// Protected methods
     pub protected_endpoints: Arc<RwLock<HashMap<ProtectedEndpoint, AuthRequired>>>,
     /// Refresh token for auth
     refresh_token: Arc<RwLock<Option<String>>>,
-    client: Arc<dyn AuthMintConnector + Send + Sync>,
+    auth_client: Arc<dyn AuthMintConnector + Send + Sync>,
     /// OIDC client for authentication
     oidc_client: Arc<RwLock<Option<OidcClient>>>,
 }
@@ -55,6 +57,7 @@ impl AuthWallet {
         mint_url: MintUrl,
         cat: Option<AuthToken>,
         localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        metadata_cache: Arc<MintMetadataCache>,
         protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
         oidc_client: Option<OidcClient>,
     ) -> Self {
@@ -62,9 +65,10 @@ impl AuthWallet {
         Self {
             mint_url,
             localstore,
+            metadata_cache,
             protected_endpoints: Arc::new(RwLock::new(protected_endpoints)),
             refresh_token: Arc::new(RwLock::new(None)),
-            client: http_client,
+            auth_client: http_client,
             oidc_client: Arc::new(RwLock::new(oidc_client)),
         }
     }
@@ -72,7 +76,7 @@ impl AuthWallet {
     /// Get the current auth token
     #[instrument(skip(self))]
     pub async fn get_auth_token(&self) -> Result<AuthToken, Error> {
-        self.client.get_auth_token().await
+        self.auth_client.get_auth_token().await
     }
 
     /// Set a new auth token
@@ -99,7 +103,7 @@ impl AuthWallet {
                 if let Some(oidc) = self.oidc_client.read().await.as_ref() {
                     oidc.verify_cat(clear_token).await?;
                 }
-                self.client.set_auth_token(token).await
+                self.auth_client.set_auth_token(token).await
             }
             AuthToken::BlindAuth(_) => Err(Error::Custom(
                 "Cannot set blind auth token directly".to_string(),
@@ -165,94 +169,78 @@ impl AuthWallet {
     /// Query mint for current mint information
     #[instrument(skip(self))]
     pub async fn get_mint_info(&self) -> Result<Option<MintInfo>, Error> {
-        self.client.get_mint_info().await.map(Some).or(Ok(None))
+        self.auth_client
+            .get_mint_info()
+            .await
+            .map(Some)
+            .or(Ok(None))
     }
 
     /// Fetch keys for mint keyset
     ///
-    /// Returns keys from local database if they are already stored.
-    /// If keys are not found locally, goes online to query the mint for the keyset and stores the [`Keys`] in local database.
+    /// Returns keys from metadata cache if available, fetches from mint if not.
     #[instrument(skip(self))]
     pub async fn load_keyset_keys(&self, keyset_id: Id) -> Result<Keys, Error> {
-        let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? {
-            keys
-        } else {
-            let keys = self.client.get_mint_blind_auth_keyset(keyset_id).await?;
-
-            keys.verify_id()?;
-
-            self.localstore.add_keys(keys.clone()).await?;
-
-            keys.keys
-        };
-
-        Ok(keys)
+        let metadata = self
+            .metadata_cache
+            .load_auth(&self.localstore, &self.auth_client)
+            .await?;
+        let active = metadata
+            .active_keysets
+            .iter()
+            .find(|x| x.unit == CurrencyUnit::Auth)
+            .cloned()
+            .ok_or(Error::NoActiveKeyset)?;
+
+        metadata
+            .keys
+            .get(&active.id)
+            .map(|x| (*(x.clone())).clone())
+            .ok_or(Error::NoActiveKeyset)
     }
 
-    /// Get blind auth keysets from local database or go online if missing
+    /// Get blind auth keysets from metadata cache
     ///
-    /// First checks the local database for cached blind auth keysets. If keysets are not found locally,
-    /// goes online to refresh keysets from the mint and updates the local database.
+    /// Checks the metadata cache for auth keysets. If cache is not populated,
+    /// fetches from the mint server and updates the cache.
     /// This is the main method for getting auth keysets in operations that can work offline
     /// but will fall back to online if needed.
     #[instrument(skip(self))]
     pub async fn load_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        match self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
-            .await?
-        {
-            Some(keysets_info) => {
-                let auth_keysets: Vec<KeySetInfo> =
-                    keysets_info.unit(CurrencyUnit::Sat).cloned().collect();
-                if auth_keysets.is_empty() {
-                    // If we don't have any auth keysets, fetch them from the mint
-                    let keysets = self.refresh_keysets().await?;
-                    Ok(keysets)
+        let metadata = self
+            .metadata_cache
+            .load_auth(&self.localstore, &self.auth_client)
+            .await?;
+
+        let auth_keysets = metadata
+            .keysets
+            .iter()
+            .filter_map(|(_, k)| {
+                if k.unit == CurrencyUnit::Auth {
+                    Some((*(k.clone())).clone())
                 } else {
-                    Ok(auth_keysets)
+                    None
                 }
-            }
-            None => {
-                // If we don't have any keysets, fetch them from the mint
-                let keysets = self.refresh_keysets().await?;
-                Ok(keysets)
-            }
+            })
+            .collect::<Vec<_>>();
+
+        if !auth_keysets.is_empty() {
+            Ok(auth_keysets)
+        } else {
+            Err(Error::UnknownKeySet)
         }
     }
 
-    /// Refresh blind auth keysets by fetching the latest from mint - always goes online
+    /// Refresh blind auth keysets by fetching the latest from mint
     ///
-    /// This method always goes online to fetch the latest blind auth keyset information from the mint.
-    /// It updates the local database with the fetched keysets and ensures we have keys for all keysets.
-    /// Returns only the keysets with Auth currency unit. This is used when operations need the most
-    /// up-to-date keyset information and are willing to go online.
+    /// Fetches the latest blind auth keyset information from the mint server,
+    /// updating the metadata cache and database. Returns only the keysets with
+    /// Auth currency unit. Use this when you need the most up-to-date keyset information.
     #[instrument(skip(self))]
     pub async fn refresh_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        let keysets_response = self.client.get_mint_blind_auth_keysets().await?;
-        let keysets = keysets_response.keysets;
-
-        // Update local store with keysets
-        self.localstore
-            .add_mint_keysets(self.mint_url.clone(), keysets.clone())
-            .await?;
-
-        // Filter for auth keysets
-        let auth_keysets = keysets
-            .clone()
-            .into_iter()
-            .filter(|k| k.unit == CurrencyUnit::Auth)
-            .collect::<Vec<KeySetInfo>>();
-
-        // Ensure we have keys for all auth keysets
-        for keyset in &auth_keysets {
-            if self.localstore.get_keys(&keyset.id).await?.is_none() {
-                tracing::debug!("Fetching missing keys for auth keyset {}", keyset.id);
-                self.load_keyset_keys(keyset.id).await?;
-            }
-        }
+        tracing::debug!("Refreshing auth keysets from mint");
 
-        Ok(auth_keysets)
+        self.load_mint_keysets().await
     }
 
     /// Get the first active blind auth keyset - always goes online
@@ -325,7 +313,7 @@ impl AuthWallet {
             Some(auth) => match auth {
                 AuthRequired::Clear => {
                     tracing::trace!("Clear auth needed for request.");
-                    self.client.get_auth_token().await.map(Some)
+                    self.auth_client.get_auth_token().await.map(Some)
                 }
                 AuthRequired::Blind => {
                     tracing::trace!("Blind auth needed for request getting Auth proof.");
@@ -359,7 +347,7 @@ impl AuthWallet {
             self.get_mint_info().await?;
         }
 
-        let auth_token = self.client.get_auth_token().await?;
+        let auth_token = self.auth_client.get_auth_token().await?;
 
         match &auth_token {
             AuthToken::ClearAuth(cat) => {
@@ -425,7 +413,7 @@ impl AuthWallet {
             outputs: premint_secrets.blinded_messages(),
         };
 
-        let mint_res = self.client.post_mint_blind_auth(request).await?;
+        let mint_res = self.auth_client.post_mint_blind_auth(request).await?;
 
         let keys = self.load_keyset_keys(active_keyset_id).await?;
 

+ 81 - 6
crates/cdk/src/wallet/builder.rs

@@ -1,12 +1,14 @@
-#[cfg(feature = "auth")]
 use std::collections::HashMap;
 use std::sync::Arc;
+use std::time::Duration;
 
 use cdk_common::database;
+use cdk_common::parking_lot::RwLock;
+use cdk_common::task::spawn;
 #[cfg(feature = "auth")]
 use cdk_common::AuthToken;
 #[cfg(feature = "auth")]
-use tokio::sync::RwLock;
+use tokio::sync::RwLock as TokioRwLock;
 
 use crate::cdk_database::WalletDatabase;
 use crate::error::Error;
@@ -14,10 +16,10 @@ use crate::mint_url::MintUrl;
 use crate::nuts::CurrencyUnit;
 #[cfg(feature = "auth")]
 use crate::wallet::auth::AuthWallet;
+use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::wallet::{HttpClient, MintConnector, SubscriptionManager, Wallet};
 
 /// Builder for creating a new [`Wallet`]
-#[derive(Debug)]
 pub struct WalletBuilder {
     mint_url: Option<MintUrl>,
     unit: Option<CurrencyUnit>,
@@ -28,6 +30,9 @@ pub struct WalletBuilder {
     seed: Option<[u8; 64]>,
     use_http_subscription: bool,
     client: Option<Arc<dyn MintConnector + Send + Sync>>,
+    metadata_cache_ttl: Option<Duration>,
+    metadata_cache: Option<Arc<MintMetadataCache>>,
+    metadata_caches: HashMap<MintUrl, Arc<MintMetadataCache>>,
 }
 
 impl Default for WalletBuilder {
@@ -41,7 +46,10 @@ impl Default for WalletBuilder {
             auth_wallet: None,
             seed: None,
             client: None,
+            metadata_cache_ttl: None,
             use_http_subscription: false,
+            metadata_cache: None,
+            metadata_caches: HashMap::new(),
         }
     }
 }
@@ -58,6 +66,12 @@ impl WalletBuilder {
         self
     }
 
+    /// Set metadata_cache_ttl
+    pub fn set_metadata_cache_ttl(mut self, metadata_cache_ttl: Option<Duration>) -> Self {
+        self.metadata_cache_ttl = metadata_cache_ttl;
+        self
+    }
+
     /// If WS is preferred (with fallback to HTTP is it is not supported by the mint) for the wallet
     /// subscriptions to mint events
     pub fn prefer_ws_subscription(mut self) -> Self {
@@ -117,13 +131,49 @@ impl WalletBuilder {
         self
     }
 
+    /// Set a shared MintMetadataCache
+    ///
+    /// This allows multiple wallets to share the same metadata cache instance for
+    /// optimal performance and memory usage. If not provided, a new cache
+    /// will be created for each wallet.
+    pub fn metadata_cache(mut self, metadata_cache: Arc<MintMetadataCache>) -> Self {
+        self.metadata_cache = Some(metadata_cache);
+        self
+    }
+
+    /// Set a HashMap of MintMetadataCaches for reusing across multiple wallets
+    ///
+    /// This allows the builder to reuse existing cache instances or create new ones.
+    /// Useful when creating multiple wallets that share metadata caches.
+    pub fn metadata_caches(
+        mut self,
+        metadata_caches: HashMap<MintUrl, Arc<MintMetadataCache>>,
+    ) -> Self {
+        self.metadata_caches = metadata_caches;
+        self
+    }
+
     /// Set auth CAT (Clear Auth Token)
     #[cfg(feature = "auth")]
     pub fn set_auth_cat(mut self, cat: String) -> Self {
+        let mint_url = self.mint_url.clone().expect("Mint URL required");
+        let localstore = self.localstore.clone().expect("Localstore required");
+
+        let metadata_cache = self.metadata_cache.clone().unwrap_or_else(|| {
+            // Check if we already have a cache for this mint in the HashMap
+            if let Some(cache) = self.metadata_caches.get(&mint_url) {
+                cache.clone()
+            } else {
+                // Create a new one
+                Arc::new(MintMetadataCache::new(mint_url.clone()))
+            }
+        });
+
         self.auth_wallet = Some(AuthWallet::new(
-            self.mint_url.clone().expect("Mint URL required"),
+            mint_url,
             Some(AuthToken::ClearAuth(cat)),
-            self.localstore.clone().expect("Localstore required"),
+            localstore,
+            metadata_cache,
             HashMap::new(),
             None,
         ));
@@ -162,13 +212,38 @@ impl WalletBuilder {
             }
         };
 
+        let metadata_cache_ttl = self.metadata_cache_ttl;
+
+        let metadata_cache = self.metadata_cache.unwrap_or_else(|| {
+            // Check if we already have a cache for this mint in the HashMap
+            if let Some(cache) = self.metadata_caches.get(&mint_url) {
+                cache.clone()
+            } else {
+                // Create a new one
+                Arc::new(MintMetadataCache::new(mint_url.clone()))
+            }
+        });
+
+        let metadata_for_loader = metadata_cache.clone();
+        let localstore_for_loader = localstore.clone();
+        spawn(async move {
+            let _ = metadata_for_loader
+                .load_from_storage(&localstore_for_loader)
+                .await
+                .inspect_err(|err| {
+                    tracing::warn!("Failed to load mint metadata from storage {err}");
+                });
+        });
+
         Ok(Wallet {
             mint_url,
             unit,
             localstore,
+            metadata_cache,
+            metadata_cache_ttl: Arc::new(RwLock::new(metadata_cache_ttl)),
             target_proof_count: self.target_proof_count.unwrap_or(3),
             #[cfg(feature = "auth")]
-            auth_wallet: Arc::new(RwLock::new(self.auth_wallet)),
+            auth_wallet: Arc::new(TokioRwLock::new(self.auth_wallet)),
             seed,
             client: client.clone(),
             subscription: SubscriptionManager::new(client, self.use_http_subscription),

+ 0 - 4
crates/cdk/src/wallet/issue/issue_bolt11.rs

@@ -52,8 +52,6 @@ impl Wallet {
         let mint_url = self.mint_url.clone();
         let unit = self.unit.clone();
 
-        self.refresh_keysets().await?;
-
         // If we have a description, we check that the mint supports it.
         if description.is_some() {
             let settings = self
@@ -196,8 +194,6 @@ impl Wallet {
         amount_split_target: SplitTarget,
         spending_conditions: Option<SpendingConditions>,
     ) -> Result<Proofs, Error> {
-        self.refresh_keysets().await?;
-
         let quote_info = self
             .localstore
             .get_mint_quote(quote_id)

+ 0 - 4
crates/cdk/src/wallet/issue/issue_bolt12.rs

@@ -29,8 +29,6 @@ impl Wallet {
         let mint_url = self.mint_url.clone();
         let unit = &self.unit;
 
-        self.refresh_keysets().await?;
-
         // If we have a description, we check that the mint supports it.
         if description.is_some() {
             let mint_method_settings = self
@@ -85,8 +83,6 @@ impl Wallet {
         amount_split_target: SplitTarget,
         spending_conditions: Option<SpendingConditions>,
     ) -> Result<Proofs, Error> {
-        self.refresh_keysets().await?;
-
         let quote_info = self.localstore.get_mint_quote(quote_id).await?;
 
         let quote_info = if let Some(quote) = quote_info {

+ 96 - 108
crates/cdk/src/wallet/keysets.rs

@@ -10,109 +10,99 @@ use crate::{Error, Wallet};
 impl Wallet {
     /// Load keys for mint keyset
     ///
-    /// Returns keys from local database if they are already stored.
-    /// If keys are not found locally, goes online to query the mint for the keyset and stores the [`Keys`] in local database.
+    /// Returns keys from metadata cache if available.
+    /// If keys are not cached, fetches from mint server.
     #[instrument(skip(self))]
     pub async fn load_keyset_keys(&self, keyset_id: Id) -> Result<Keys, Error> {
-        let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? {
-            keys
-        } else {
-            tracing::debug!(
-                "Keyset {} not in db fetching from mint {}",
-                keyset_id,
-                self.mint_url
-            );
-
-            let keys = self.client.get_mint_keyset(keyset_id).await?;
-
-            keys.verify_id()?;
-
-            self.localstore.add_keys(keys.clone()).await?;
-
-            keys.keys
-        };
-
-        Ok(keys)
+        self.metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
+            .await?
+            .keys
+            .get(&keyset_id)
+            .map(|x| (*x.clone()).clone())
+            .ok_or(Error::UnknownKeySet)
     }
 
-    /// Get keysets from local database or go online if missing
-    ///
-    /// First checks the local database for cached keysets. If keysets are not found locally,
-    /// goes online to refresh keysets from the mint and updates the local database.
-    /// This is the main method for getting keysets in token operations that can work offline
-    /// but will fall back to online if needed.
+    /// Alias of get_mint_keysets, kept for backwards compatibility reasons
     #[instrument(skip(self))]
     pub async fn load_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        match self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
-            .await?
-        {
-            Some(keysets_info) => Ok(keysets_info),
-            None => {
-                // If we don't have any keysets, fetch them from the mint
-                let keysets = self.refresh_keysets().await?;
-                Ok(keysets)
-            }
-        }
+        self.get_mint_keysets().await
     }
 
-    /// Get keysets from local database only - pure offline operation
+    /// Get keysets from metadata cache (may fetch if not populated)
     ///
-    /// Only checks the local database for cached keysets. If keysets are not found locally,
-    /// returns an error without going online. This is used for operations that must remain
-    /// offline and rely on previously cached keyset data.
+    /// Checks the metadata cache for keysets. If cache is not populated,
+    /// fetches from mint and updates cache. Returns error if no active keysets found.
     #[instrument(skip(self))]
+    #[inline(always)]
     pub async fn get_mint_keysets(&self) -> Result<Vec<KeySetInfo>, Error> {
-        match self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
+        let keysets = self
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
             .await?
-        {
-            Some(keysets_info) => Ok(keysets_info),
-            None => Err(Error::UnknownKeySet),
+            .keysets
+            .iter()
+            .filter_map(|(_, keyset)| {
+                if keyset.unit == self.unit && keyset.active {
+                    Some((*keyset.clone()).clone())
+                } else {
+                    None
+                }
+            })
+            .collect::<Vec<_>>();
+
+        if !keysets.is_empty() {
+            Ok(keysets)
+        } else {
+            Err(Error::UnknownKeySet)
         }
     }
 
-    /// Refresh keysets by fetching the latest from mint - always goes online
+    /// Refresh keysets by fetching the latest from mint - always fetches fresh data
     ///
-    /// This method always goes online to fetch the latest keyset information from the mint.
-    /// It updates the local database with the fetched keysets and ensures we have keys
-    /// for all active keysets. This is used when operations need the most up-to-date
-    /// keyset information and are willing to go online.
+    /// Forces a fresh fetch of keyset information from the mint server,
+    /// updating the metadata cache and database. Use this when you need
+    /// the most up-to-date keyset information.
     #[instrument(skip(self))]
     pub async fn refresh_keysets(&self) -> Result<KeySetInfos, Error> {
-        tracing::debug!("Refreshing keysets and ensuring we have keys");
-        let _ = self.fetch_mint_info().await?;
+        tracing::debug!("Refreshing keysets from mint");
 
-        // Fetch all current keysets from mint
-        let keysets_response = self.client.get_mint_keysets().await?;
-        let all_keysets = keysets_response.keysets;
-
-        // Update local storage with keyset info
-        self.localstore
-            .add_mint_keysets(self.mint_url.clone(), all_keysets.clone())
-            .await?;
-
-        // Filter for active keysets matching our unit
-        let keysets: KeySetInfos = all_keysets.unit(self.unit.clone()).cloned().collect();
-
-        // Ensure we have keys for all active keysets
-        for keyset in &keysets {
-            self.load_keyset_keys(keyset.id).await?;
+        let keysets = self
+            .metadata_cache
+            .load_from_mint(&self.localstore, &self.client)
+            .await?
+            .keysets
+            .iter()
+            .filter_map(|(_, keyset)| {
+                if keyset.unit == self.unit && keyset.active {
+                    Some((*keyset.clone()).clone())
+                } else {
+                    None
+                }
+            })
+            .collect::<Vec<_>>();
+
+        if !keysets.is_empty() {
+            Ok(keysets)
+        } else {
+            Err(Error::UnknownKeySet)
         }
-
-        Ok(keysets)
     }
 
-    /// Get the active keyset with the lowest fees - always goes online
+    /// Get the active keyset with the lowest fees - fetches fresh data from mint
     ///
-    /// This method always goes online to refresh keysets from the mint and then returns
-    /// the active keyset with the minimum input fees. Use this when you need the most
-    /// up-to-date keyset information for operations.
+    /// Forces a fresh fetch of keysets from the mint and returns the active keyset
+    /// with the minimum input fees. Use this when you need the most up-to-date
+    /// keyset information for operations.
     #[instrument(skip(self))]
     pub async fn fetch_active_keyset(&self) -> Result<KeySetInfo, Error> {
-        self.refresh_keysets()
+        self.get_mint_keysets()
             .await?
             .active()
             .min_by_key(|k| k.input_fee_ppk)
@@ -120,47 +110,46 @@ impl Wallet {
             .ok_or(Error::NoActiveKeyset)
     }
 
-    /// Get the active keyset with the lowest fees from local database only - offline operation
+    /// Get the active keyset with the lowest fees from cache
     ///
-    /// Returns the active keyset with minimum input fees from cached keysets in the local database.
-    /// This is an offline operation that does not contact the mint. If no keysets are found locally,
-    /// returns an error. Use this for offline operations or when you want to avoid network calls.
+    /// Returns the active keyset with minimum input fees from the metadata cache.
+    /// Uses cached data if available, fetches from mint if cache not populated.
     #[instrument(skip(self))]
     pub async fn get_active_keyset(&self) -> Result<KeySetInfo, Error> {
-        match self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
+        self.metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
             .await?
-        {
-            Some(keysets_info) => keysets_info
-                .into_iter()
-                .min_by_key(|k| k.input_fee_ppk)
-                .ok_or(Error::NoActiveKeyset),
-            None => Err(Error::UnknownKeySet),
-        }
+            .active_keysets
+            .iter()
+            .min_by_key(|k| k.input_fee_ppk)
+            .map(|ks| (**ks).clone())
+            .ok_or(Error::NoActiveKeyset)
     }
 
-    /// Get keyset fees and amounts for mint from local database only - offline operation
+    /// Get keyset fees and amounts for all keysets from metadata cache
     ///
     /// Returns a HashMap of keyset IDs to their input fee rates (per-proof-per-thousand)
-    /// from cached keysets in the local database. This is an offline operation that does
-    /// not contact the mint. If no keysets are found locally, returns an error.
+    /// and available amounts. Uses cached data if available, fetches from mint if not.
     pub async fn get_keyset_fees_and_amounts(&self) -> Result<KeysetFeeAndAmounts, Error> {
-        let keysets = self
-            .localstore
-            .get_mint_keysets(self.mint_url.clone())
-            .await?
-            .ok_or(Error::UnknownKeySet)?;
+        let metadata = self
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
+            .await?;
 
         let mut fees = HashMap::new();
-        for keyset in keysets {
+        for keyset in metadata.keysets.values() {
+            let keys = self.load_keyset_keys(keyset.id).await?;
             fees.insert(
                 keyset.id,
                 (
                     keyset.input_fee_ppk,
-                    self.load_keyset_keys(keyset.id)
-                        .await?
-                        .iter()
+                    keys.iter()
                         .map(|(amount, _)| amount.to_u64())
                         .collect::<Vec<_>>(),
                 )
@@ -171,11 +160,10 @@ impl Wallet {
         Ok(fees)
     }
 
-    /// Get keyset fees and amounts for mint by keyset id from local database only - offline operation
+    /// Get keyset fees and amounts for a specific keyset ID
     ///
-    /// Returns the input fee rate (per-proof-per-thousand) for a specific keyset ID from
-    /// cached keysets in the local database. This is an offline operation that does not
-    /// contact the mint. If the keyset is not found locally, returns an error.
+    /// Returns the input fee rate (per-proof-per-thousand) and available amounts
+    /// for a specific keyset. Uses cached data if available, fetches from mint if not.
     pub async fn get_keyset_fees_and_amounts_by_id(
         &self,
         keyset_id: Id,

+ 2 - 8
crates/cdk/src/wallet/melt/melt_bolt11.rs

@@ -49,8 +49,6 @@ impl Wallet {
         request: String,
         options: Option<MeltOptions>,
     ) -> Result<MeltQuote, Error> {
-        self.refresh_keysets().await?;
-
         let invoice = Bolt11Invoice::from_str(&request)?;
 
         let quote_request = MeltQuoteBolt11Request {
@@ -217,11 +215,7 @@ impl Wallet {
             }
         };
 
-        let active_keys = self
-            .localstore
-            .get_keys(&active_keyset_id)
-            .await?
-            .ok_or(Error::NoActiveKeyset)?;
+        let active_keys = self.load_keyset_keys(active_keyset_id).await?;
 
         let change_proofs = match melt_response.change {
             Some(change) => {
@@ -390,7 +384,7 @@ impl Wallet {
         let available_proofs = self.get_unspent_proofs().await?;
 
         let active_keyset_ids = self
-            .refresh_keysets()
+            .get_mint_keysets()
             .await?
             .into_iter()
             .map(|k| k.id)

+ 90 - 0
crates/cdk/src/wallet/melt/melt_lightning_address.rs

@@ -0,0 +1,90 @@
+//! Melt Lightning Address
+//!
+//! Implementation of melt functionality for Lightning addresses
+
+use std::str::FromStr;
+
+use cdk_common::wallet::MeltQuote;
+use tracing::instrument;
+
+use crate::lightning_address::LightningAddress;
+use crate::{Amount, Error, Wallet};
+
+impl Wallet {
+    /// Melt Quote for Lightning address
+    ///
+    /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice
+    /// and then creates a melt quote for that invoice.
+    ///
+    /// # Arguments
+    ///
+    /// * `lightning_address` - Lightning address in the format "user@domain.com"
+    /// * `amount_msat` - Amount to pay in millisatoshis
+    ///
+    /// # Returns
+    ///
+    /// A `MeltQuote` that can be used to execute the payment
+    ///
+    /// # Errors
+    ///
+    /// This method will return an error if:
+    /// - The Lightning address format is invalid
+    /// - HTTP request to the Lightning address service fails
+    /// - The amount is outside the acceptable range
+    /// - The service returns an error
+    /// - The mint fails to provide a quote for the invoice
+    ///
+    /// # Example
+    ///
+    /// ```rust,no_run
+    /// use cdk::Amount;
+    /// # use cdk::Wallet;
+    /// # async fn example(wallet: Wallet) -> Result<(), cdk::Error> {
+    /// let quote = wallet
+    ///     .melt_lightning_address_quote("alice@example.com", Amount::from(100_000)) // 100 sats in msat
+    ///     .await?;
+    /// # Ok(())
+    /// # }
+    /// ```
+    #[instrument(skip(self, amount_msat), fields(lightning_address = %lightning_address))]
+    pub async fn melt_lightning_address_quote(
+        &self,
+        lightning_address: &str,
+        amount_msat: impl Into<Amount>,
+    ) -> Result<MeltQuote, Error> {
+        let amount = amount_msat.into();
+
+        // Parse the Lightning address
+        let ln_address = LightningAddress::from_str(lightning_address).map_err(|e| {
+            tracing::error!(
+                "Failed to parse Lightning address '{}': {}",
+                lightning_address,
+                e
+            );
+            Error::LightningAddressParse(e.to_string())
+        })?;
+
+        tracing::debug!("Resolving Lightning address: {}", ln_address);
+
+        // Request an invoice from the Lightning address service
+        let invoice = ln_address
+            .request_invoice(&self.client, amount)
+            .await
+            .map_err(|e| {
+                tracing::error!(
+                    "Failed to get invoice from Lightning address service: {}",
+                    e
+                );
+                Error::LightningAddressRequest(e.to_string())
+            })?;
+
+        tracing::debug!(
+            "Received invoice from Lightning address service: {}",
+            invoice
+        );
+
+        // Create a melt quote for the invoice using the existing bolt11 functionality
+        // The invoice from LNURL already contains the amount, so we don't need amountless options
+        self.melt_quote(invoice.to_string(), None).await
+    }
+}

+ 63 - 0
crates/cdk/src/wallet/melt/mod.rs

@@ -11,6 +11,8 @@ use crate::Wallet;
 mod melt_bip353;
 mod melt_bolt11;
 mod melt_bolt12;
+#[cfg(feature = "wallet")]
+mod melt_lightning_address;
 
 impl Wallet {
     /// Check pending melt quotes
@@ -84,4 +86,65 @@ impl Wallet {
         }
         Ok(())
     }
+
+    /// Get a melt quote for a human-readable address
+    ///
+    /// This method accepts a human-readable address that could be either a BIP353 address
+    /// or a Lightning address. It intelligently determines which to try based on mint support:
+    ///
+    /// 1. If the mint supports Bolt12, it tries BIP353 first
+    /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails
+    /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address
+    /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly
+    #[cfg(all(feature = "bip353", feature = "wallet", not(target_arch = "wasm32")))]
+    pub async fn melt_human_readable_quote(
+        &self,
+        address: &str,
+        amount_msat: impl Into<crate::Amount>,
+    ) -> Result<MeltQuote, Error> {
+        use cdk_common::nuts::PaymentMethod;
+
+        let amount = amount_msat.into();
+
+        // Get mint info from cache to check bolt12 support (no network call)
+        let mint_info = &self
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
+            .await?
+            .mint_info;
+
+        // Check if mint supports bolt12 by looking at nut05 methods
+        let supports_bolt12 = mint_info
+            .nuts
+            .nut05
+            .methods
+            .iter()
+            .any(|m| m.method == PaymentMethod::Bolt12);
+
+        if supports_bolt12 {
+            // Mint supports bolt12, try BIP353 first
+            match self.melt_bip353_quote(address, amount).await {
+                Ok(quote) => Ok(quote),
+                Err(Error::Bip353Resolve(_)) => {
+                    // DNS resolution failed, fall back to Lightning address
+                    tracing::debug!(
+                        "BIP353 DNS resolution failed for {}, trying Lightning address",
+                        address
+                    );
+                    return self.melt_lightning_address_quote(address, amount).await;
+                }
+                Err(e) => {
+                    // BIP353 resolved but failed for another reason (e.g., mint error)
+                    // Don't fall back to Lightning address
+                    Err(e)
+                }
+            }
+        } else {
+            // Mint doesn't support bolt12, use Lightning address directly
+            self.melt_lightning_address_quote(address, amount).await
+        }
+    }
 }

+ 22 - 0
crates/cdk/src/wallet/mint_connector/http_client.rs

@@ -216,6 +216,28 @@ where
         self.transport.resolve_dns_txt(domain).await
     }
 
+    /// Fetch Lightning address pay request data
+    #[instrument(skip(self))]
+    async fn fetch_lnurl_pay_request(
+        &self,
+        url: &str,
+    ) -> Result<crate::lightning_address::LnurlPayResponse, Error> {
+        let parsed_url =
+            url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
+        self.transport.http_get(parsed_url, None).await
+    }
+
+    /// Fetch invoice from Lightning address callback
+    #[instrument(skip(self))]
+    async fn fetch_lnurl_invoice(
+        &self,
+        url: &str,
+    ) -> Result<crate::lightning_address::LnurlPayInvoiceResponse, Error> {
+        let parsed_url =
+            url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?;
+        self.transport.http_get(parsed_url, None).await
+    }
+
     /// Get Active Mint Keys [NUT-01]
     #[instrument(skip(self), fields(mint_url = %self.mint_url))]
     async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error> {

+ 14 - 0
crates/cdk/src/wallet/mint_connector/mod.rs

@@ -6,6 +6,8 @@ use async_trait::async_trait;
 use cdk_common::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response};
 
 use super::Error;
+// Re-export Lightning address types for trait implementers
+pub use crate::lightning_address::{LnurlPayInvoiceResponse, LnurlPayResponse};
 use crate::nuts::{
     CheckStateRequest, CheckStateResponse, Id, KeySet, KeysetResponse, MeltQuoteBolt11Request,
     MeltQuoteBolt11Response, MeltRequest, MintInfo, MintQuoteBolt11Request,
@@ -35,6 +37,18 @@ pub trait MintConnector: Debug {
     /// Resolve the DNS record getting the TXT value
     async fn resolve_dns_txt(&self, _domain: &str) -> Result<Vec<String>, Error>;
 
+    /// Fetch Lightning address pay request data
+    async fn fetch_lnurl_pay_request(
+        &self,
+        url: &str,
+    ) -> Result<crate::lightning_address::LnurlPayResponse, Error>;
+
+    /// Fetch invoice from Lightning address callback
+    async fn fetch_lnurl_invoice(
+        &self,
+        url: &str,
+    ) -> Result<crate::lightning_address::LnurlPayInvoiceResponse, Error>;
+
     /// Get Active Mint Keys [NUT-01]
     async fn get_mint_keys(&self) -> Result<Vec<KeySet>, Error>;
     /// Get Keyset Keys [NUT-01]

+ 677 - 0
crates/cdk/src/wallet/mint_metadata_cache.rs

@@ -0,0 +1,677 @@
+//! Per-mint cryptographic key and metadata cache
+//!
+//! Provides on-demand fetching and caching of mint metadata (info, keysets, and keys)
+//! with atomic in-memory cache updates and deferred database persistence.
+//!
+//! # Architecture
+//!
+//! - **Pull-based loading**: Keys fetched on-demand from mint HTTP API
+//! - **Atomic cache**: Single `MintMetadata` snapshot updated via `ArcSwap`
+//! - **Deferred persistence**: Database writes happen asynchronously after cache update
+//! - **Multi-database support**: Tracks sync status per storage instance via pointer identity
+//!
+//! # Usage
+//!
+//! ```ignore
+//! // Create manager (cheap, no I/O)
+//! let manager = Arc::new(MintMetadataCache::new(mint_url));
+//!
+//! // Load metadata (returns cached if available, fetches if not)
+//! let metadata = manager.load(&storage, &client).await?;
+//! let keys = metadata.keys.get(&keyset_id).ok_or(Error::UnknownKeySet)?;
+//!
+//! // Force refresh from mint
+//! let fresh = manager.load_from_mint(&storage, &client).await?;
+//! ```
+
+use std::collections::HashMap;
+use std::fmt::Debug;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+
+use arc_swap::ArcSwap;
+use cdk_common::database::{self, WalletDatabase};
+use cdk_common::mint_url::MintUrl;
+use cdk_common::nuts::{KeySetInfo, Keys};
+use cdk_common::parking_lot::RwLock;
+use cdk_common::task::spawn;
+use cdk_common::{KeySet, MintInfo};
+use tokio::sync::Mutex;
+
+use crate::nuts::Id;
+use crate::wallet::MintConnector;
+#[cfg(feature = "auth")]
+use crate::wallet::{AuthMintConnector, AuthWallet};
+use crate::{Error, Wallet};
+
+/// Metadata freshness and versioning information
+///
+/// Tracks when data was last fetched and which version is currently cached.
+/// Used to determine if cache is ready and if database sync is needed.
+#[derive(Clone, Debug)]
+pub struct FreshnessStatus {
+    /// Whether this data has been successfully fetched at least once
+    pub is_populated: bool,
+
+    /// A future time when the cache would be considered as staled.
+    pub updated_at: Instant,
+
+    /// Monotonically increasing version number (for database sync tracking)
+    version: usize,
+}
+
+impl Default for FreshnessStatus {
+    fn default() -> Self {
+        Self {
+            is_populated: false,
+            updated_at: Instant::now(),
+            version: 0,
+        }
+    }
+}
+
+/// Complete metadata snapshot for a single mint
+///
+/// Contains all cryptographic keys, keyset metadata, and mint information
+/// fetched from a mint server. This struct is atomically swapped as a whole
+/// to ensure readers always see a consistent view.
+///
+/// Cloning is cheap due to `Arc` wrapping of large data structures.
+#[derive(Clone, Debug, Default)]
+pub struct MintMetadata {
+    /// Mint server information (name, description, supported features, etc.)
+    pub mint_info: MintInfo,
+
+    /// All keysets indexed by their ID (includes both active and inactive)
+    pub keysets: HashMap<Id, Arc<KeySetInfo>>,
+
+    /// Cryptographic keys for each keyset, indexed by keyset ID
+    pub keys: HashMap<Id, Arc<Keys>>,
+
+    /// Subset of keysets that are currently active (cached for convenience)
+    pub active_keysets: Vec<Arc<KeySetInfo>>,
+
+    /// Freshness tracking for regular (non-auth) mint data
+    status: FreshnessStatus,
+
+    /// Freshness tracking for blind auth keysets (when `auth` feature enabled)
+    #[cfg(feature = "auth")]
+    auth_status: FreshnessStatus,
+}
+
+/// On-demand mint metadata cache with deferred database persistence
+///
+/// Manages a single mint's cryptographic keys and metadata. Fetches data from
+/// the mint's HTTP API on-demand and caches it in memory. Database writes are
+/// deferred to background tasks to avoid blocking operations.
+///
+/// # Thread Safety
+///
+/// All methods are safe to call concurrently. The cache uses `ArcSwap` for
+/// lock-free reads and atomic updates. A `Mutex` ensures only one fetch
+/// operation runs at a time, with other callers waiting and re-reading cache.
+///
+/// # Cloning
+///
+/// Cheap to clone - all data is behind `Arc`. Clones share the same cache.
+#[derive(Clone)]
+pub struct MintMetadataCache {
+    /// The mint server URL this cache manages
+    mint_url: MintUrl,
+
+    /// Atomically-updated metadata snapshot (lock-free reads)
+    metadata: Arc<ArcSwap<MintMetadata>>,
+
+    /// Tracks which database instances have been synced to which cache version.
+    /// Key: pointer identity of storage Arc, Value: last synced cache version
+    db_sync_versions: Arc<RwLock<HashMap<usize, usize>>>,
+
+    /// Mutex to ensure only one fetch operation runs at a time
+    /// Other callers wait for the lock, then re-read the updated cache
+    fetch_lock: Arc<Mutex<()>>,
+}
+
+impl std::fmt::Debug for MintMetadataCache {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("MintMetadataCache")
+            .field("mint_url", &self.mint_url)
+            .field("is_populated", &self.metadata.load().status.is_populated)
+            .field("keyset_count", &self.metadata.load().keysets.len())
+            .finish()
+    }
+}
+
+impl Wallet {
+    /// Sets the metadata cache TTL
+    pub fn set_metadata_cache_ttl(&self, ttl: Option<Duration>) {
+        let mut guarded_ttl = self.metadata_cache_ttl.write();
+        *guarded_ttl = ttl;
+    }
+
+    /// Get information about metadata cache info
+    pub fn get_metadata_cache_info(&self) -> FreshnessStatus {
+        self.metadata_cache.metadata.load().status.clone()
+    }
+}
+
+#[cfg(feature = "auth")]
+impl AuthWallet {
+    /// Get information about metadata cache info
+    pub fn get_metadata_cache_info(&self) -> FreshnessStatus {
+        self.metadata_cache.metadata.load().auth_status.clone()
+    }
+}
+
+impl MintMetadataCache {
+    /// Compute a unique identifier for an Arc pointer
+    ///
+    /// Used to track which storage instances have been synced. We use pointer
+    /// identity rather than a counter because wallets may use multiple storage
+    /// backends simultaneously (e.g., different databases for different mints).
+    fn arc_pointer_id<T>(arc: &Arc<T>) -> usize
+    where
+        T: ?Sized,
+    {
+        Arc::as_ptr(arc) as *const () as usize
+    }
+
+    /// Create a new metadata cache for the given mint
+    ///
+    /// This is a cheap operation that only allocates memory. No network or
+    /// database I/O occurs until `load()` or `load_from_mint()` is called.
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// let cache = MintMetadataCache::new(mint_url, None);
+    /// // No data loaded yet - call load() to fetch
+    /// ```
+    pub fn new(mint_url: MintUrl) -> Self {
+        Self {
+            mint_url,
+            metadata: Arc::new(ArcSwap::default()),
+            db_sync_versions: Arc::new(Default::default()),
+            fetch_lock: Arc::new(Mutex::new(())),
+        }
+    }
+
+    /// Load metadata from mint server and update cache
+    ///
+    /// Always performs an HTTP fetch from the mint server to get fresh data.
+    /// Updates the in-memory cache and spawns a background task to persist
+    /// to the database.
+    ///
+    /// Uses a mutex to ensure only one fetch runs at a time. If multiple
+    /// callers request a fetch simultaneously, only one performs the HTTP
+    /// request while others wait for the lock, then return the updated cache.
+    ///
+    /// Use this when you need guaranteed fresh data from the mint.
+    ///
+    /// # Arguments
+    ///
+    /// * `storage` - Database to persist metadata to (async background write)
+    /// * `client` - HTTP client for fetching from mint server
+    ///
+    /// # Returns
+    ///
+    /// Fresh metadata from the mint server
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// // Force refresh from mint (ignores cache)
+    /// let fresh = cache.load_from_mint(&storage, &client).await?;
+    /// ```
+    #[inline(always)]
+    pub async fn load_from_mint(
+        &self,
+        storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        client: &Arc<dyn MintConnector + Send + Sync>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        // Acquire lock to ensure only one fetch at a time
+        let current_version = self.metadata.load().status.version;
+        let _guard = self.fetch_lock.lock().await;
+
+        // Check if another caller already updated the cache while we waited
+        let current_metadata = self.metadata.load().clone();
+        if current_metadata.status.is_populated && current_metadata.status.version > current_version
+        {
+            // Cache was just updated by another caller - return it
+            tracing::debug!(
+                "Cache was updated while waiting for fetch lock, returning cached data"
+            );
+            return Ok(current_metadata);
+        }
+
+        // Perform the fetch
+        #[cfg(feature = "auth")]
+        let metadata = self.fetch_from_http(Some(client), None).await?;
+
+        #[cfg(not(feature = "auth"))]
+        let metadata = self.fetch_from_http(Some(client)).await?;
+
+        // Spawn background task to persist to database (non-blocking)
+        self.spawn_database_sync(storage.clone(), metadata.clone());
+
+        Ok(metadata)
+    }
+
+    /// Load metadata from cache or fetch if not available
+    ///
+    /// Returns cached metadata if available and it is still valid, otherwise fetches from the mint.
+    /// If cache is stale relative to the database, spawns a background sync task.
+    ///
+    /// This is the primary method for normal operations - it balances freshness
+    /// with performance by returning cached data when available.
+    ///
+    /// # Arguments
+    ///
+    /// * `storage` - Database to persist metadata to (if fetched or stale)
+    /// * `client` - HTTP client for fetching from mint (only if cache empty)
+    /// * `ttl` - Optional TTL, if not provided it is assumed that any cached data is good enough
+    ///
+    /// # Returns
+    ///
+    /// Metadata from cache if available, otherwise fresh from mint
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// // Use cached data if available, fetch if not
+    /// let metadata = cache.load(&storage, &client).await?;
+    /// ```
+    #[inline(always)]
+    pub async fn load(
+        &self,
+        storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        client: &Arc<dyn MintConnector + Send + Sync>,
+        ttl: Option<Duration>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        let cached_metadata = self.metadata.load().clone();
+        let storage_id = Self::arc_pointer_id(storage);
+
+        // Check what version of cache this database has seen
+        let db_synced_version = self
+            .db_sync_versions
+            .read()
+            .get(&storage_id)
+            .cloned()
+            .unwrap_or_default();
+
+        if cached_metadata.status.is_populated
+            && ttl
+                .map(|ttl| cached_metadata.status.updated_at + ttl > Instant::now())
+                .unwrap_or(true)
+        {
+            // Cache is ready - check if database needs updating
+            if db_synced_version != cached_metadata.status.version {
+                // Database is stale - sync in background
+                // We spawn rather than await to avoid blocking the caller
+                // and to prevent deadlocks with any existing transactions
+                self.spawn_database_sync(storage.clone(), cached_metadata.clone());
+            }
+            return Ok(cached_metadata);
+        }
+
+        // Cache not populated - fetch from mint
+        self.load_from_mint(storage, client).await
+    }
+
+    /// Load mint info and keys from storage.
+    ///
+    /// This function should be called without any competing transaction with the storage.
+    pub async fn load_from_storage(
+        &self,
+        storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+    ) -> Result<(), Error> {
+        // Load keys from database before fetching from HTTP
+        // This prevents re-fetching keys we already have and avoids duplicate insertions
+        let mut updated_metadata = (*self.metadata.load().clone()).clone();
+
+        updated_metadata.mint_info = storage
+            .get_mint(self.mint_url.clone())
+            .await?
+            .ok_or(Error::UnknownKeySet)?;
+
+        let keysets = storage
+            .get_mint_keysets(self.mint_url.clone())
+            .await?
+            .ok_or(Error::UnknownKeySet)?
+            .into_iter()
+            .map(Arc::new)
+            .collect::<Vec<_>>();
+
+        for keyset_info in keysets.iter() {
+            if let Some(keys) = storage.get_keys(&keyset_info.id).await? {
+                tracing::trace!(
+                    "Loaded keys for keyset {} from database (auth)",
+                    keyset_info.id
+                );
+                updated_metadata.keys.insert(keyset_info.id, Arc::new(keys));
+            }
+            if keyset_info.active {
+                updated_metadata.active_keysets.push(keyset_info.clone());
+            }
+        }
+
+        updated_metadata.keysets = keysets
+            .into_iter()
+            .map(|keyset| (keyset.id, keyset))
+            .collect();
+        updated_metadata.status.is_populated = true;
+        updated_metadata.status.version += 1;
+        updated_metadata.status.updated_at = Instant::now();
+
+        #[cfg(feature = "auth")]
+        {
+            updated_metadata.auth_status.is_populated = true;
+            updated_metadata.auth_status.updated_at = Instant::now();
+            updated_metadata.auth_status.version += 1;
+        }
+
+        // Update cache with database keys before HTTP fetch
+        self.metadata.store(Arc::new(updated_metadata));
+
+        Ok(())
+    }
+
+    /// Load auth keysets and keys (auth feature only)
+    ///
+    /// Fetches blind authentication keysets from the mint. Always performs
+    /// an HTTP fetch to get current auth keysets.
+    ///
+    /// # Arguments
+    ///
+    /// * `storage` - Database to persist metadata to
+    /// * `auth_client` - Auth-capable HTTP client for fetching blind auth keysets
+    ///
+    /// # Returns
+    ///
+    /// Metadata containing auth keysets and keys
+    #[cfg(feature = "auth")]
+    pub async fn load_auth(
+        &self,
+        storage: &Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        auth_client: &Arc<dyn AuthMintConnector + Send + Sync>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        let cached_metadata = self.metadata.load().clone();
+        let storage_id = Self::arc_pointer_id(storage);
+
+        let db_synced_version = self
+            .db_sync_versions
+            .read()
+            .get(&storage_id)
+            .cloned()
+            .unwrap_or_default();
+
+        // Check if auth data is populated in cache
+        if cached_metadata.auth_status.is_populated
+            && cached_metadata.auth_status.updated_at > Instant::now()
+        {
+            if db_synced_version != cached_metadata.status.version {
+                // Database needs updating - spawn background sync
+                self.spawn_database_sync(storage.clone(), cached_metadata.clone());
+            }
+            return Ok(cached_metadata);
+        }
+
+        // Acquire fetch lock to ensure only one auth fetch at a time
+        let _guard = self.fetch_lock.lock().await;
+
+        // Re-check if auth data was updated while waiting for lock
+        let current_metadata = self.metadata.load().clone();
+        if current_metadata.auth_status.is_populated
+            && current_metadata.auth_status.updated_at > Instant::now()
+        {
+            tracing::debug!(
+                "Auth cache was updated while waiting for fetch lock, returning cached data"
+            );
+            return Ok(current_metadata);
+        }
+
+        // Auth data not in cache - fetch from mint
+        let metadata = self.fetch_from_http(None, Some(auth_client)).await?;
+
+        // Spawn background task to persist
+        self.spawn_database_sync(storage.clone(), metadata.clone());
+
+        Ok(metadata)
+    }
+
+    /// Spawn a background task to sync metadata to database
+    ///
+    /// This is non-blocking and happens asynchronously. The task will:
+    /// 1. Check if this sync is still needed (version may be superseded)
+    /// 2. Save mint info, keysets, and keys to the database
+    /// 3. Update the sync tracking to record this storage has been updated
+    fn spawn_database_sync(
+        &self,
+        storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        metadata: Arc<MintMetadata>,
+    ) {
+        let mint_url = self.mint_url.clone();
+        let db_sync_versions = self.db_sync_versions.clone();
+
+        spawn(async move {
+            Self::persist_to_database(mint_url, storage, metadata, db_sync_versions).await
+        });
+    }
+
+    /// Persist metadata to database (called from background task)
+    ///
+    /// Saves mint info, keysets, and keys to the database. Checks version
+    /// before writing to avoid redundant work if a newer version has already
+    /// been persisted.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - Mint URL for database keys
+    /// * `storage` - Database to write to
+    /// * `metadata` - Metadata to persist
+    /// * `db_sync_versions` - Shared version tracker
+    async fn persist_to_database(
+        mint_url: MintUrl,
+        storage: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        metadata: Arc<MintMetadata>,
+        db_sync_versions: Arc<RwLock<HashMap<usize, usize>>>,
+    ) {
+        let storage_id = Self::arc_pointer_id(&storage);
+
+        // Check if this write is still needed
+        {
+            let mut versions = db_sync_versions.write();
+
+            let current_synced_version = versions.get(&storage_id).cloned().unwrap_or_default();
+
+            if metadata.status.version <= current_synced_version {
+                // A newer version has already been persisted - skip this write
+                return;
+            }
+
+            // Mark this version as being synced
+            versions.insert(storage_id, metadata.status.version);
+        }
+
+        // Save mint info
+        storage
+            .add_mint(mint_url.clone(), Some(metadata.mint_info.clone()))
+            .await
+            .inspect_err(|e| tracing::warn!("Failed to save mint info for {}: {}", mint_url, e))
+            .ok();
+
+        // Save all keysets
+        let keysets: Vec<_> = metadata.keysets.values().map(|ks| (**ks).clone()).collect();
+
+        if !keysets.is_empty() {
+            storage
+                .add_mint_keysets(mint_url.clone(), keysets)
+                .await
+                .inspect_err(|e| tracing::warn!("Failed to save keysets for {}: {}", mint_url, e))
+                .ok();
+        }
+
+        // Save keys for each keyset
+        for (keyset_id, keys) in &metadata.keys {
+            if let Some(keyset_info) = metadata.keysets.get(keyset_id) {
+                // Check if keys already exist in database to avoid duplicate insertion
+                if storage.get_keys(keyset_id).await.ok().flatten().is_some() {
+                    tracing::trace!(
+                        "Keys for keyset {} already in database, skipping insert",
+                        keyset_id
+                    );
+                    continue;
+                }
+
+                let keyset = KeySet {
+                    id: *keyset_id,
+                    unit: keyset_info.unit.clone(),
+                    final_expiry: keyset_info.final_expiry,
+                    keys: (**keys).clone(),
+                };
+
+                storage
+                    .add_keys(keyset)
+                    .await
+                    .inspect_err(|e| {
+                        tracing::warn!(
+                            "Failed to save keys for keyset {} at {}: {}",
+                            keyset_id,
+                            mint_url,
+                            e
+                        )
+                    })
+                    .ok();
+            }
+        }
+    }
+
+    /// Fetch fresh metadata from mint HTTP API and update cache
+    ///
+    /// Performs the following steps:
+    /// 1. Fetches mint info from server
+    /// 2. Fetches list of all keysets
+    /// 3. Fetches cryptographic keys for each keyset
+    /// 4. Verifies keyset IDs match their keys
+    /// 5. Atomically updates in-memory cache
+    ///
+    /// # Arguments
+    ///
+    /// * `client` - Optional regular mint client (for non-auth operations)
+    /// * `auth_client` - Optional auth client (for blind auth keysets)
+    ///
+    /// # Returns
+    ///
+    /// Newly fetched and cached metadata
+    async fn fetch_from_http(
+        &self,
+        client: Option<&Arc<dyn MintConnector + Send + Sync>>,
+        #[cfg(feature = "auth")] auth_client: Option<&Arc<dyn AuthMintConnector + Send + Sync>>,
+    ) -> Result<Arc<MintMetadata>, Error> {
+        tracing::debug!("Fetching mint metadata from HTTP for {}", self.mint_url);
+
+        // Start with current cache to preserve data from other sources
+        let mut new_metadata = (*self.metadata.load().clone()).clone();
+        let mut keysets_to_fetch = Vec::new();
+
+        // Fetch regular mint data
+        if let Some(client) = client.as_ref() {
+            // Get mint information
+            new_metadata.mint_info = client.get_mint_info().await.inspect_err(|err| {
+                tracing::error!("Failed to fetch mint info for {}: {}", self.mint_url, err);
+            })?;
+
+            // Get list of keysets
+            keysets_to_fetch.extend(
+                client
+                    .get_mint_keysets()
+                    .await
+                    .inspect_err(|err| {
+                        tracing::error!("Failed to fetch keysets for {}: {}", self.mint_url, err);
+                    })?
+                    .keysets,
+            );
+        }
+
+        // Fetch auth keysets if auth client provided
+        #[cfg(feature = "auth")]
+        if let Some(auth_client) = auth_client.as_ref() {
+            keysets_to_fetch.extend(auth_client.get_mint_blind_auth_keysets().await?.keysets);
+        }
+
+        tracing::debug!(
+            "Fetched {} keysets for {}",
+            keysets_to_fetch.len(),
+            self.mint_url
+        );
+
+        // Fetch keys for each keyset
+        for keyset_info in keysets_to_fetch {
+            let keyset_arc = Arc::new(keyset_info.clone());
+            new_metadata
+                .keysets
+                .insert(keyset_info.id, keyset_arc.clone());
+
+            // Track active keysets separately for quick access
+            if keyset_info.active {
+                new_metadata.active_keysets.push(keyset_arc);
+            }
+
+            // Only fetch keys if we don't already have them cached
+            if let std::collections::hash_map::Entry::Vacant(e) =
+                new_metadata.keys.entry(keyset_info.id)
+            {
+                let keyset = if let Some(client) = client.as_ref() {
+                    client.get_mint_keyset(keyset_info.id).await?
+                } else {
+                    #[cfg(feature = "auth")]
+                    if let Some(auth_client) = auth_client.as_ref() {
+                        auth_client
+                            .get_mint_blind_auth_keyset(keyset_info.id)
+                            .await?
+                    } else {
+                        return Err(Error::Internal);
+                    }
+
+                    #[cfg(not(feature = "auth"))]
+                    return Err(Error::Internal);
+                };
+
+                // Verify the keyset ID matches the keys
+                keyset.verify_id()?;
+
+                e.insert(Arc::new(keyset.keys));
+            }
+        }
+
+        // Update freshness status based on what was fetched
+        if client.is_some() {
+            new_metadata.status.is_populated = true;
+            new_metadata.status.updated_at = Instant::now();
+            new_metadata.status.version += 1;
+        }
+
+        #[cfg(feature = "auth")]
+        if auth_client.is_some() {
+            new_metadata.auth_status.is_populated = true;
+            new_metadata.auth_status.updated_at = Instant::now();
+            new_metadata.auth_status.version += 1;
+        }
+
+        tracing::info!(
+            "Updated cache for {} with {} keysets (version {})",
+            self.mint_url,
+            new_metadata.keysets.len(),
+            new_metadata.status.version
+        );
+
+        // Atomically update cache
+        let metadata_arc = Arc::new(new_metadata);
+        self.metadata.store(metadata_arc.clone());
+        Ok(metadata_arc)
+    }
+
+    /// Get the mint URL this cache manages
+    pub fn mint_url(&self) -> &MintUrl {
+        &self.mint_url
+    }
+}

+ 102 - 68
crates/cdk/src/wallet/mod.rs

@@ -1,17 +1,20 @@
 #![doc = include_str!("./README.md")]
 
 use std::collections::HashMap;
+use std::fmt::Debug;
 use std::str::FromStr;
 use std::sync::atomic::AtomicBool;
 use std::sync::Arc;
+use std::time::Duration;
 
 use cdk_common::amount::FeeAndAmounts;
 use cdk_common::database::{self, WalletDatabase};
+use cdk_common::parking_lot::RwLock;
 use cdk_common::subscription::WalletParams;
 use getrandom::getrandom;
 use subscription::{ActiveSubscription, SubscriptionManager};
 #[cfg(feature = "auth")]
-use tokio::sync::RwLock;
+use tokio::sync::RwLock as TokioRwLock;
 use tracing::instrument;
 use zeroize::Zeroize;
 
@@ -28,6 +31,7 @@ use crate::nuts::{
 };
 use crate::types::ProofInfo;
 use crate::util::unix_time;
+use crate::wallet::mint_metadata_cache::MintMetadataCache;
 use crate::Amount;
 #[cfg(feature = "auth")]
 use crate::OidcClient;
@@ -42,6 +46,7 @@ mod issue;
 mod keysets;
 mod melt;
 mod mint_connector;
+mod mint_metadata_cache;
 pub mod multi_mint_wallet;
 pub mod payment_request;
 mod proofs;
@@ -65,7 +70,7 @@ pub use mint_connector::http_client::HttpClient as BaseHttpClient;
 pub use mint_connector::transport::Transport as HttpTransport;
 #[cfg(feature = "auth")]
 pub use mint_connector::AuthHttpClient;
-pub use mint_connector::{HttpClient, MintConnector};
+pub use mint_connector::{HttpClient, LnurlPayInvoiceResponse, LnurlPayResponse, MintConnector};
 pub use multi_mint_wallet::{MultiMintReceiveOptions, MultiMintSendOptions, MultiMintWallet};
 pub use receive::ReceiveOptions;
 pub use send::{PreparedSend, SendMemo, SendOptions};
@@ -86,10 +91,13 @@ pub struct Wallet {
     pub unit: CurrencyUnit,
     /// Storage backend
     pub localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+    /// Mint metadata cache for this mint (lock-free cached access to keys, keysets, and mint info)
+    pub metadata_cache: Arc<MintMetadataCache>,
     /// The targeted amount of proofs to have at each size
     pub target_proof_count: usize,
+    metadata_cache_ttl: Arc<RwLock<Option<Duration>>>,
     #[cfg(feature = "auth")]
-    auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
+    auth_wallet: Arc<TokioRwLock<Option<AuthWallet>>>,
     seed: [u8; 64],
     client: Arc<dyn MintConnector + Send + Sync>,
     subscription: SubscriptionManager,
@@ -217,12 +225,18 @@ impl Wallet {
         proofs_per_keyset: HashMap<Id, u64>,
     ) -> Result<Amount, Error> {
         let mut fee_per_keyset = HashMap::new();
+        let metadata = self
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
+            .await?;
 
         for keyset_id in proofs_per_keyset.keys() {
-            let mint_keyset_info = self
-                .localstore
-                .get_keyset_by_id(keyset_id)
-                .await?
+            let mint_keyset_info = metadata
+                .keysets
+                .get(keyset_id)
                 .ok_or(Error::UnknownKeySet)?;
             fee_per_keyset.insert(*keyset_id, mint_keyset_info.input_fee_ppk);
         }
@@ -236,9 +250,14 @@ impl Wallet {
     #[instrument(skip_all)]
     pub async fn get_keyset_count_fee(&self, keyset_id: &Id, count: u64) -> Result<Amount, Error> {
         let input_fee_ppk = self
-            .localstore
-            .get_keyset_by_id(keyset_id)
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
             .await?
+            .keysets
+            .get(keyset_id)
             .ok_or(Error::UnknownKeySet)?
             .input_fee_ppk;
 
@@ -265,71 +284,86 @@ impl Wallet {
     /// Query mint for current mint information
     #[instrument(skip(self))]
     pub async fn fetch_mint_info(&self) -> Result<Option<MintInfo>, Error> {
-        match self.client.get_mint_info().await {
-            Ok(mint_info) => {
-                // If mint provides time make sure it is accurate
-                if let Some(mint_unix_time) = mint_info.time {
-                    let current_unix_time = unix_time();
-                    if current_unix_time.abs_diff(mint_unix_time) > 30 {
-                        tracing::warn!(
-                            "Mint time does match wallet time. Mint: {}, Wallet: {}",
-                            mint_unix_time,
-                            current_unix_time
-                        );
-                        return Err(Error::MintTimeExceedsTolerance);
-                    }
-                }
+        let mint_info = self
+            .metadata_cache
+            .load_from_mint(&self.localstore, &self.client)
+            .await?
+            .mint_info
+            .clone();
+
+        // If mint provides time make sure it is accurate
+        if let Some(mint_unix_time) = mint_info.time {
+            let current_unix_time = unix_time();
+            if current_unix_time.abs_diff(mint_unix_time) > 30 {
+                tracing::warn!(
+                    "Mint time does match wallet time. Mint: {}, Wallet: {}",
+                    mint_unix_time,
+                    current_unix_time
+                );
+                return Err(Error::MintTimeExceedsTolerance);
+            }
+        }
 
-                // Create or update auth wallet
-                #[cfg(feature = "auth")]
-                {
-                    let mut auth_wallet = self.auth_wallet.write().await;
-                    match &*auth_wallet {
-                        Some(auth_wallet) => {
-                            let mut protected_endpoints =
-                                auth_wallet.protected_endpoints.write().await;
-                            *protected_endpoints = mint_info.protected_endpoints();
-
-                            if let Some(oidc_client) = mint_info
-                                .openid_discovery()
-                                .map(|url| OidcClient::new(url, None))
-                            {
-                                auth_wallet.set_oidc_client(Some(oidc_client)).await;
-                            }
-                        }
-                        None => {
-                            tracing::info!("Mint has auth enabled creating auth wallet");
-
-                            let oidc_client = mint_info
-                                .openid_discovery()
-                                .map(|url| OidcClient::new(url, None));
-                            let new_auth_wallet = AuthWallet::new(
-                                self.mint_url.clone(),
-                                None,
-                                self.localstore.clone(),
-                                mint_info.protected_endpoints(),
-                                oidc_client,
-                            );
-                            *auth_wallet = Some(new_auth_wallet.clone());
-
-                            self.client.set_auth_wallet(Some(new_auth_wallet)).await;
-                        }
+        // Create or update auth wallet
+        #[cfg(feature = "auth")]
+        {
+            let mut auth_wallet = self.auth_wallet.write().await;
+            match &*auth_wallet {
+                Some(auth_wallet) => {
+                    let mut protected_endpoints = auth_wallet.protected_endpoints.write().await;
+                    *protected_endpoints = mint_info.protected_endpoints();
+
+                    if let Some(oidc_client) = mint_info
+                        .openid_discovery()
+                        .map(|url| OidcClient::new(url, None))
+                    {
+                        auth_wallet.set_oidc_client(Some(oidc_client)).await;
                     }
                 }
+                None => {
+                    tracing::info!("Mint has auth enabled creating auth wallet");
+
+                    let oidc_client = mint_info
+                        .openid_discovery()
+                        .map(|url| OidcClient::new(url, None));
+                    let new_auth_wallet = AuthWallet::new(
+                        self.mint_url.clone(),
+                        None,
+                        self.localstore.clone(),
+                        self.metadata_cache.clone(),
+                        mint_info.protected_endpoints(),
+                        oidc_client,
+                    );
+                    *auth_wallet = Some(new_auth_wallet.clone());
+
+                    self.client.set_auth_wallet(Some(new_auth_wallet)).await;
+                }
+            }
+        }
 
-                self.localstore
-                    .add_mint(self.mint_url.clone(), Some(mint_info.clone()))
-                    .await?;
+        tracing::trace!("Mint info updated for {}", self.mint_url);
 
-                tracing::trace!("Mint info updated for {}", self.mint_url);
+        Ok(Some(mint_info))
+    }
 
-                Ok(Some(mint_info))
-            }
-            Err(err) => {
-                tracing::warn!("Could not get mint info {}", err);
-                Ok(None)
-            }
-        }
+    /// Load mint info from cache
+    ///
+    /// This is a helper function that loads the mint info from the metadata cache
+    /// using the configured TTL. Unlike `fetch_mint_info()`, this does not make
+    /// a network call if the cache is fresh.
+    #[instrument(skip(self))]
+    pub async fn load_mint_info(&self) -> Result<MintInfo, Error> {
+        let mint_info = self
+            .metadata_cache
+            .load(&self.localstore, &self.client, {
+                let ttl = self.metadata_cache_ttl.read();
+                *ttl
+            })
+            .await?
+            .mint_info
+            .clone();
+
+        Ok(mint_info)
     }
 
     /// Get amounts needed to refill proof state

+ 4 - 30
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -11,6 +11,7 @@ use std::sync::Arc;
 use anyhow::Result;
 use cdk_common::database;
 use cdk_common::database::WalletDatabase;
+use cdk_common::task::spawn;
 use cdk_common::wallet::{Transaction, TransactionDirection};
 use tokio::sync::RwLock;
 use tracing::instrument;
@@ -914,20 +915,7 @@ impl MultiMintWallet {
             let target_mint_url = target_mint_url.clone();
 
             // Spawn parallel transfer task
-            #[cfg(not(target_arch = "wasm32"))]
-            let task = tokio::spawn(async move {
-                self_clone
-                    .transfer(
-                        &source_mint_url,
-                        &target_mint_url,
-                        TransferMode::ExactReceive(transfer_amount),
-                    )
-                    .await
-                    .map(|result| result.amount_received)
-            });
-
-            #[cfg(target_arch = "wasm32")]
-            let task = tokio::task::spawn_local(async move {
+            let task = spawn(async move {
                 self_clone
                     .transfer(
                         &source_mint_url,
@@ -1342,14 +1330,7 @@ impl MultiMintWallet {
             let amount_msat = u64::from(amount) * 1000;
             let options = Some(MeltOptions::new_mpp(amount_msat));
 
-            #[cfg(not(target_arch = "wasm32"))]
-            let task = tokio::spawn(async move {
-                let quote = wallet.melt_quote(bolt11_clone, options).await;
-                (mint_url_clone, quote)
-            });
-
-            #[cfg(target_arch = "wasm32")]
-            let task = tokio::task::spawn_local(async move {
+            let task = spawn(async move {
                 let quote = wallet.melt_quote(bolt11_clone, options).await;
                 (mint_url_clone, quote)
             });
@@ -1398,14 +1379,7 @@ impl MultiMintWallet {
 
             let mint_url_clone = mint_url.clone();
 
-            #[cfg(not(target_arch = "wasm32"))]
-            let task = tokio::spawn(async move {
-                let melted = wallet.melt(&quote_id).await;
-                (mint_url_clone, melted)
-            });
-
-            #[cfg(target_arch = "wasm32")]
-            let task = tokio::task::spawn_local(async move {
+            let task = spawn(async move {
                 let melted = wallet.melt(&quote_id).await;
                 (mint_url_clone, melted)
             });

+ 6 - 3
crates/cdk/src/wallet/receive.rs

@@ -28,8 +28,6 @@ impl Wallet {
     ) -> Result<Amount, Error> {
         let mint_url = &self.mint_url;
 
-        self.refresh_keysets().await?;
-
         let active_keyset_id = self.fetch_active_keyset().await?.id;
 
         let keys = self.load_keyset_keys(active_keyset_id).await?;
@@ -128,7 +126,12 @@ impl Wallet {
             }
         }
 
-        let swap_response = self.client.post_swap(pre_swap.swap_request).await?;
+        let swap_response = self
+            .try_proof_operation_or_reclaim(
+                pre_swap.swap_request.inputs().clone(),
+                self.client.post_swap(pre_swap.swap_request),
+            )
+            .await?;
 
         // Proof to keep
         let recv_proofs = construct_proofs(

+ 2 - 8
crates/cdk/src/wallet/swap.rs

@@ -21,8 +21,6 @@ impl Wallet {
         spending_conditions: Option<SpendingConditions>,
         include_fees: bool,
     ) -> Result<Option<Proofs>, Error> {
-        self.refresh_keysets().await?;
-
         tracing::info!("Swapping");
         let mint_url = &self.mint_url;
         let unit = &self.unit;
@@ -49,11 +47,7 @@ impl Wallet {
             .get_keyset_fees_and_amounts_by_id(active_keyset_id)
             .await?;
 
-        let active_keys = self
-            .localstore
-            .get_keys(&active_keyset_id)
-            .await?
-            .ok_or(Error::NoActiveKeyset)?;
+        let active_keys = self.load_keyset_keys(active_keyset_id).await?;
 
         let post_swap_proofs = construct_proofs(
             swap_response.signatures,
@@ -175,7 +169,7 @@ impl Wallet {
         ensure_cdk!(proofs_sum >= amount, Error::InsufficientFunds);
 
         let active_keyset_ids = self
-            .refresh_keysets()
+            .get_mint_keysets()
             .await?
             .active()
             .map(|k| k.id)

+ 18 - 18
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
     "crane": {
       "locked": {
-        "lastModified": 1760924934,
-        "narHash": "sha256-tuuqY5aU7cUkR71sO2TraVKK2boYrdW3gCSXUkF4i44=",
+        "lastModified": 1762538466,
+        "narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=",
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "c6b4d5308293d0d04fcfeee92705017537cad02f",
+        "rev": "0cea393fffb39575c46b7a0318386467272182fe",
         "type": "github"
       },
       "original": {
@@ -23,11 +23,11 @@
         "rust-analyzer-src": []
       },
       "locked": {
-        "lastModified": 1761287960,
-        "narHash": "sha256-DbGYVbF0TgoKTFNQv/3jqaUWql8OYzewl4v2gw6jmQs=",
+        "lastModified": 1762929886,
+        "narHash": "sha256-TQZ3Ugb1FoHpTSc8KLrzN4njIZU4FemAMHyS4M3mt6s=",
         "owner": "nix-community",
         "repo": "fenix",
-        "rev": "64cb168ed9ec61ef18e28f1e4ee9f44381d6ecd2",
+        "rev": "6998514dce2c365142a0a119a95ef95d89b84086",
         "type": "github"
       },
       "original": {
@@ -93,11 +93,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1761173472,
-        "narHash": "sha256-m9W0dYXflzeGgKNravKJvTMR4Qqa2MVD11AwlGMufeE=",
+        "lastModified": 1762756533,
+        "narHash": "sha256-HiRDeUOD1VLklHeOmaKDzf+8Hb7vSWPVFcWwaTrpm+U=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "c8aa8cc00a5cb57fada0851a038d35c08a36a2bb",
+        "rev": "c2448301fb856e351aab33e64c33a3fc8bcf637d",
         "type": "github"
       },
       "original": {
@@ -109,11 +109,11 @@
     },
     "nixpkgs_2": {
       "locked": {
-        "lastModified": 1759070547,
-        "narHash": "sha256-JVZl8NaVRYb0+381nl7LvPE+A774/dRpif01FKLrYFQ=",
+        "lastModified": 1759417375,
+        "narHash": "sha256-O7eHcgkQXJNygY6AypkF9tFhsoDQjpNEojw3eFs73Ow=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "647e5c14cbd5067f44ac86b74f014962df460840",
+        "rev": "dc704e6102e76aad573f63b74c742cd96f8f1e6c",
         "type": "github"
       },
       "original": {
@@ -130,11 +130,11 @@
         "nixpkgs": "nixpkgs_2"
       },
       "locked": {
-        "lastModified": 1760663237,
-        "narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
+        "lastModified": 1762868777,
+        "narHash": "sha256-QqS72GvguP56oKDNUckWUPNJHjsdeuXh5RyoKz0wJ+E=",
         "owner": "cachix",
         "repo": "pre-commit-hooks.nix",
-        "rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
+        "rev": "c5c3147730384576196fb5da048a6e45dee10d56",
         "type": "github"
       },
       "original": {
@@ -160,11 +160,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1761273263,
-        "narHash": "sha256-6d6ojnu6A6sVxIjig8OL6E1T8Ge9st3YGgVwg5MOY+Q=",
+        "lastModified": 1762915112,
+        "narHash": "sha256-d9j1g8nKmYDHy+/bIOPQTh9IwjRliqaTM0QLHMV92Ic=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "28405834d4fdd458d28e123fae4db148daecec6f",
+        "rev": "aa1e85921cfa04de7b6914982a94621fbec5cc02",
         "type": "github"
       },
       "original": {

+ 13 - 3
flake.nix

@@ -56,7 +56,7 @@
 
         # Toolchains
         # latest stable
-        stable_toolchain = pkgs.rust-bin.stable."1.90.0".default.override {
+        stable_toolchain = pkgs.rust-bin.stable."1.91.1".default.override {
           targets = [ "wasm32-unknown-unknown" ]; # wasm
           extensions = [
             "rustfmt"
@@ -109,9 +109,11 @@
             clightning
             bitcoind
             sqlx-cli
-            cargo-outdated
             mprocs
 
+            cargo-outdated
+            cargo-mutants
+
             # Needed for github ci
             libz
           ]
@@ -184,7 +186,15 @@
 
             stable = pkgs.mkShell (
               {
-                shellHook = ''${_shellHook}'';
+                shellHook = ''
+                  ${_shellHook}
+                  # Needed for github ci
+                  export LD_LIBRARY_PATH=${
+                    pkgs.lib.makeLibraryPath [
+                      pkgs.zlib
+                    ]
+                  }:$LD_LIBRARY_PATH
+                '';
                 buildInputs = buildInputs ++ [ stable_toolchain ];
                 inherit nativeBuildInputs;
 

+ 84 - 5
justfile

@@ -59,7 +59,7 @@ test:
   if [ ! -f Cargo.toml ]; then
     cd {{invocation_directory()}}
   fi
-  cargo test --lib
+  cargo test --lib --workspace --exclude cdk-postgres
 
   # Run pure integration tests
   cargo test -p cdk-integration-tests --test mint 
@@ -87,10 +87,89 @@ test-all db="memory":
     ./misc/fake_itests.sh "{{db}}" external_signatory
     ./misc/fake_itests.sh "{{db}}"
     
+# Mutation Testing Commands
+
+# Run mutation tests on a specific crate
+# Usage: just mutants <crate-name>
+# Example: just mutants cashu
+mutants CRATE:
+  #!/usr/bin/env bash
+  set -euo pipefail
+  echo "Running mutation tests on crate: {{CRATE}}"
+  cargo mutants --package {{CRATE}} -vV
+
+# Run mutation tests on the cashu crate
+mutants-cashu:
+  #!/usr/bin/env bash
+  set -euo pipefail
+  echo "Running mutation tests on cashu crate..."
+  cargo mutants --package cashu -vV
+
+# Run mutation tests on the cdk crate
+mutants-cdk:
+  #!/usr/bin/env bash
+  set -euo pipefail
+  echo "Running mutation tests on cdk crate..."
+  cargo mutants --package cdk -vV
+
+# Run mutation tests on entire workspace (WARNING: very slow)
+mutants-all:
+  #!/usr/bin/env bash
+  set -euo pipefail
+  echo "Running mutation tests on entire workspace..."
+  echo "WARNING: This may take a very long time!"
+  cargo mutants -vV
+
+# Quick mutation test for current work (alias for mutants-diff)
+mutants-quick:
+  #!/usr/bin/env bash
+  set -euo pipefail
+  echo "Running mutations on changed files since HEAD..."
+  cargo mutants --in-diff HEAD -vV
+
+# Run mutation tests only on changed code since HEAD
+mutants-diff:
+  #!/usr/bin/env bash
+  set -euo pipefail
+  echo "Running mutation tests on changed code..."
+  cargo mutants --in-diff HEAD -vV
+
+# Run mutation tests and save output to log file
+# Usage: just mutants-log <crate-name> <log-suffix>
+# Example: just mutants-log cashu baseline
+mutants-log CRATE SUFFIX:
+  #!/usr/bin/env bash
+  set -euo pipefail
+  if [ ! -f Cargo.toml ]; then
+    cd {{invocation_directory()}}
+  fi
+  LOG_FILE="mutants-{{CRATE}}-{{SUFFIX}}.log"
+  echo "Running mutation tests on {{CRATE}}, saving to $LOG_FILE..."
+  cargo mutants --package {{CRATE}} -vV 2>&1 | tee "$LOG_FILE"
+  echo "Results saved to $LOG_FILE"
+
+# Mutation test with baseline comparison
+# Usage: just mutants-check <crate-name>
+# Example: just mutants-check cashu
+mutants-check CRATE:
+  #!/usr/bin/env bash
+  set -euo pipefail
+  BASELINE="mutants-{{CRATE}}-baseline.log"
+  if [ ! -f "$BASELINE" ]; then
+    echo "ERROR: No baseline found at $BASELINE"
+    echo "Run: just mutants-log {{CRATE}} baseline"
+    exit 1
+  fi
+  cargo mutants --package {{CRATE}} -vV | tee mutants-{{CRATE}}-current.log
+  # Compare results
+  echo "=== Baseline vs Current ==="
+  diff <(grep "^CAUGHT\|^MISSED" "$BASELINE" | wc -l) \
+       <(grep "^CAUGHT\|^MISSED" mutants-{{CRATE}}-current.log | wc -l) || true
+
 test-nutshell:
   #!/usr/bin/env bash
   set -euo pipefail
-  
+
   # Function to cleanup docker containers
   cleanup() {
     echo "Cleaning up docker containers..."
@@ -155,11 +234,11 @@ test-nutshell:
     
 
 # run `cargo clippy` on everything
-clippy *ARGS="--locked --offline --workspace --all-targets":
-  cargo clippy {{ARGS}}
+clippy *ARGS="--workspace --all-targets":
+  cargo clippy {{ARGS}} -- -D warnings
 
 # run `cargo clippy --fix` on everything
-clippy-fix *ARGS="--locked --offline --workspace --all-targets":
+clippy-fix *ARGS="--workspace --all-targets":
   cargo clippy {{ARGS}} --fix
 
 typos: 

+ 54 - 0
meetings/2025-11-12-agenda.md

@@ -0,0 +1,54 @@
+# CDK Development Meeting
+
+Nov 12 2025 15:00 UTC
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+## Merged
+
+- [#1258](https://github.com/cashubtc/cdk/pull/1258) - mint async melt
+- [#1256](https://github.com/cashubtc/cdk/pull/1256) - refactor: replace proof swap with state check in error recovery
+- [#1254](https://github.com/cashubtc/cdk/pull/1254) - Weekly Meeting Agenda - 2025-11-05
+- [#1247](https://github.com/cashubtc/cdk/pull/1247) - feat: add keyset_amounts table to track issued and redeemed amounts
+
+## Ongoing
+
+- [#1253](https://github.com/cashubtc/cdk/pull/1253) - feat: P2BK
+- [#1252](https://github.com/cashubtc/cdk/pull/1252) - Fix race condition when concurrent payments are processed for the same payment_id
+- [#1251](https://github.com/cashubtc/cdk/pull/1251) - feat: custom axum router
+- [#1240](https://github.com/cashubtc/cdk/pull/1240) - Introduce MintMetadataCache for efficient key and metadata management
+- [#1214](https://github.com/cashubtc/cdk/pull/1214) - feat(cdk-payment-processor): add currency unit parameter to make_payment
+- [#1212](https://github.com/cashubtc/cdk/pull/1212) - Various mint bugfixes for swap and melt. SIG_INPUTS+SIG_ALL, locktimes, P2PK+HTLC. Also updates the SIG_ALL message for amount-switching
+- [#1211](https://github.com/cashubtc/cdk/pull/1211) - Regtest setup
+- [#1210](https://github.com/cashubtc/cdk/pull/1210) - test: add mutation testing infrastructure
+- [#1208](https://github.com/cashubtc/cdk/pull/1208) - Sig all fixes
+- [#1204](https://github.com/cashubtc/cdk/pull/1204) - Add database transaction trait for cdk wallet
+- [#1202](https://github.com/cashubtc/cdk/pull/1202) - Onchain
+- [#1201](https://github.com/cashubtc/cdk/pull/1201) - feat: npubcash
+- [#1200](https://github.com/cashubtc/cdk/pull/1200) - feat: optimize pending mint quotes query performance
+- [#1198](https://github.com/cashubtc/cdk/pull/1198) - fix: check the removed_ys argument before creating the delete query
+- [#1196](https://github.com/cashubtc/cdk/pull/1196) - feat(ci): add merge queue workflow and simplify clippy checks
+- [#1190](https://github.com/cashubtc/cdk/pull/1190) - feat(cashu): add NUT-26 bech32m encoding for payment requests
+- [#1182](https://github.com/cashubtc/cdk/pull/1182) - ehash: add support for mining share mint quotes
+- [#1181](https://github.com/cashubtc/cdk/pull/1181) - Deterministic Currency Unit Derivation Paths
+- [#1171](https://github.com/cashubtc/cdk/pull/1171) - Add Dart Bindings Support
+- [#1153](https://github.com/cashubtc/cdk/pull/1153) - Add cdk-mintd module and package
+- [#1132](https://github.com/cashubtc/cdk/pull/1132) - Quote id as lookup
+- [#1127](https://github.com/cashubtc/cdk/pull/1127) - rename ln settings in toml configuration
+- [#1118](https://github.com/cashubtc/cdk/pull/1118) - feat: uniffi bindings for golang
+- [#1100](https://github.com/cashubtc/cdk/pull/1100) - NUT-XX: Cairo Spending Conditions implementation
+- [#1067](https://github.com/cashubtc/cdk/pull/1067) - Nutxx ohttp
+- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive
+- [#1049](https://github.com/cashubtc/cdk/pull/1049) - feat: ldk-node run mintd
+- [#1011](https://github.com/cashubtc/cdk/pull/1011) - fix: migrate check_mint_quote_paid fn from ln.rs to mod.rs
+
+## New
+
+### Issues
+
+- [#1259](https://github.com/cashubtc/cdk/issues/1259) - Suggested changes in the payment_processor.proto declaration
+
+### PRs
+
+- [#1260](https://github.com/cashubtc/cdk/pull/1260) - feat(ci): add nightly rustfmt automation with flexible formatting policy
+- [#1257](https://github.com/cashubtc/cdk/pull/1257) - bring signatory up to date with the remote signer spec

+ 15 - 4
misc/fake_itests.sh

@@ -175,7 +175,7 @@ done
 
 # Run first test
 echo "Running fake_wallet test"
-cargo test -p cdk-integration-tests --test fake_wallet
+cargo test -p cdk-integration-tests --test fake_wallet -- --nocapture
 status1=$?
 
 # Exit immediately if the first test failed
@@ -186,15 +186,26 @@ fi
 
 # Run second test only if the first one succeeded
 echo "Running happy_path_mint_wallet test"
-cargo test -p cdk-integration-tests --test happy_path_mint_wallet
+cargo test -p cdk-integration-tests --test happy_path_mint_wallet --  --nocapture
 status2=$?
 
-# Exit with the status of the second test
+# Exit if the second test failed
 if [ $status2 -ne 0 ]; then
     echo "Second test failed with status $status2, exiting"
     exit $status2
 fi
 
-# Both tests passed
+# Run third test (async_melt) only if previous tests succeeded
+echo "Running async_melt test"
+cargo test -p cdk-integration-tests --test async_melt
+status3=$?
+
+# Exit with the status of the third test
+if [ $status3 -ne 0 ]; then
+    echo "Third test (async_melt) failed with status $status3, exiting"
+    exit $status3
+fi
+
+# All tests passed
 echo "All tests passed successfully"
 exit 0

+ 1 - 1
rust-toolchain.toml

@@ -1,4 +1,4 @@
 [toolchain]
-channel="1.90.0"
+channel="1.91.1"
 components = ["rustfmt", "clippy", "rust-analyzer"]
 

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