瀏覽代碼

Merge branch 'feature/wallet-db-transactions' into go-ffi

Cesar Rodas 2 月之前
父節點
當前提交
0e92f80cd8
共有 100 個文件被更改,包括 9062 次插入3263 次删除
  1. 9 0
      .cargo-mutants.toml
  2. 39 0
      .cargo/mutants.toml
  3. 58 0
      .github/ISSUE_TEMPLATE/mutation-testing.md
  4. 36 10
      .github/scripts/generate-agenda.sh
  5. 19 91
      .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. 105 0
      CHANGELOG.md
  11. 20 20
      Cargo.toml
  12. 61 0
      DEVELOPMENT.md
  13. 345 0
      crates/cashu/src/amount.rs
  14. 164 0
      crates/cashu/src/dhke.rs
  15. 1 1
      crates/cashu/src/nuts/mod.rs
  16. 8 8
      crates/cashu/src/nuts/nut00/mod.rs
  17. 26 0
      crates/cashu/src/nuts/nut03.rs
  18. 39 1
      crates/cashu/src/nuts/nut05.rs
  19. 473 0
      crates/cashu/src/nuts/nut10.rs
  20. 556 433
      crates/cashu/src/nuts/nut11/mod.rs
  21. 153 0
      crates/cashu/src/nuts/nut12.rs
  22. 374 66
      crates/cashu/src/nuts/nut14/mod.rs
  23. 3 104
      crates/cashu/src/nuts/nut23.rs
  24. 96 3
      crates/cdk-axum/src/router_handlers.rs
  25. 14 9
      crates/cdk-cli/src/sub_commands/cat_device_login.rs
  26. 10 12
      crates/cdk-cli/src/sub_commands/cat_login.rs
  27. 37 0
      crates/cdk-cln/README.md
  28. 4 4
      crates/cdk-common/src/common.rs
  29. 10 14
      crates/cdk-common/src/database/mint/mod.rs
  30. 7 2
      crates/cdk-common/src/database/mint/test/mod.rs
  31. 23 7
      crates/cdk-common/src/database/mod.rs
  32. 104 45
      crates/cdk-common/src/database/wallet.rs
  33. 10 0
      crates/cdk-common/src/error.rs
  34. 3 0
      crates/cdk-common/src/lib.rs
  35. 72 11
      crates/cdk-common/src/mint.rs
  36. 3 14
      crates/cdk-common/src/pub_sub/pubsub.rs
  37. 2 8
      crates/cdk-common/src/pub_sub/remote_consumer.rs
  38. 25 0
      crates/cdk-common/src/task.rs
  39. 2 0
      crates/cdk-ffi/Cargo.toml
  40. 1074 214
      crates/cdk-ffi/src/database.rs
  41. 1 0
      crates/cdk-ffi/src/lib.rs
  42. 195 1
      crates/cdk-ffi/src/multi_mint_wallet.rs
  43. 53 320
      crates/cdk-ffi/src/postgres.rs
  44. 28 289
      crates/cdk-ffi/src/sqlite.rs
  45. 96 0
      crates/cdk-ffi/src/types/invoice.rs
  46. 4 4
      crates/cdk-ffi/src/types/mint.rs
  47. 2 0
      crates/cdk-ffi/src/types/mod.rs
  48. 91 1
      crates/cdk-ffi/src/wallet.rs
  49. 14 0
      crates/cdk-integration-tests/src/init_pure_tests.rs
  50. 13 40
      crates/cdk-integration-tests/src/lib.rs
  51. 146 0
      crates/cdk-integration-tests/tests/async_melt.rs
  52. 8 3
      crates/cdk-integration-tests/tests/bolt12.rs
  53. 3 5
      crates/cdk-integration-tests/tests/fake_auth.rs
  54. 197 29
      crates/cdk-integration-tests/tests/fake_wallet.rs
  55. 189 31
      crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs
  56. 73 3
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  57. 177 2
      crates/cdk-integration-tests/tests/mint.rs
  58. 8 0
      crates/cdk-integration-tests/tests/test_fees.rs
  59. 311 4
      crates/cdk-integration-tests/tests/test_swap_flow.rs
  60. 4 2
      crates/cdk-ldk-node/src/lib.rs
  61. 7 1
      crates/cdk-ldk-node/src/web/handlers/payments.rs
  62. 45 0
      crates/cdk-lnbits/README.md
  63. 4 3
      crates/cdk-lnbits/src/lib.rs
  64. 38 0
      crates/cdk-lnd/README.md
  65. 14 5
      crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs
  66. 2 2
      crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto
  67. 8 6
      crates/cdk-mint-rpc/src/proto/server.rs
  68. 13 5
      crates/cdk-mintd/README.md
  69. 14 11
      crates/cdk-mintd/example.config.toml
  70. 456 45
      crates/cdk-mintd/src/config.rs
  71. 10 5
      crates/cdk-mintd/src/lib.rs
  72. 35 0
      crates/cdk-mintd/src/setup.rs
  73. 555 452
      crates/cdk-redb/src/wallet/mod.rs
  74. 0 1
      crates/cdk-signatory/src/common.rs
  75. 1 2
      crates/cdk-signatory/src/signatory.rs
  76. 2 3
      crates/cdk-sql-common/build.rs
  77. 2 0
      crates/cdk-sql-common/src/mint/auth/migrations/postgres/20251122000000_drop_max_order.sql
  78. 28 0
      crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20251122000000_drop_max_order.sql
  79. 6 6
      crates/cdk-sql-common/src/mint/auth/mod.rs
  80. 2 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql
  81. 25 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251102000000_create_keyset_amounts.sql
  82. 2 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251122000000_drop_max_order.sql
  83. 2 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql
  84. 30 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251102000000_create_keyset_amounts.sql
  85. 30 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251122000000_drop_max_order.sql
  86. 327 353
      crates/cdk-sql-common/src/mint/mod.rs
  87. 12 0
      crates/cdk-sql-common/src/stmt.rs
  88. 15 0
      crates/cdk-sql-common/src/wallet/migrations/postgres/20251111000000_keyset_counter_table.sql
  89. 36 0
      crates/cdk-sql-common/src/wallet/migrations/sqlite/20251111000000_keyset_counter_table.sql
  90. 826 541
      crates/cdk-sql-common/src/wallet/mod.rs
  91. 2 0
      crates/cdk-sqlite/src/lib.rs
  92. 104 4
      crates/cdk-sqlite/src/wallet/mod.rs
  93. 4 0
      crates/cdk/Cargo.toml
  94. 6 1
      crates/cdk/examples/auth_wallet.rs
  95. 300 0
      crates/cdk/examples/human_readable_payment.rs
  96. 10 2
      crates/cdk/examples/p2pk.rs
  97. 1 1
      crates/cdk/examples/proof-selection.rs
  98. 132 0
      crates/cdk/src/fees.rs
  99. 129 0
      crates/cdk/src/invoice.rs
  100. 7 3
      crates/cdk/src/lib.rs

+ 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 -->

+ 36 - 10
.github/scripts/generate-agenda.sh

@@ -9,7 +9,7 @@ OUTPUT_DIR="meetings"
 
 # Calculate date range (last 7 days)
 SINCE_DATE=$(date -d "$DAYS_BACK days ago" -u +"%Y-%m-%dT%H:%M:%SZ")
-MEETING_DATE=$(date -u +"%b %d %Y %H:%M UTC")
+MEETING_DATE=$(date -u +"%b %d %Y 15:00 UTC")
 FILE_DATE=$(date -u +"%Y-%m-%d")
 
 echo "Generating meeting agenda for $MEETING_DATE"
@@ -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
 )
 

+ 19 - 91
.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"
@@ -137,7 +132,8 @@ jobs:
             
             # FFI bindings
             -p cdk-ffi,
-            
+            -p cdk-ffi --no-default-features,
+
             # Binaries
             --bin cdk-cli,
             --bin cdk-cli --features sqlcipher,
@@ -381,61 +377,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 +406,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 +501,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 +535,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

+ 105 - 0
CHANGELOG.md

@@ -4,6 +4,109 @@
 <!-- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -->
 <!-- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -->
 
+
+## [0.14.0](https://github.com/cashubtc/cdk/releases/tag/v0.14.0)
+
+### Summary
+
+This release focuses on reliability and robustness improvements across the codebase. The mint now implements saga patterns for both melt and swap operations, providing better error recovery and state consistency during these critical operations. Async melt processing has been added for improved throughput. The wallet gains a new Tor mint connector with isolated circuits support for enhanced privacy when communicating with mints, along with a MintMetadataCache that delivers significant performance improvements for key and metadata management. A new proof recovery mechanism automatically handles failed wallet operations. MultiMintWallet receives improvements including the ability to check and wait for mint quotes and configure internal wallets. NUT-11 SIG_ALL message aggregation has been updated to match the latest specification. On the infrastructure side, a generic pubsub module has been introduced in cdk-common, and cdk-ffi adds postgres support. Additional highlights include keyset amount tracking and SQL balance calculation optimization for improved performance, wallet functions to pay human readable addresses (BIP353 and Lightning address), invoice decoding for BOLT11 and BOLT12 in the FFI bindings, and a mutation testing infrastructure to ensure security-critical code coverage. The release also brings numerous bug fixes addressing database contention, HTLC witness handling, and quote state management.
+
+### Added
+- cdk: Add wallet functions to pay human readable addresses (BIP353 and Lightning address) ([thesimplekid]).
+- cdk-ffi: Add invoice decoding for bolt11 and bolt12 ([thesimplekid]).
+- cdk: Add keyset_amounts table to track issued and redeemed amounts for improved performance ([crodas]).
+- cdk: Add melt quote state transition validation ([thesimplekid]).
+- cdk: Add payment request and proof to transaction records ([thesimplekid]).
+- cdk: Add WebSocket authentication support ([thesimplekid]).
+- cdk: Add proof recovery mechanism for failed wallet operations ([crodas]).
+- cdk-ffi: Added postgres support ([asmo]).
+- cdk: Optimize SQL balance calculation ([vnprc]).
+- cdk: Add tor mint connector for wallet with isolated circuits support ([lollerfirst]).
+- cdk-common: Introduce a generic pubsub module ([crodas]).
+- cdk: Add MultiMintWallet check and wait for mint quotes ([davidcaseria]).
+- cdk: Allow passing metadata to a melt ([benthecarman]).
+- cashu: Include supported amounts instead of assuming the power of 2 ([crodas]).
+- test: Add mutation testing infrastructure and security-critical coverage ([thesimplekid]).
+
+### Changed
+- cdk: Introduce MintMetadataCache for efficient key and metadata management ([crodas]).
+- cdk: Implement saga pattern for melt operations ([thesimplekid]).
+- cdk: Implement saga pattern for swap operations ([thesimplekid]).
+- cdk: Async melt processing ([thesimplekid]).
+- cdk: Extract keyset key loading into helper method ([thesimplekid]).
+- cdk: Update Wallet::fetch_mint_info ([crodas]).
+- cdk-ffi: Update FFI Database Objects to Records ([davidcaseria]).
+- cdk: Redesign Lightning invoice creation and display with better UX and status handling ([erik]).
+- cdk: Configure internal Wallets of a MultiMintWallet ([davidcaseria]).
+- cdk-ffi: Split uniffi types into multiple mods ([davidcaseria]).
+- cdk-ffi: Make Uniffi Records Codable in Swift ([davidcaseria]).
+- cdk: Replace proof swap with state check in error recovery ([crodas]).
+- cdk: Simplify mint addition in MultiMintWallet ([thesimplekid]).
+- cdk: Update NUT-11 SIG_ALL message aggregation per spec ([SatsAndSports]).
+- cdk: Remove delete functions for quotes ([thesimplekid]).
+
+### Fixed
+- cdk: Enable pure environment variable configuration for Lightning backends ([thesimplekid]).
+- cdk: Prevent database contention in metadata cache load operations ([crodas]).
+- cdk: Allow starting insecure mint server ([thesimplekid]).
+- cdk: Load keyset keys from database to prevent duplicate insertions ([thesimplekid]).
+- cdk: Fix missing try_proof_operation_or_reclaim wrapping of a swap ([crodas]).
+- cdk: Don't read keys from the database unnecessarily ([crodas]).
+- cdk: Return actual error from get_payment_quote ([gudnuf]).
+- cdk: Require 0 signatures for HTLC with no pubkeys specified ([thesimplekid]).
+- cdk: Fix NUT-14 disabled in info ([thesimplekid]).
+- cdk: Check the removed_ys argument before creating the delete query ([asmo]).
+- cdk: Add parent directory validation before database creation ([thesimplekid]).
+- cashu: Skip serializing empty NUT15 settings in mint info ([thesimplekid]).
+- cdk: Fix bug with websocket close ([crodas]).
+- cdk: Fix htlc witness deserialization ([stefanbitcr]).
+- cdk-lnbits: Fix msats error handling ([thesimplekid]).
+- cdk: Handle fiat melt amount conversions ([gudnuf]).
+- cdk: Only settle same unit quote internally ([gudnuf]).
+- cdk: Revert redis cache removal ([thesimplekid]).
+- cdk: Improve add transaction handling ([thesimplekid]).
+- cdk: Read the latest mint quote status in a transaction to avoid race conditions ([crodas]).
+- cdk: Fix websocket issues and mint quotes ([crodas]).
+- cashu: Fix PreMintSecrets into_iter() ([codingpeanut157]).
+
+## [0.13.4](https://github.com/cashubtc/cdk/releases/tag/v0.13.4)
+
+### Added
+- cdk-lnbits: Update LNbits integration ([thesimplekid]).
+- cdk: Clean witness data ([thesimplekid]).
+
+## [0.13.3](https://github.com/cashubtc/cdk/releases/tag/v0.13.3)
+
+### Fixed
+- cdk-lnbits: Fix lnbits fee calc ([thesimplekid]).
+
+## [0.13.2](https://github.com/cashubtc/cdk/releases/tag/v0.13.2)
+
+### Added
+- cashu: Add spending-condition inspection helpers and token_secrets() ([lollerfirst]).
+
+### Changed
+- cdk: Make sorting Transactions a stable sort ([benthecarman]).
+- Updated stable Rust to 1.85.0 ([thesimplekid]).
+
+### Fixed
+- cdk-lnbits: Add websocket reconnection with exponential backoff ([thesimplekid]).
+- cdk: Add parent directory validation before database creation ([thesimplekid]).
+- cashu: Skip serializing empty NUT15 settings in mint info ([lollerfirst]).
+- cdk: Improve Melted error handling and add debug logging ([thesimplekid]).
+- cdk: Read the latest mint quote status in a transaction to avoid race conditions ([crodas]).
+
+## [0.13.1](https://github.com/cashubtc/cdk/releases/tag/v0.13.1)
+
+### Fixed
+- cdk: Only settle same unit quote internally ([gudnuf]).
+- cdk-cli: Show amounts correctly ([thesimplekid]).
+- cdk-lnbits: Fix msats error handling ([thesimplekid]).
+
+### Changed
+- cdk: Simplify mint addition in MultiMintWallet by removing unnecessary mint info fetching and keyset refresh ([thesimplekid]).
+- cdk-ffi: Make UniFFI Records Codable in Swift ([davidcaseria]).
+
 ## [0.13.0](https://github.com/cashubtc/cdk/releases/tag/v0.13.0)
 
 ### Summary
@@ -571,3 +674,5 @@ Additionally, this release introduces a Mint binary cdk-mintd that uses the cdk-
 [gudnuf]: https://github.com/gudnuf
 [codingpeanut157]: https://github.com/codingpeanut157
 [erik]: https://github.com/swedishfrenchpress
+[SatsAndSports]: https://github.com/SatsAndSports
+[stefanbitcr]: https://github.com/stefanbitcr

+ 20 - 20
Cargo.toml

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

+ 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;

+ 8 - 8
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),
+            },
         }
     }
 
@@ -930,7 +927,10 @@ impl Iterator for PreMintSecrets {
 
     fn next(&mut self) -> Option<Self::Item> {
         // Use the iterator of the vector
-        self.secrets.pop()
+        if self.secrets.is_empty() {
+            return None;
+        }
+        Some(self.secrets.remove(0))
     }
 }
 

+ 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

文件差異過大導致無法顯示
+ 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"
+        );
+    }
+}

+ 3 - 104
crates/cashu/src/nuts/nut23.rs

@@ -5,8 +5,7 @@ use std::str::FromStr;
 
 use lightning_invoice::Bolt11Invoice;
 use serde::de::DeserializeOwned;
-use serde::{Deserialize, Deserializer, Serialize};
-use serde_json::Value;
+use serde::{Deserialize, Serialize};
 use thiserror::Error;
 
 use super::{BlindSignature, CurrencyUnit, MeltQuoteState, Mpp, PublicKey};
@@ -239,9 +238,9 @@ impl MeltQuoteBolt11Request {
 }
 
 /// Melt quote response [NUT-05]
-#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
 #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))]
-#[serde(bound = "Q: Serialize")]
+#[serde(bound = "Q: Serialize + DeserializeOwned")]
 pub struct MeltQuoteBolt11Response<Q> {
     /// Quote Id
     pub quote: Q,
@@ -249,10 +248,6 @@ pub struct MeltQuoteBolt11Response<Q> {
     pub amount: Amount,
     /// The fee reserve that is required
     pub fee_reserve: Amount,
-    /// Whether the request haas be paid
-    // TODO: To be deprecated
-    /// Deprecated
-    pub paid: Option<bool>,
     /// Quote State
     pub state: MeltQuoteState,
     /// Unix timestamp until the quote is valid
@@ -281,7 +276,6 @@ impl<Q: ToString> MeltQuoteBolt11Response<Q> {
             quote: self.quote.to_string(),
             amount: self.amount,
             fee_reserve: self.fee_reserve,
-            paid: self.paid,
             state: self.state,
             expiry: self.expiry,
             payment_preimage: self.payment_preimage,
@@ -299,7 +293,6 @@ impl From<MeltQuoteBolt11Response<QuoteId>> for MeltQuoteBolt11Response<String>
             quote: value.quote.to_string(),
             amount: value.amount,
             fee_reserve: value.fee_reserve,
-            paid: value.paid,
             state: value.state,
             expiry: value.expiry,
             payment_preimage: value.payment_preimage,
@@ -309,97 +302,3 @@ impl From<MeltQuoteBolt11Response<QuoteId>> for MeltQuoteBolt11Response<String>
         }
     }
 }
-
-// A custom deserializer is needed until all mints
-// update some will return without the required state.
-impl<'de, Q: DeserializeOwned> Deserialize<'de> for MeltQuoteBolt11Response<Q> {
-    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
-    where
-        D: Deserializer<'de>,
-    {
-        let value = Value::deserialize(deserializer)?;
-
-        let quote: Q = serde_json::from_value(
-            value
-                .get("quote")
-                .ok_or(serde::de::Error::missing_field("quote"))?
-                .clone(),
-        )
-        .map_err(|_| serde::de::Error::custom("Invalid quote if string"))?;
-
-        let amount = value
-            .get("amount")
-            .ok_or(serde::de::Error::missing_field("amount"))?
-            .as_u64()
-            .ok_or(serde::de::Error::missing_field("amount"))?;
-        let amount = Amount::from(amount);
-
-        let fee_reserve = value
-            .get("fee_reserve")
-            .ok_or(serde::de::Error::missing_field("fee_reserve"))?
-            .as_u64()
-            .ok_or(serde::de::Error::missing_field("fee_reserve"))?;
-
-        let fee_reserve = Amount::from(fee_reserve);
-
-        let paid: Option<bool> = value.get("paid").and_then(|p| p.as_bool());
-
-        let state: Option<String> = value
-            .get("state")
-            .and_then(|s| serde_json::from_value(s.clone()).ok());
-
-        let (state, paid) = match (state, paid) {
-            (None, None) => return Err(serde::de::Error::custom("State or paid must be defined")),
-            (Some(state), _) => {
-                let state: MeltQuoteState = MeltQuoteState::from_str(&state)
-                    .map_err(|_| serde::de::Error::custom("Unknown state"))?;
-                let paid = state == MeltQuoteState::Paid;
-
-                (state, paid)
-            }
-            (None, Some(paid)) => {
-                let state = if paid {
-                    MeltQuoteState::Paid
-                } else {
-                    MeltQuoteState::Unpaid
-                };
-                (state, paid)
-            }
-        };
-
-        let expiry = value
-            .get("expiry")
-            .ok_or(serde::de::Error::missing_field("expiry"))?
-            .as_u64()
-            .ok_or(serde::de::Error::missing_field("expiry"))?;
-
-        let payment_preimage: Option<String> = value
-            .get("payment_preimage")
-            .and_then(|p| serde_json::from_value(p.clone()).ok());
-
-        let change: Option<Vec<BlindSignature>> = value
-            .get("change")
-            .and_then(|b| serde_json::from_value(b.clone()).ok());
-
-        let request: Option<String> = value
-            .get("request")
-            .and_then(|r| serde_json::from_value(r.clone()).ok());
-
-        let unit: Option<CurrencyUnit> = value
-            .get("unit")
-            .and_then(|u| serde_json::from_value(u.clone()).ok());
-
-        Ok(Self {
-            quote,
-            amount,
-            fee_reserve,
-            paid: Some(paid),
-            state,
-            expiry,
-            payment_preimage,
-            change,
-            request,
-            unit,
-        })
-    }
-}

+ 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))
 }

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

@@ -16,10 +16,6 @@ use crate::token_storage;
 pub struct CatDeviceLoginSubCommand {
     /// Mint url
     mint_url: MintUrl,
-    /// Client ID for OIDC authentication
-    #[arg(default_value = "cashu-client")]
-    #[arg(long)]
-    client_id: String,
 }
 
 pub async fn cat_device_login(
@@ -39,8 +35,7 @@ pub async fn cat_device_login(
         .await?
         .ok_or(anyhow!("Mint info not found"))?;
 
-    let (access_token, refresh_token) =
-        get_device_code_token(&mint_info, &sub_command_args.client_id).await;
+    let (access_token, refresh_token) = get_device_code_token(&mint_info).await;
 
     // Save tokens to file in work directory
     if let Err(e) =
@@ -60,7 +55,7 @@ pub async fn cat_device_login(
     Ok(())
 }
 
-async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String, String) {
+async fn get_device_code_token(mint_info: &MintInfo) -> (String, String) {
     let openid_discovery = mint_info
         .nuts
         .nut21
@@ -68,6 +63,13 @@ async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String
         .expect("Nut21 defined")
         .openid_discovery;
 
+    let client_id = mint_info
+        .nuts
+        .nut21
+        .clone()
+        .expect("Nut21 defined")
+        .client_id;
+
     let oidc_client = OidcClient::new(openid_discovery, None);
 
     // Get the OIDC configuration
@@ -83,7 +85,10 @@ async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String
     let client = reqwest::Client::new();
     let device_code_response = client
         .post(device_auth_url)
-        .form(&[("client_id", client_id)])
+        .form(&[
+            ("client_id", client_id.clone().as_str()),
+            ("scope", "openid offline_access"),
+        ])
         .send()
         .await
         .expect("Failed to send device code request");
@@ -129,7 +134,7 @@ async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String
             .form(&[
                 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
                 ("device_code", device_code),
-                ("client_id", client_id),
+                ("client_id", client_id.clone().as_str()),
             ])
             .send()
             .await

+ 10 - 12
crates/cdk-cli/src/sub_commands/cat_login.rs

@@ -18,10 +18,6 @@ pub struct CatLoginSubCommand {
     username: String,
     /// Password
     password: String,
-    /// Client ID for OIDC authentication
-    #[arg(default_value = "cashu-client")]
-    #[arg(long)]
-    client_id: String,
 }
 
 pub async fn cat_login(
@@ -43,7 +39,6 @@ pub async fn cat_login(
 
     let (access_token, refresh_token) = get_access_token(
         &mint_info,
-        &sub_command_args.client_id,
         &sub_command_args.username,
         &sub_command_args.password,
     )
@@ -66,12 +61,7 @@ pub async fn cat_login(
     Ok(())
 }
 
-async fn get_access_token(
-    mint_info: &MintInfo,
-    client_id: &str,
-    user: &str,
-    password: &str,
-) -> (String, String) {
+async fn get_access_token(mint_info: &MintInfo, user: &str, password: &str) -> (String, String) {
     let openid_discovery = mint_info
         .nuts
         .nut21
@@ -79,6 +69,13 @@ async fn get_access_token(
         .expect("Nut21 defined")
         .openid_discovery;
 
+    let client_id = mint_info
+        .nuts
+        .nut21
+        .clone()
+        .expect("Nut21 defined")
+        .client_id;
+
     let oidc_client = OidcClient::new(openid_discovery, None);
 
     // Get the token endpoint from the OIDC configuration
@@ -91,7 +88,8 @@ async fn get_access_token(
     // Create the request parameters
     let params = [
         ("grant_type", "password"),
-        ("client_id", client_id),
+        ("client_id", &client_id),
+        ("scope", "openid offline_access"),
         ("username", user),
         ("password", password),
     ];

+ 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).

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

@@ -31,7 +31,7 @@ impl Melted {
     pub fn from_proofs(
         state: MeltQuoteState,
         preimage: Option<String>,
-        amount: Amount,
+        quote_amount: Amount,
         proofs: Proofs,
         change_proofs: Option<Proofs>,
     ) -> Result<Self, Error> {
@@ -44,19 +44,19 @@ impl Melted {
         tracing::info!(
             "Proofs amount: {} Amount: {} Change: {}",
             proofs_amount,
-            amount,
+            quote_amount,
             change_amount
         );
 
         let fee_paid = proofs_amount
-            .checked_sub(amount + change_amount)
+            .checked_sub(quote_amount + change_amount)
             .ok_or(Error::AmountOverflow)?;
 
         Ok(Self {
             state,
             preimage,
             change: change_proofs,
-            amount,
+            amount: quote_amount,
             fee_paid,
         })
     }

+ 10 - 14
crates/cdk-common/src/database/mint/mod.rs

@@ -6,7 +6,7 @@ use async_trait::async_trait;
 use cashu::quote_id::QuoteId;
 use cashu::Amount;
 
-use super::Error;
+use super::{DbTransactionFinalizer, Error};
 use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote, Operation};
 use crate::nuts::{
     BlindSignature, BlindedMessage, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey,
@@ -304,11 +304,15 @@ pub trait ProofsDatabase {
     ) -> Result<Vec<PublicKey>, Self::Err>;
     /// Get [`Proofs`] state
     async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result<Vec<Option<State>>, Self::Err>;
+
     /// Get [`Proofs`] by state
     async fn get_proofs_by_keyset_id(
         &self,
         keyset_id: &Id,
     ) -> Result<(Proofs, Vec<Option<State>>), Self::Err>;
+
+    /// Get total proofs redeemed by keyset id
+    async fn get_total_redeemed(&self) -> Result<HashMap<Id, Amount>, Self::Err>;
 }
 
 #[async_trait]
@@ -343,16 +347,21 @@ pub trait SignaturesDatabase {
         &self,
         blinded_messages: &[PublicKey],
     ) -> Result<Vec<Option<BlindSignature>>, Self::Err>;
+
     /// Get [`BlindSignature`]s for keyset_id
     async fn get_blind_signatures_for_keyset(
         &self,
         keyset_id: &Id,
     ) -> Result<Vec<BlindSignature>, Self::Err>;
+
     /// Get [`BlindSignature`]s for quote
     async fn get_blind_signatures_for_quote(
         &self,
         quote_id: &QuoteId,
     ) -> Result<Vec<BlindSignature>, Self::Err>;
+
+    /// Get total amount issued by keyset id
+    async fn get_total_issued(&self) -> Result<HashMap<Id, Amount>, Self::Err>;
 }
 
 #[async_trait]
@@ -394,19 +403,6 @@ pub trait SagaDatabase {
     ) -> Result<Vec<mint::Saga>, Self::Err>;
 }
 
-#[async_trait]
-/// Commit and Rollback
-pub trait DbTransactionFinalizer {
-    /// Mint Signature Database Error
-    type Err: Into<Error> + From<Error>;
-
-    /// Commits all the changes into the database
-    async fn commit(self: Box<Self>) -> Result<(), Self::Err>;
-
-    /// Rollbacks the write transaction
-    async fn rollback(self: Box<Self>) -> Result<(), Self::Err>;
-}
-
 /// Key-Value Store Transaction trait
 #[async_trait]
 pub trait KVStoreTransaction<'a, Error>: DbTransactionFinalizer<Err = Error> {

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

@@ -22,6 +22,12 @@ mod proofs;
 pub use self::mint::*;
 pub use self::proofs::*;
 
+/// Generate standard keyset amounts as powers of 2
+#[inline]
+fn standard_keyset_amounts(max_order: u32) -> Vec<u64> {
+    (0..max_order).map(|n| 2u64.pow(n)).collect()
+}
+
 #[inline]
 async fn setup_keyset<DB>(db: &DB) -> Id
 where
@@ -36,9 +42,8 @@ where
         final_expiry: None,
         derivation_path: DerivationPath::from_str("m/0'/0'/0'").unwrap(),
         derivation_path_index: Some(0),
-        max_order: 32,
         input_fee_ppk: 0,
-        amounts: vec![],
+        amounts: standard_keyset_amounts(32),
     };
     let mut writer = db.begin_transaction().await.expect("db.begin()");
     writer.add_keyset_info(keyset_info).await.unwrap();

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

@@ -7,18 +7,21 @@ mod wallet;
 
 #[cfg(feature = "mint")]
 pub use mint::{
-    Database as MintDatabase, DbTransactionFinalizer as MintDbWriterFinalizer, DynMintDatabase,
-    KVStore as MintKVStore, KVStoreDatabase as MintKVStoreDatabase,
-    KVStoreTransaction as MintKVStoreTransaction, KeysDatabase as MintKeysDatabase,
-    KeysDatabaseTransaction as MintKeyDatabaseTransaction, ProofsDatabase as MintProofsDatabase,
-    ProofsTransaction as MintProofsTransaction, QuotesDatabase as MintQuotesDatabase,
-    QuotesTransaction as MintQuotesTransaction, SignaturesDatabase as MintSignaturesDatabase,
+    Database as MintDatabase, DynMintDatabase, KVStore as MintKVStore,
+    KVStoreDatabase as MintKVStoreDatabase, KVStoreTransaction as MintKVStoreTransaction,
+    KeysDatabase as MintKeysDatabase, KeysDatabaseTransaction as MintKeyDatabaseTransaction,
+    ProofsDatabase as MintProofsDatabase, ProofsTransaction as MintProofsTransaction,
+    QuotesDatabase as MintQuotesDatabase, QuotesTransaction as MintQuotesTransaction,
+    SignaturesDatabase as MintSignaturesDatabase,
     SignaturesTransaction as MintSignatureTransaction, Transaction as MintTransaction,
 };
 #[cfg(all(feature = "mint", feature = "auth"))]
 pub use mint::{DynMintAuthDatabase, MintAuthDatabase, MintAuthTransaction};
 #[cfg(feature = "wallet")]
-pub use wallet::Database as WalletDatabase;
+pub use wallet::{
+    Database as WalletDatabase, DatabaseTransaction as WalletDatabaseTransaction,
+    DynWalletDatabaseTransaction,
+};
 
 /// Data conversion error
 #[derive(thiserror::Error, Debug)]
@@ -203,3 +206,16 @@ impl From<crate::state::Error> for Error {
         }
     }
 }
+
+#[async_trait::async_trait]
+/// Commit and Rollback
+pub trait DbTransactionFinalizer {
+    /// Mint Signature Database Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Commits all the changes into the database
+    async fn commit(self: Box<Self>) -> Result<(), Self::Err>;
+
+    /// Rollbacks the write transaction
+    async fn rollback(self: Box<Self>) -> Result<(), Self::Err>;
+}

+ 104 - 45
crates/cdk-common/src/database/wallet.rs

@@ -6,7 +6,7 @@ use std::fmt::Debug;
 use async_trait::async_trait;
 use cashu::KeySet;
 
-use super::Error;
+use super::{DbTransactionFinalizer, Error};
 use crate::common::ProofInfo;
 use crate::mint_url::MintUrl;
 use crate::nuts::{
@@ -16,78 +16,141 @@ use crate::wallet::{
     self, MintQuote as WalletMintQuote, Transaction, TransactionDirection, TransactionId,
 };
 
-/// Wallet Database trait
+/// Easy to use Dynamic Database type alias
+pub type DynWalletDatabaseTransaction = Box<dyn DatabaseTransaction<super::Error> + Sync + Send>;
+
+/// Database transaction
+///
+/// This trait encapsulates all the changes to be done in the wallet
 #[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
 #[cfg_attr(not(target_arch = "wasm32"), async_trait)]
-pub trait Database: Debug {
-    /// Wallet Database Error
-    type Err: Into<Error> + From<Error>;
-
+pub trait DatabaseTransaction<Error>: DbTransactionFinalizer<Err = Error> {
     /// Add Mint to storage
     async fn add_mint(
-        &self,
+        &mut self,
         mint_url: MintUrl,
         mint_info: Option<MintInfo>,
-    ) -> Result<(), Self::Err>;
+    ) -> Result<(), Error>;
+
     /// Remove Mint from storage
-    async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), Self::Err>;
-    /// Get mint from storage
-    async fn get_mint(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, Self::Err>;
-    /// Get all mints from storage
-    async fn get_mints(&self) -> Result<HashMap<MintUrl, Option<MintInfo>>, Self::Err>;
+    async fn remove_mint(&mut self, mint_url: MintUrl) -> Result<(), Error>;
+
     /// Update mint url
     async fn update_mint_url(
-        &self,
+        &mut self,
         old_mint_url: MintUrl,
         new_mint_url: MintUrl,
-    ) -> Result<(), Self::Err>;
+    ) -> Result<(), Error>;
+
+    /// Get mint keyset by id
+    async fn get_keyset_by_id(&mut self, keyset_id: &Id) -> Result<Option<KeySetInfo>, Error>;
+
+    /// Get [`Keys`] from storage
+    async fn get_keys(&mut self, id: &Id) -> Result<Option<Keys>, Error>;
 
     /// Add mint keyset to storage
     async fn add_mint_keysets(
-        &self,
+        &mut self,
         mint_url: MintUrl,
         keysets: Vec<KeySetInfo>,
-    ) -> Result<(), Self::Err>;
+    ) -> Result<(), Error>;
+
+    /// Get mint quote from storage. This function locks the returned minted quote for update
+    async fn get_mint_quote(&mut self, quote_id: &str) -> Result<Option<WalletMintQuote>, Error>;
+
+    /// Add mint quote to storage
+    async fn add_mint_quote(&mut self, quote: WalletMintQuote) -> Result<(), Error>;
+
+    /// Remove mint quote from storage
+    async fn remove_mint_quote(&mut self, quote_id: &str) -> Result<(), Error>;
+
+    /// Get melt quote from storage
+    async fn get_melt_quote(&mut self, quote_id: &str) -> Result<Option<wallet::MeltQuote>, Error>;
+
+    /// Add melt quote to storage
+    async fn add_melt_quote(&mut self, quote: wallet::MeltQuote) -> Result<(), Error>;
+
+    /// Remove melt quote from storage
+    async fn remove_melt_quote(&mut self, quote_id: &str) -> Result<(), Error>;
+
+    /// Add [`Keys`] to storage
+    async fn add_keys(&mut self, keyset: KeySet) -> Result<(), Error>;
+
+    /// Remove [`Keys`] from storage
+    async fn remove_keys(&mut self, id: &Id) -> Result<(), Error>;
+
+    /// Get proofs from storage and lock them for update
+    async fn get_proofs(
+        &mut self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<State>>,
+        spending_conditions: Option<Vec<SpendingConditions>>,
+    ) -> Result<Vec<ProofInfo>, Error>;
+
+    /// Update the proofs in storage by adding new proofs or removing proofs by
+    /// their Y value.
+    async fn update_proofs(
+        &mut self,
+        added: Vec<ProofInfo>,
+        removed_ys: Vec<PublicKey>,
+    ) -> Result<(), Error>;
+
+    /// Update proofs state in storage
+    async fn update_proofs_state(&mut self, ys: Vec<PublicKey>, state: State) -> Result<(), Error>;
+
+    /// Atomically increment Keyset counter and return new value
+    async fn increment_keyset_counter(&mut self, keyset_id: &Id, count: u32) -> Result<u32, Error>;
+
+    /// Add transaction to storage
+    async fn add_transaction(&mut self, transaction: Transaction) -> Result<(), Error>;
+
+    /// Remove transaction from storage
+    async fn remove_transaction(&mut self, transaction_id: TransactionId) -> Result<(), Error>;
+}
+
+/// Wallet Database trait
+#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
+#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
+pub trait Database: Debug {
+    /// Wallet Database Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Begins a DB transaction
+    async fn begin_db_transaction(
+        &self,
+    ) -> Result<Box<dyn DatabaseTransaction<Self::Err> + Send + Sync>, Self::Err>;
+
+    /// Get mint from storage
+    async fn get_mint(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, Self::Err>;
+
+    /// Get all mints from storage
+    async fn get_mints(&self) -> Result<HashMap<MintUrl, Option<MintInfo>>, Self::Err>;
+
     /// Get mint keysets for mint url
     async fn get_mint_keysets(
         &self,
         mint_url: MintUrl,
     ) -> Result<Option<Vec<KeySetInfo>>, Self::Err>;
+
     /// Get mint keyset by id
     async fn get_keyset_by_id(&self, keyset_id: &Id) -> Result<Option<KeySetInfo>, Self::Err>;
 
-    /// Add mint quote to storage
-    async fn add_mint_quote(&self, quote: WalletMintQuote) -> Result<(), Self::Err>;
     /// Get mint quote from storage
     async fn get_mint_quote(&self, quote_id: &str) -> Result<Option<WalletMintQuote>, Self::Err>;
+
     /// Get mint quotes from storage
     async fn get_mint_quotes(&self) -> Result<Vec<WalletMintQuote>, Self::Err>;
-    /// Remove mint quote from storage
-    async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err>;
 
-    /// Add melt quote to storage
-    async fn add_melt_quote(&self, quote: wallet::MeltQuote) -> Result<(), Self::Err>;
     /// Get melt quote from storage
     async fn get_melt_quote(&self, quote_id: &str) -> Result<Option<wallet::MeltQuote>, Self::Err>;
+
     /// Get melt quotes from storage
     async fn get_melt_quotes(&self) -> Result<Vec<wallet::MeltQuote>, Self::Err>;
-    /// Remove melt quote from storage
-    async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err>;
 
-    /// Add [`Keys`] to storage
-    async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err>;
     /// Get [`Keys`] from storage
     async fn get_keys(&self, id: &Id) -> Result<Option<Keys>, Self::Err>;
-    /// Remove [`Keys`] from storage
-    async fn remove_keys(&self, id: &Id) -> Result<(), Self::Err>;
 
-    /// Update the proofs in storage by adding new proofs or removing proofs by
-    /// their Y value.
-    async fn update_proofs(
-        &self,
-        added: Vec<ProofInfo>,
-        removed_ys: Vec<PublicKey>,
-    ) -> Result<(), Self::Err>;
     /// Get proofs from storage
     async fn get_proofs(
         &self,
@@ -96,6 +159,10 @@ pub trait Database: Debug {
         state: Option<Vec<State>>,
         spending_conditions: Option<Vec<SpendingConditions>>,
     ) -> Result<Vec<ProofInfo>, Self::Err>;
+
+    /// Get proofs by Y values
+    async fn get_proofs_by_ys(&self, ys: Vec<PublicKey>) -> Result<Vec<ProofInfo>, Self::Err>;
+
     /// Get balance
     async fn get_balance(
         &self,
@@ -103,19 +170,13 @@ pub trait Database: Debug {
         unit: Option<CurrencyUnit>,
         state: Option<Vec<State>>,
     ) -> Result<u64, Self::Err>;
-    /// Update proofs state in storage
-    async fn update_proofs_state(&self, ys: Vec<PublicKey>, state: State) -> Result<(), Self::Err>;
 
-    /// Atomically increment Keyset counter and return new value
-    async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<u32, Self::Err>;
-
-    /// Add transaction to storage
-    async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err>;
     /// Get transaction from storage
     async fn get_transaction(
         &self,
         transaction_id: TransactionId,
     ) -> Result<Option<Transaction>, Self::Err>;
+
     /// List transactions from storage
     async fn list_transactions(
         &self,
@@ -123,6 +184,4 @@ pub trait Database: Debug {
         direction: Option<TransactionDirection>,
         unit: Option<CurrencyUnit>,
     ) -> Result<Vec<Transaction>, Self::Err>;
-    /// Remove transaction from storage
-    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err>;
 }

+ 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};

+ 72 - 11
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,38 @@ 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}"))),
+        }
+    }
+}
+
+/// States specific to melt saga
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum MeltSagaState {
+    /// Setup complete (proofs reserved, quote verified)
+    SetupComplete,
+    /// Payment attempted to Lightning network (may or may not have succeeded)
+    PaymentAttempted,
+}
+
+impl fmt::Display for MeltSagaState {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            MeltSagaState::SetupComplete => write!(f, "setup_complete"),
+            MeltSagaState::PaymentAttempted => write!(f, "payment_attempted"),
+        }
+    }
+}
+
+impl FromStr for MeltSagaState {
+    type Err = Error;
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        let value = value.to_lowercase();
+        match value.as_str() {
+            "setup_complete" => Ok(MeltSagaState::SetupComplete),
+            "payment_attempted" => Ok(MeltSagaState::PaymentAttempted),
+            _ => Err(Error::Custom(format!("Invalid melt saga state: {}", value))),
         }
     }
 }
@@ -91,10 +122,10 @@ impl FromStr for SwapSagaState {
 pub enum SagaStateEnum {
     /// Swap saga states
     Swap(SwapSagaState),
+    /// Melt saga states
+    Melt(MeltSagaState),
     // Future: Mint saga states
     // Mint(MintSagaState),
-    // Future: Melt saga states
-    // Melt(MeltSagaState),
 }
 
 impl SagaStateEnum {
@@ -102,8 +133,8 @@ impl SagaStateEnum {
     pub fn new(operation_kind: OperationKind, s: &str) -> Result<Self, Error> {
         match operation_kind {
             OperationKind::Swap => Ok(SagaStateEnum::Swap(SwapSagaState::from_str(s)?)),
+            OperationKind::Melt => Ok(SagaStateEnum::Melt(MeltSagaState::from_str(s)?)),
             OperationKind::Mint => Err(Error::Custom("Mint saga not implemented yet".to_string())),
-            OperationKind::Melt => Err(Error::Custom("Melt saga not implemented yet".to_string())),
         }
     }
 
@@ -114,6 +145,10 @@ impl SagaStateEnum {
                 SwapSagaState::SetupComplete => "setup_complete",
                 SwapSagaState::Signed => "signed",
             },
+            SagaStateEnum::Melt(state) => match state {
+                MeltSagaState::SetupComplete => "setup_complete",
+                MeltSagaState::PaymentAttempted => "payment_attempted",
+            },
         }
     }
 }
@@ -131,6 +166,9 @@ pub struct Saga {
     pub blinded_secrets: Vec<PublicKey>,
     /// Y values (public keys) from input proofs
     pub input_ys: Vec<PublicKey>,
+    /// Quote ID for melt operations (used for payment status lookup during recovery)
+    /// None for swap operations
+    pub quote_id: Option<String>,
     /// Unix timestamp when saga was created
     pub created_at: u64,
     /// Unix timestamp when saga was last updated
@@ -152,6 +190,7 @@ impl Saga {
             state: SagaStateEnum::Swap(state),
             blinded_secrets,
             input_ys,
+            quote_id: None,
             created_at: now,
             updated_at: now,
         }
@@ -162,6 +201,33 @@ impl Saga {
         self.state = SagaStateEnum::Swap(new_state);
         self.updated_at = unix_time();
     }
+
+    /// Create new melt saga
+    pub fn new_melt(
+        operation_id: Uuid,
+        state: MeltSagaState,
+        input_ys: Vec<PublicKey>,
+        blinded_secrets: Vec<PublicKey>,
+        quote_id: String,
+    ) -> Self {
+        let now = unix_time();
+        Self {
+            operation_id,
+            operation_kind: OperationKind::Melt,
+            state: SagaStateEnum::Melt(state),
+            blinded_secrets,
+            input_ys,
+            quote_id: Some(quote_id),
+            created_at: now,
+            updated_at: now,
+        }
+    }
+
+    /// Update melt saga state
+    pub fn update_melt_state(&mut self, new_state: MeltSagaState) {
+        self.state = SagaStateEnum::Melt(new_state);
+        self.updated_at = unix_time();
+    }
 }
 
 /// Operation
@@ -213,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}"))),
         }
     }
 }
@@ -523,8 +589,6 @@ pub struct MintKeySetInfo {
     pub derivation_path: DerivationPath,
     /// DerivationPath index of Keyset
     pub derivation_path_index: Option<u32>,
-    /// Max order of keyset
-    pub max_order: u8,
     /// Supported amounts
     pub amounts: Vec<u64>,
     /// Input Fee ppk
@@ -607,7 +671,6 @@ impl From<&MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
             payment_preimage: None,
             change: None,
             state: melt_quote.state,
-            paid: Some(melt_quote.state == MeltQuoteState::Paid),
             expiry: melt_quote.expiry,
             amount: melt_quote.amount,
             fee_reserve: melt_quote.fee_reserve,
@@ -619,12 +682,10 @@ impl From<&MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
 
 impl From<MeltQuote> for MeltQuoteBolt11Response<QuoteId> {
     fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response<QuoteId> {
-        let paid = melt_quote.state == MeltQuoteState::Paid;
         MeltQuoteBolt11Response {
             quote: melt_quote.id.clone(),
             amount: melt_quote.amount,
             fee_reserve: melt_quote.fee_reserve,
-            paid: Some(paid),
             state: melt_quote.state,
             expiry: melt_quote.expiry,
             payment_preimage: melt_quote.payment_preimage,

+ 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)
+}

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

@@ -21,6 +21,7 @@ cdk-postgres = { workspace = true, optional = true }
 futures = { workspace = true }
 once_cell = { workspace = true }
 rand = { workspace = true }
+cdk-sql-common = { workspace = true }
 serde = { workspace = true, features = ["derive", "rc"] }
 serde_json = { workspace = true }
 thiserror = { workspace = true }
@@ -28,6 +29,7 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
 uniffi = { version = "0.28.3", features = ["cli", "tokio"] }
 url = { workspace = true }
 uuid = { workspace = true, features = ["v4"] }
+cdk-common.workspace = true
 
 
 [features]

文件差異過大導致無法顯示
+ 1074 - 214
crates/cdk-ffi/src/database.rs


+ 1 - 0
crates/cdk-ffi/src/lib.rs

@@ -7,6 +7,7 @@
 pub mod database;
 pub mod error;
 pub mod multi_mint_wallet;
+#[cfg(feature = "postgres")]
 pub mod postgres;
 pub mod sqlite;
 pub mod token;

+ 195 - 1
crates/cdk-ffi/src/multi_mint_wallet.rs

@@ -23,7 +23,7 @@ pub struct MultiMintWallet {
 
 #[uniffi::export(async_runtime = "tokio")]
 impl MultiMintWallet {
-    /// Create a new MultiMintWallet from mnemonic using WalletDatabase trait
+    /// Create a new MultiMintWallet from mnemonic using WalletDatabaseFfi trait
     #[uniffi::constructor]
     pub fn new(
         unit: CurrencyUnit,
@@ -111,6 +111,51 @@ impl MultiMintWallet {
         self.inner.unit().clone().into()
     }
 
+    /// Set metadata cache TTL (time-to-live) in seconds for a specific mint
+    ///
+    /// Controls how long cached mint metadata (keysets, keys, mint info) is considered fresh
+    /// before requiring a refresh from the mint server for a specific mint.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint URL to set the TTL for
+    /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires.
+    pub async fn set_metadata_cache_ttl_for_mint(
+        &self,
+        mint_url: MintUrl,
+        ttl_secs: Option<u64>,
+    ) -> Result<(), FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let wallets = self.inner.get_wallets().await;
+
+        if let Some(wallet) = wallets.iter().find(|w| w.mint_url == cdk_mint_url) {
+            let ttl = ttl_secs.map(std::time::Duration::from_secs);
+            wallet.set_metadata_cache_ttl(ttl);
+            Ok(())
+        } else {
+            Err(FfiError::Generic {
+                msg: format!("Mint not found: {}", cdk_mint_url),
+            })
+        }
+    }
+
+    /// Set metadata cache TTL (time-to-live) in seconds for all mints
+    ///
+    /// Controls how long cached mint metadata is considered fresh for all mints
+    /// in this MultiMintWallet.
+    ///
+    /// # Arguments
+    ///
+    /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires for any mint.
+    pub async fn set_metadata_cache_ttl_for_all_mints(&self, ttl_secs: Option<u64>) {
+        let wallets = self.inner.get_wallets().await;
+        let ttl = ttl_secs.map(std::time::Duration::from_secs);
+
+        for wallet in wallets.iter() {
+            wallet.set_metadata_cache_ttl(ttl);
+        }
+    }
+
     /// Add a mint to this MultiMintWallet
     pub async fn add_mint(
         &self,
@@ -304,6 +349,113 @@ impl MultiMintWallet {
         Ok(quote.into())
     }
 
+    /// Get a melt quote for a BIP353 human-readable address
+    ///
+    /// This method resolves a BIP353 address (e.g., "alice@example.com") to a Lightning offer
+    /// and then creates a melt quote for that offer at the specified mint.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint to use for creating the melt quote
+    /// * `bip353_address` - Human-readable address in the format "user@domain.com"
+    /// * `amount_msat` - Amount to pay in millisatoshis
+    #[cfg(not(target_arch = "wasm32"))]
+    pub async fn melt_bip353_quote(
+        &self,
+        mint_url: MintUrl,
+        bip353_address: String,
+        amount_msat: u64,
+    ) -> Result<MeltQuote, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let cdk_amount = cdk::Amount::from(amount_msat);
+        let quote = self
+            .inner
+            .melt_bip353_quote(&cdk_mint_url, &bip353_address, cdk_amount)
+            .await?;
+        Ok(quote.into())
+    }
+
+    /// Get a melt quote for a Lightning address
+    ///
+    /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice
+    /// and then creates a melt quote for that invoice at the specified mint.
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint to use for creating the melt quote
+    /// * `lightning_address` - Lightning address in the format "user@domain.com"
+    /// * `amount_msat` - Amount to pay in millisatoshis
+    pub async fn melt_lightning_address_quote(
+        &self,
+        mint_url: MintUrl,
+        lightning_address: String,
+        amount_msat: u64,
+    ) -> Result<MeltQuote, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let cdk_amount = cdk::Amount::from(amount_msat);
+        let quote = self
+            .inner
+            .melt_lightning_address_quote(&cdk_mint_url, &lightning_address, cdk_amount)
+            .await?;
+        Ok(quote.into())
+    }
+
+    /// Get a melt quote for a human-readable address
+    ///
+    /// This method accepts a human-readable address that could be either a BIP353 address
+    /// or a Lightning address. It intelligently determines which to try based on mint support:
+    ///
+    /// 1. If the mint supports Bolt12, it tries BIP353 first
+    /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails
+    /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address
+    /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly
+    ///
+    /// # Arguments
+    ///
+    /// * `mint_url` - The mint to use for creating the melt quote
+    /// * `address` - Human-readable address (BIP353 or Lightning address)
+    /// * `amount_msat` - Amount to pay in millisatoshis
+    #[cfg(not(target_arch = "wasm32"))]
+    pub async fn melt_human_readable_quote(
+        &self,
+        mint_url: MintUrl,
+        address: String,
+        amount_msat: u64,
+    ) -> Result<MeltQuote, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let cdk_amount = cdk::Amount::from(amount_msat);
+        let quote = self
+            .inner
+            .melt_human_readable_quote(&cdk_mint_url, &address, cdk_amount)
+            .await?;
+        Ok(quote.into())
+    }
+
+    /// Melt tokens
+    pub async fn melt_with_mint(
+        &self,
+        mint_url: MintUrl,
+        quote_id: String,
+    ) -> Result<Melted, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let melted = self.inner.melt_with_mint(&cdk_mint_url, &quote_id).await?;
+        Ok(melted.into())
+    }
+
+    /// Check melt quote status
+    pub async fn check_melt_quote(
+        &self,
+        mint_url: MintUrl,
+        quote_id: String,
+    ) -> Result<MeltQuote, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let melted = self
+            .inner
+            .check_melt_quote(&cdk_mint_url, &quote_id)
+            .await?;
+        Ok(melted.into())
+    }
+
     /// Melt tokens (pay a bolt11 invoice)
     pub async fn melt(
         &self,
@@ -356,6 +508,30 @@ impl MultiMintWallet {
         Ok(transactions.into_iter().map(Into::into).collect())
     }
 
+    /// Get proofs for a transaction by transaction ID
+    ///
+    /// This retrieves all proofs associated with a transaction. If `mint_url` is provided,
+    /// it will only check that specific mint's wallet. Otherwise, it searches across all
+    /// wallets to find which mint the transaction belongs to.
+    ///
+    /// # Arguments
+    ///
+    /// * `id` - The transaction ID
+    /// * `mint_url` - Optional mint URL to check directly, avoiding iteration over all wallets
+    pub async fn get_proofs_for_transaction(
+        &self,
+        id: TransactionId,
+        mint_url: Option<MintUrl>,
+    ) -> Result<Vec<Proof>, FfiError> {
+        let cdk_id = id.try_into()?;
+        let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?;
+        let proofs = self
+            .inner
+            .get_proofs_for_transaction(cdk_id, cdk_mint_url)
+            .await?;
+        Ok(proofs.into_iter().map(Into::into).collect())
+    }
+
     /// Check all mint quotes and mint if paid
     pub async fn check_all_mint_quotes(
         &self,
@@ -378,6 +554,24 @@ impl MultiMintWallet {
         wallets.iter().map(|w| w.mint_url.to_string()).collect()
     }
 
+    /// Get all wallets from MultiMintWallet
+    pub async fn get_wallets(&self) -> Vec<Arc<crate::wallet::Wallet>> {
+        let wallets = self.inner.get_wallets().await;
+        wallets
+            .into_iter()
+            .map(|w| Arc::new(crate::wallet::Wallet::from_inner(Arc::new(w))))
+            .collect()
+    }
+
+    /// Get a specific wallet from MultiMintWallet by mint URL
+    pub async fn get_wallet(&self, mint_url: MintUrl) -> Option<Arc<crate::wallet::Wallet>> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into().ok()?;
+        let wallet = self.inner.get_wallet(&cdk_mint_url).await?;
+        Some(Arc::new(crate::wallet::Wallet::from_inner(Arc::new(
+            wallet,
+        ))))
+    }
+
     /// Verify token DLEQ proofs
     pub async fn verify_token_dleq(&self, token: Arc<Token>) -> Result<(), FfiError> {
         let cdk_token = token.inner.clone();

+ 53 - 320
crates/cdk-ffi/src/postgres.rs

@@ -2,19 +2,17 @@ use std::collections::HashMap;
 use std::sync::Arc;
 
 // Bring the CDK wallet database trait into scope so trait methods resolve on the inner DB
-use cdk::cdk_database::WalletDatabase as CdkWalletDatabase;
-#[cfg(feature = "postgres")]
-use cdk_postgres::WalletPgDatabase as CdkWalletPgDatabase;
+use cdk_postgres::PgConnectionPool;
 
 use crate::{
-    CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl,
-    ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection,
-    TransactionId, WalletDatabase,
+    CurrencyUnit, FfiError, FfiWalletSQLDatabase, Id, KeySetInfo, Keys, MeltQuote, MintInfo,
+    MintQuote, MintUrl, ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction,
+    TransactionDirection, TransactionId, WalletDatabase, WalletDatabaseTransactionWrapper,
 };
 
 #[derive(uniffi::Object)]
 pub struct WalletPostgresDatabase {
-    inner: Arc<CdkWalletPgDatabase>,
+    inner: Arc<FfiWalletSQLDatabase<PgConnectionPool>>,
 }
 
 // Keep a long-lived Tokio runtime for Postgres-created resources so that
@@ -35,230 +33,80 @@ fn pg_runtime() -> &'static tokio::runtime::Runtime {
     })
 }
 
-// Implement the local WalletDatabase trait (simple trait path required by uniffi)
+#[uniffi::export]
+impl WalletPostgresDatabase {
+    /// Create a new Postgres-backed wallet database
+    /// Requires cdk-ffi to be built with feature "postgres".
+    /// Example URL:
+    ///  "host=localhost user=test password=test dbname=testdb port=5433 schema=wallet sslmode=prefer"
+    #[cfg(feature = "postgres")]
+    #[uniffi::constructor]
+    pub fn new(url: String) -> Result<Arc<Self>, FfiError> {
+        let inner = match tokio::runtime::Handle::try_current() {
+            Ok(handle) => tokio::task::block_in_place(|| {
+                handle.block_on(
+                    async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await },
+                )
+            }),
+            // Important: use a process-long runtime so background connection tasks stay alive.
+            Err(_) => pg_runtime()
+                .block_on(async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }),
+        }
+        .map_err(|e| FfiError::Database { msg: e.to_string() })?;
+        Ok(Arc::new(WalletPostgresDatabase {
+            inner: FfiWalletSQLDatabase::new(inner),
+        }))
+    }
+}
+
 #[uniffi::export(async_runtime = "tokio")]
 #[async_trait::async_trait]
 impl WalletDatabase for WalletPostgresDatabase {
-    // Forward all trait methods to inner CDK database via the bridge adapter
-    async fn add_mint(
-        &self,
-        mint_url: MintUrl,
-        mint_info: Option<MintInfo>,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let cdk_mint_info = mint_info.map(Into::into);
-        println!("adding new mint");
-        self.inner
-            .add_mint(cdk_mint_url, cdk_mint_info)
-            .await
-            .map_err(|e| {
-                println!("ffi error {:?}", e);
-                FfiError::Database { msg: e.to_string() }
-            })
+    async fn begin_db_transaction(&self) -> Result<WalletDatabaseTransactionWrapper, FfiError> {
+        self.inner.begin_db_transaction().await
     }
-    async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        self.inner
-            .remove_mint(cdk_mint_url)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+
+    async fn get_proofs_by_ys(&self, ys: Vec<PublicKey>) -> Result<Vec<ProofInfo>, FfiError> {
+        self.inner.get_proofs_by_ys(ys).await
     }
+
     async fn get_mint(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let result = self
-            .inner
-            .get_mint(cdk_mint_url)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(Into::into))
+        self.inner.get_mint(mint_url).await
     }
+
     async fn get_mints(&self) -> Result<HashMap<MintUrl, Option<MintInfo>>, FfiError> {
-        let result = self
-            .inner
-            .get_mints()
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result
-            .into_iter()
-            .map(|(k, v)| (k.into(), v.map(Into::into)))
-            .collect())
-    }
-    async fn update_mint_url(
-        &self,
-        old_mint_url: MintUrl,
-        new_mint_url: MintUrl,
-    ) -> Result<(), FfiError> {
-        let cdk_old_mint_url = old_mint_url.try_into()?;
-        let cdk_new_mint_url = new_mint_url.try_into()?;
-        self.inner
-            .update_mint_url(cdk_old_mint_url, cdk_new_mint_url)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-    async fn add_mint_keysets(
-        &self,
-        mint_url: MintUrl,
-        keysets: Vec<KeySetInfo>,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let cdk_keysets: Vec<cdk::nuts::KeySetInfo> = keysets.into_iter().map(Into::into).collect();
-        self.inner
-            .add_mint_keysets(cdk_mint_url, cdk_keysets)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_mints().await
     }
+
     async fn get_mint_keysets(
         &self,
         mint_url: MintUrl,
     ) -> Result<Option<Vec<KeySetInfo>>, FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let result = self
-            .inner
-            .get_mint_keysets(cdk_mint_url)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect()))
+        self.inner.get_mint_keysets(mint_url).await
     }
 
     async fn get_keyset_by_id(&self, keyset_id: Id) -> Result<Option<KeySetInfo>, FfiError> {
-        let cdk_id = keyset_id.into();
-        let result = self
-            .inner
-            .get_keyset_by_id(&cdk_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(Into::into))
-    }
-
-    // Mint Quote Management
-    async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> {
-        let cdk_quote = quote.try_into()?;
-        self.inner
-            .add_mint_quote(cdk_quote)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_keyset_by_id(keyset_id).await
     }
 
     async fn get_mint_quote(&self, quote_id: String) -> Result<Option<MintQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_mint_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(|q| q.into()))
+        self.inner.get_mint_quote(quote_id).await
     }
 
     async fn get_mint_quotes(&self) -> Result<Vec<MintQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_mint_quotes()
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.into_iter().map(|q| q.into()).collect())
-    }
-
-    async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> {
-        self.inner
-            .remove_mint_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Melt Quote Management
-    async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> {
-        let cdk_quote = quote.try_into()?;
-        self.inner
-            .add_melt_quote(cdk_quote)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_mint_quotes().await
     }
 
     async fn get_melt_quote(&self, quote_id: String) -> Result<Option<MeltQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_melt_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(|q| q.into()))
+        self.inner.get_melt_quote(quote_id).await
     }
 
     async fn get_melt_quotes(&self) -> Result<Vec<MeltQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_melt_quotes()
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.into_iter().map(|q| q.into()).collect())
-    }
-
-    async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> {
-        self.inner
-            .remove_melt_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Keys Management
-    async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> {
-        // Convert FFI KeySet to cdk::nuts::KeySet
-        let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?;
-        self.inner
-            .add_keys(cdk_keyset)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_melt_quotes().await
     }
 
     async fn get_keys(&self, id: Id) -> Result<Option<Keys>, FfiError> {
-        let cdk_id = id.into();
-        let result = self
-            .inner
-            .get_keys(&cdk_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(Into::into))
-    }
-
-    async fn remove_keys(&self, id: Id) -> Result<(), FfiError> {
-        let cdk_id = id.into();
-        self.inner
-            .remove_keys(&cdk_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Proof Management
-    async fn update_proofs(
-        &self,
-        added: Vec<ProofInfo>,
-        removed_ys: Vec<PublicKey>,
-    ) -> Result<(), FfiError> {
-        // Convert FFI types to CDK types
-        let cdk_added: Result<Vec<cdk::types::ProofInfo>, FfiError> = added
-            .into_iter()
-            .map(|info| {
-                Ok::<cdk::types::ProofInfo, FfiError>(cdk::types::ProofInfo {
-                    proof: info.proof.try_into()?,
-                    y: info.y.try_into()?,
-                    mint_url: info.mint_url.try_into()?,
-                    state: info.state.into(),
-                    spending_condition: info
-                        .spending_condition
-                        .map(|sc| sc.try_into())
-                        .transpose()?,
-                    unit: info.unit.into(),
-                })
-            })
-            .collect();
-        let cdk_added = cdk_added?;
-
-        let cdk_removed_ys: Result<Vec<cdk::nuts::PublicKey>, FfiError> =
-            removed_ys.into_iter().map(|pk| pk.try_into()).collect();
-        let cdk_removed_ys = cdk_removed_ys?;
-
-        self.inner
-            .update_proofs(cdk_added, cdk_removed_ys)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_keys(id).await
     }
 
     async fn get_proofs(
@@ -268,25 +116,9 @@ impl WalletDatabase for WalletPostgresDatabase {
         state: Option<Vec<ProofState>>,
         spending_conditions: Option<Vec<SpendingConditions>>,
     ) -> Result<Vec<ProofInfo>, FfiError> {
-        let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?;
-        let cdk_unit = unit.map(Into::into);
-        let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect());
-        let cdk_spending_conditions: Option<Vec<cdk::nuts::SpendingConditions>> =
-            spending_conditions
-                .map(|sc| {
-                    sc.into_iter()
-                        .map(|c| c.try_into())
-                        .collect::<Result<Vec<_>, FfiError>>()
-                })
-                .transpose()?;
-
-        let result = self
-            .inner
-            .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions)
+        self.inner
+            .get_proofs(mint_url, unit, state, spending_conditions)
             .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-
-        Ok(result.into_iter().map(Into::into).collect())
     }
 
     async fn get_balance(
@@ -295,63 +127,14 @@ impl WalletDatabase for WalletPostgresDatabase {
         unit: Option<CurrencyUnit>,
         state: Option<Vec<ProofState>>,
     ) -> Result<u64, FfiError> {
-        let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?;
-        let cdk_unit = unit.map(Into::into);
-        let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect());
-
-        self.inner
-            .get_balance(cdk_mint_url, cdk_unit, cdk_state)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    async fn update_proofs_state(
-        &self,
-        ys: Vec<PublicKey>,
-        state: ProofState,
-    ) -> Result<(), FfiError> {
-        let cdk_ys: Result<Vec<cdk::nuts::PublicKey>, FfiError> =
-            ys.into_iter().map(|pk| pk.try_into()).collect();
-        let cdk_ys = cdk_ys?;
-        let cdk_state = state.into();
-
-        self.inner
-            .update_proofs_state(cdk_ys, cdk_state)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Keyset Counter Management
-    async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result<u32, FfiError> {
-        let cdk_id = keyset_id.into();
-        self.inner
-            .increment_keyset_counter(&cdk_id, count)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Transaction Management
-    async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> {
-        // Convert FFI Transaction to CDK Transaction using TryFrom
-        let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?;
-
-        self.inner
-            .add_transaction(cdk_transaction)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_balance(mint_url, unit, state).await
     }
 
     async fn get_transaction(
         &self,
         transaction_id: TransactionId,
     ) -> Result<Option<Transaction>, FfiError> {
-        let cdk_id = transaction_id.try_into()?;
-        let result = self
-            .inner
-            .get_transaction(cdk_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(Into::into))
+        self.inner.get_transaction(transaction_id).await
     }
 
     async fn list_transactions(
@@ -360,58 +143,8 @@ impl WalletDatabase for WalletPostgresDatabase {
         direction: Option<TransactionDirection>,
         unit: Option<CurrencyUnit>,
     ) -> Result<Vec<Transaction>, FfiError> {
-        let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?;
-        let cdk_direction = direction.map(Into::into);
-        let cdk_unit = unit.map(Into::into);
-
-        let result = self
-            .inner
-            .list_transactions(cdk_mint_url, cdk_direction, cdk_unit)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-
-        Ok(result.into_iter().map(Into::into).collect())
-    }
-
-    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> {
-        let cdk_id = transaction_id.try_into()?;
         self.inner
-            .remove_transaction(cdk_id)
+            .list_transactions(mint_url, direction, unit)
             .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-}
-
-#[uniffi::export]
-impl WalletPostgresDatabase {
-    /// Create a new Postgres-backed wallet database
-    /// Requires cdk-ffi to be built with feature "postgres".
-    /// Example URL:
-    ///  "host=localhost user=test password=test dbname=testdb port=5433 schema=wallet sslmode=prefer"
-    #[cfg(feature = "postgres")]
-    #[uniffi::constructor]
-    pub fn new(url: String) -> Result<Arc<Self>, FfiError> {
-        let inner = match tokio::runtime::Handle::try_current() {
-            Ok(handle) => tokio::task::block_in_place(|| {
-                handle.block_on(
-                    async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await },
-                )
-            }),
-            // Important: use a process-long runtime so background connection tasks stay alive.
-            Err(_) => pg_runtime()
-                .block_on(async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }),
-        }
-        .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(Arc::new(WalletPostgresDatabase {
-            inner: Arc::new(inner),
-        }))
-    }
-
-    fn clone_as_trait(&self) -> Arc<dyn WalletDatabase> {
-        // Safety: UniFFI objects are reference counted and Send+Sync via Arc
-        let obj: Arc<dyn WalletDatabase> = Arc::new(WalletPostgresDatabase {
-            inner: self.inner.clone(),
-        });
-        obj
     }
 }

+ 28 - 289
crates/cdk-ffi/src/sqlite.rs

@@ -2,22 +2,18 @@ use std::collections::HashMap;
 use std::sync::Arc;
 
 use cdk_sqlite::wallet::WalletSqliteDatabase as CdkWalletSqliteDatabase;
+use cdk_sqlite::SqliteConnectionManager;
 
 use crate::{
-    CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl,
-    ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection,
-    TransactionId, WalletDatabase,
+    CurrencyUnit, FfiError, FfiWalletSQLDatabase, Id, KeySetInfo, Keys, MeltQuote, MintInfo,
+    MintQuote, MintUrl, ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction,
+    TransactionDirection, TransactionId, WalletDatabase,
 };
 
-/// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait
+/// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabaseFfi trait
 #[derive(uniffi::Object)]
 pub struct WalletSqliteDatabase {
-    inner: Arc<CdkWalletSqliteDatabase>,
-}
-use cdk::cdk_database::WalletDatabase as CdkWalletDatabase;
-
-impl WalletSqliteDatabase {
-    // No additional methods needed beyond the trait implementation
+    inner: Arc<FfiWalletSQLDatabase<SqliteConnectionManager>>,
 }
 
 #[uniffi::export]
@@ -41,7 +37,7 @@ impl WalletSqliteDatabase {
         }
         .map_err(|e| FfiError::Database { msg: e.to_string() })?;
         Ok(Arc::new(Self {
-            inner: Arc::new(db),
+            inner: FfiWalletSQLDatabase::new(db),
         }))
     }
 
@@ -63,7 +59,7 @@ impl WalletSqliteDatabase {
         }
         .map_err(|e| FfiError::Database { msg: e.to_string() })?;
         Ok(Arc::new(Self {
-            inner: Arc::new(db),
+            inner: FfiWalletSQLDatabase::new(db),
         }))
     }
 }
@@ -71,229 +67,49 @@ impl WalletSqliteDatabase {
 #[uniffi::export(async_runtime = "tokio")]
 #[async_trait::async_trait]
 impl WalletDatabase for WalletSqliteDatabase {
-    // Mint Management
-    async fn add_mint(
+    async fn begin_db_transaction(
         &self,
-        mint_url: MintUrl,
-        mint_info: Option<MintInfo>,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let cdk_mint_info = mint_info.map(Into::into);
-        self.inner
-            .add_mint(cdk_mint_url, cdk_mint_info)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        self.inner
-            .remove_mint(cdk_mint_url)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+    ) -> Result<crate::database::WalletDatabaseTransactionWrapper, FfiError> {
+        self.inner.begin_db_transaction().await
     }
 
     async fn get_mint(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let result = self
-            .inner
-            .get_mint(cdk_mint_url)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(Into::into))
+        self.inner.get_mint(mint_url).await
     }
 
     async fn get_mints(&self) -> Result<HashMap<MintUrl, Option<MintInfo>>, FfiError> {
-        let result = self
-            .inner
-            .get_mints()
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result
-            .into_iter()
-            .map(|(k, v)| (k.into(), v.map(Into::into)))
-            .collect())
-    }
-
-    async fn update_mint_url(
-        &self,
-        old_mint_url: MintUrl,
-        new_mint_url: MintUrl,
-    ) -> Result<(), FfiError> {
-        let cdk_old_mint_url = old_mint_url.try_into()?;
-        let cdk_new_mint_url = new_mint_url.try_into()?;
-        self.inner
-            .update_mint_url(cdk_old_mint_url, cdk_new_mint_url)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Keyset Management
-    async fn add_mint_keysets(
-        &self,
-        mint_url: MintUrl,
-        keysets: Vec<KeySetInfo>,
-    ) -> Result<(), FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let cdk_keysets: Vec<cdk::nuts::KeySetInfo> = keysets.into_iter().map(Into::into).collect();
-        self.inner
-            .add_mint_keysets(cdk_mint_url, cdk_keysets)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_mints().await
     }
 
     async fn get_mint_keysets(
         &self,
         mint_url: MintUrl,
     ) -> Result<Option<Vec<KeySetInfo>>, FfiError> {
-        let cdk_mint_url = mint_url.try_into()?;
-        let result = self
-            .inner
-            .get_mint_keysets(cdk_mint_url)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect()))
+        self.inner.get_mint_keysets(mint_url).await
     }
 
     async fn get_keyset_by_id(&self, keyset_id: Id) -> Result<Option<KeySetInfo>, FfiError> {
-        let cdk_id = keyset_id.into();
-        let result = self
-            .inner
-            .get_keyset_by_id(&cdk_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(Into::into))
-    }
-
-    // Mint Quote Management
-    async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> {
-        let cdk_quote = quote.try_into()?;
-        self.inner
-            .add_mint_quote(cdk_quote)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_keyset_by_id(keyset_id).await
     }
 
     async fn get_mint_quote(&self, quote_id: String) -> Result<Option<MintQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_mint_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(|q| q.into()))
+        self.inner.get_mint_quote(quote_id).await
     }
 
     async fn get_mint_quotes(&self) -> Result<Vec<MintQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_mint_quotes()
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.into_iter().map(|q| q.into()).collect())
-    }
-
-    async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> {
-        self.inner
-            .remove_mint_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Melt Quote Management
-    async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> {
-        let cdk_quote = quote.try_into()?;
-        self.inner
-            .add_melt_quote(cdk_quote)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_mint_quotes().await
     }
 
     async fn get_melt_quote(&self, quote_id: String) -> Result<Option<MeltQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_melt_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(|q| q.into()))
+        self.inner.get_melt_quote(quote_id).await
     }
 
     async fn get_melt_quotes(&self) -> Result<Vec<MeltQuote>, FfiError> {
-        let result = self
-            .inner
-            .get_melt_quotes()
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.into_iter().map(|q| q.into()).collect())
-    }
-
-    async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> {
-        self.inner
-            .remove_melt_quote(&quote_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Keys Management
-    async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> {
-        // Convert FFI KeySet to cdk::nuts::KeySet
-        let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?;
-        self.inner
-            .add_keys(cdk_keyset)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_melt_quotes().await
     }
 
     async fn get_keys(&self, id: Id) -> Result<Option<Keys>, FfiError> {
-        let cdk_id = id.into();
-        let result = self
-            .inner
-            .get_keys(&cdk_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(Into::into))
-    }
-
-    async fn remove_keys(&self, id: Id) -> Result<(), FfiError> {
-        let cdk_id = id.into();
-        self.inner
-            .remove_keys(&cdk_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Proof Management
-    async fn update_proofs(
-        &self,
-        added: Vec<ProofInfo>,
-        removed_ys: Vec<PublicKey>,
-    ) -> Result<(), FfiError> {
-        // Convert FFI types to CDK types
-        let cdk_added: Result<Vec<cdk::types::ProofInfo>, FfiError> = added
-            .into_iter()
-            .map(|info| {
-                Ok::<cdk::types::ProofInfo, FfiError>(cdk::types::ProofInfo {
-                    proof: info.proof.try_into()?,
-                    y: info.y.try_into()?,
-                    mint_url: info.mint_url.try_into()?,
-                    state: info.state.into(),
-                    spending_condition: info
-                        .spending_condition
-                        .map(|sc| sc.try_into())
-                        .transpose()?,
-                    unit: info.unit.into(),
-                })
-            })
-            .collect();
-        let cdk_added = cdk_added?;
-
-        let cdk_removed_ys: Result<Vec<cdk::nuts::PublicKey>, FfiError> =
-            removed_ys.into_iter().map(|pk| pk.try_into()).collect();
-        let cdk_removed_ys = cdk_removed_ys?;
-
-        self.inner
-            .update_proofs(cdk_added, cdk_removed_ys)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_keys(id).await
     }
 
     async fn get_proofs(
@@ -303,25 +119,13 @@ impl WalletDatabase for WalletSqliteDatabase {
         state: Option<Vec<ProofState>>,
         spending_conditions: Option<Vec<SpendingConditions>>,
     ) -> Result<Vec<ProofInfo>, FfiError> {
-        let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?;
-        let cdk_unit = unit.map(Into::into);
-        let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect());
-        let cdk_spending_conditions: Option<Vec<cdk::nuts::SpendingConditions>> =
-            spending_conditions
-                .map(|sc| {
-                    sc.into_iter()
-                        .map(|c| c.try_into())
-                        .collect::<Result<Vec<_>, FfiError>>()
-                })
-                .transpose()?;
-
-        let result = self
-            .inner
-            .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions)
+        self.inner
+            .get_proofs(mint_url, unit, state, spending_conditions)
             .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
+    }
 
-        Ok(result.into_iter().map(Into::into).collect())
+    async fn get_proofs_by_ys(&self, ys: Vec<PublicKey>) -> Result<Vec<ProofInfo>, FfiError> {
+        self.inner.get_proofs_by_ys(ys).await
     }
 
     async fn get_balance(
@@ -330,63 +134,14 @@ impl WalletDatabase for WalletSqliteDatabase {
         unit: Option<CurrencyUnit>,
         state: Option<Vec<ProofState>>,
     ) -> Result<u64, FfiError> {
-        let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?;
-        let cdk_unit = unit.map(Into::into);
-        let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect());
-
-        self.inner
-            .get_balance(cdk_mint_url, cdk_unit, cdk_state)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    async fn update_proofs_state(
-        &self,
-        ys: Vec<PublicKey>,
-        state: ProofState,
-    ) -> Result<(), FfiError> {
-        let cdk_ys: Result<Vec<cdk::nuts::PublicKey>, FfiError> =
-            ys.into_iter().map(|pk| pk.try_into()).collect();
-        let cdk_ys = cdk_ys?;
-        let cdk_state = state.into();
-
-        self.inner
-            .update_proofs_state(cdk_ys, cdk_state)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Keyset Counter Management
-    async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result<u32, FfiError> {
-        let cdk_id = keyset_id.into();
-        self.inner
-            .increment_keyset_counter(&cdk_id, count)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
-    }
-
-    // Transaction Management
-    async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> {
-        // Convert FFI Transaction to CDK Transaction using TryFrom
-        let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?;
-
-        self.inner
-            .add_transaction(cdk_transaction)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
+        self.inner.get_balance(mint_url, unit, state).await
     }
 
     async fn get_transaction(
         &self,
         transaction_id: TransactionId,
     ) -> Result<Option<Transaction>, FfiError> {
-        let cdk_id = transaction_id.try_into()?;
-        let result = self
-            .inner
-            .get_transaction(cdk_id)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Ok(result.map(Into::into))
+        self.inner.get_transaction(transaction_id).await
     }
 
     async fn list_transactions(
@@ -395,24 +150,8 @@ impl WalletDatabase for WalletSqliteDatabase {
         direction: Option<TransactionDirection>,
         unit: Option<CurrencyUnit>,
     ) -> Result<Vec<Transaction>, FfiError> {
-        let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?;
-        let cdk_direction = direction.map(Into::into);
-        let cdk_unit = unit.map(Into::into);
-
-        let result = self
-            .inner
-            .list_transactions(cdk_mint_url, cdk_direction, cdk_unit)
-            .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-
-        Ok(result.into_iter().map(Into::into).collect())
-    }
-
-    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> {
-        let cdk_id = transaction_id.try_into()?;
         self.inner
-            .remove_transaction(cdk_id)
+            .list_transactions(mint_url, direction, unit)
             .await
-            .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
 }

+ 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::*;

+ 91 - 1
crates/cdk-ffi/src/wallet.rs

@@ -16,9 +16,16 @@ pub struct Wallet {
     inner: Arc<CdkWallet>,
 }
 
+impl Wallet {
+    /// Create a Wallet from an existing CDK wallet (internal use only)
+    pub(crate) fn from_inner(inner: Arc<CdkWallet>) -> Self {
+        Self { inner }
+    }
+}
+
 #[uniffi::export(async_runtime = "tokio")]
 impl Wallet {
-    /// Create a new Wallet from mnemonic using WalletDatabase trait
+    /// Create a new Wallet from mnemonic using WalletDatabaseFfi trait
     #[uniffi::constructor]
     pub fn new(
         mint_url: String,
@@ -62,6 +69,29 @@ impl Wallet {
         self.inner.unit.clone().into()
     }
 
+    /// Set metadata cache TTL (time-to-live) in seconds
+    ///
+    /// Controls how long cached mint metadata (keysets, keys, mint info) is considered fresh
+    /// before requiring a refresh from the mint server.
+    ///
+    /// # Arguments
+    ///
+    /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires and is always used.
+    ///
+    /// # Example
+    ///
+    /// ```ignore
+    /// // Cache expires after 5 minutes
+    /// wallet.set_metadata_cache_ttl(Some(300));
+    ///
+    /// // Cache never expires (default)
+    /// wallet.set_metadata_cache_ttl(None);
+    /// ```
+    pub fn set_metadata_cache_ttl(&self, ttl_secs: Option<u64>) {
+        let ttl = ttl_secs.map(std::time::Duration::from_secs);
+        self.inner.set_metadata_cache_ttl(ttl);
+    }
+
     /// Get total balance
     pub async fn total_balance(&self) -> Result<Amount, FfiError> {
         let balance = self.inner.total_balance().await?;
@@ -86,6 +116,14 @@ impl Wallet {
         Ok(info.map(Into::into))
     }
 
+    /// Load mint info
+    ///
+    /// This will get mint info from cache if it is fresh
+    pub async fn load_mint_info(&self) -> Result<MintInfo, FfiError> {
+        let info = self.inner.load_mint_info().await?;
+        Ok(info.into())
+    }
+
     /// Receive tokens
     pub async fn receive(
         &self,
@@ -329,6 +367,19 @@ impl Wallet {
         Ok(transaction.map(Into::into))
     }
 
+    /// Get proofs for a transaction by transaction ID
+    ///
+    /// This retrieves all proofs associated with a transaction by looking up
+    /// the transaction's Y values and fetching the corresponding proofs.
+    pub async fn get_proofs_for_transaction(
+        &self,
+        id: TransactionId,
+    ) -> Result<Vec<Proof>, FfiError> {
+        let cdk_id = id.try_into()?;
+        let proofs = self.inner.get_proofs_for_transaction(cdk_id).await?;
+        Ok(proofs.into_iter().map(Into::into).collect())
+    }
+
     /// Revert a transaction
     pub async fn revert_transaction(&self, id: TransactionId) -> Result<(), FfiError> {
         let cdk_id = id.try_into()?;
@@ -423,6 +474,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)
     }

+ 13 - 40
crates/cdk-integration-tests/src/lib.rs

@@ -23,7 +23,6 @@ use std::sync::Arc;
 use anyhow::{anyhow, bail, Result};
 use cashu::Bolt11Invoice;
 use cdk::amount::{Amount, SplitTarget};
-use cdk::nuts::State;
 use cdk::{StreamExt, Wallet};
 use cdk_fake_wallet::create_fake_invoice;
 use init_regtest::{get_lnd_dir, LND_RPC_ADDR};
@@ -37,6 +36,17 @@ pub mod init_pure_tests;
 pub mod init_regtest;
 pub mod shared;
 
+/// Generate standard keyset amounts as powers of 2
+///
+/// Returns a vector of amounts: [1, 2, 4, 8, 16, 32, ..., 2^(n-1)]
+/// where n is the number of amounts to generate.
+///
+/// # Arguments
+/// * `max_order` - The maximum power of 2 (exclusive). For example, max_order=32 generates amounts up to 2^31
+pub fn standard_keyset_amounts(max_order: u32) -> Vec<u64> {
+    (0..max_order).map(|n| 2u64.pow(n)).collect()
+}
+
 pub async fn fund_wallet(wallet: Arc<Wallet>, amount: Amount) {
     let quote = wallet
         .mint_quote(amount, None)
@@ -65,44 +75,7 @@ pub fn get_second_mint_url_from_env() -> String {
     }
 }
 
-// Get all pending from wallet and attempt to swap
-// Will panic if there are no pending
-// Will return Ok if swap fails as expected
-pub async fn attempt_to_swap_pending(wallet: &Wallet) -> Result<()> {
-    let pending = wallet
-        .localstore
-        .get_proofs(None, None, Some(vec![State::Pending]), None)
-        .await?;
-
-    assert!(!pending.is_empty());
-
-    let swap = wallet
-        .swap(
-            None,
-            SplitTarget::None,
-            pending.into_iter().map(|p| p.proof).collect(),
-            None,
-            false,
-        )
-        .await;
-
-    match swap {
-        Ok(_swap) => {
-            bail!("These proofs should be pending")
-        }
-        Err(err) => match err {
-            cdk::error::Error::TokenPending => (),
-            _ => {
-                println!("{err:?}");
-                bail!("Wrong error")
-            }
-        },
-    }
-
-    Ok(())
-}
-
-// 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");
@@ -171,7 +144,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(_) => {

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

@@ -515,11 +515,9 @@ async fn test_reuse_auth_proof() {
         assert!(quote.amount == Some(10.into()));
     }
 
-    wallet
-        .localstore
-        .update_proofs(proofs, vec![])
-        .await
-        .unwrap();
+    let mut tx = wallet.localstore.begin_db_transaction().await.unwrap();
+    tx.update_proofs(proofs, vec![]).await.unwrap();
+    tx.commit().await.unwrap();
 
     {
         let quote_res = wallet.mint_quote(10.into(), None).await;

+ 197 - 29
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -15,6 +15,7 @@
 //! - Duplicate proof detection
 
 use std::sync::Arc;
+use std::time::Duration;
 
 use bip39::Mnemonic;
 use cashu::Amount;
@@ -28,7 +29,6 @@ use cdk::wallet::types::TransactionDirection;
 use cdk::wallet::{HttpClient, MintConnector, Wallet};
 use cdk::StreamExt;
 use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
-use cdk_integration_tests::attempt_to_swap_pending;
 use cdk_sqlite::wallet::memory;
 
 const MINT_URL: &str = "http://127.0.0.1:8086";
@@ -70,7 +70,13 @@ async fn test_fake_tokens_pending() {
 
     assert!(melt.is_err());
 
-    attempt_to_swap_pending(&wallet).await.unwrap();
+    // melt failed, but there is new code to reclaim unspent proofs
+    assert!(!wallet
+        .localstore
+        .get_proofs(None, None, Some(vec![State::Pending]), None)
+        .await
+        .unwrap()
+        .is_empty());
 }
 
 /// Tests that if the pay error fails and the check returns unknown or failed,
@@ -126,15 +132,8 @@ async fn test_fake_melt_payment_fail() {
     let melt = wallet.melt(&melt_quote.id).await;
     assert!(melt.is_err());
 
-    // The mint should have unset proofs from pending since payment failed
-    let all_proof = wallet.get_unspent_proofs().await.unwrap();
-    let states = wallet.check_proofs_spent(all_proof).await.unwrap();
-    for state in states {
-        assert!(state.state == State::Unspent);
-    }
-
     let wallet_bal = wallet.total_balance().await.unwrap();
-    assert_eq!(wallet_bal, 100.into());
+    assert_eq!(wallet_bal, 98.into());
 }
 
 /// Tests that when both the pay_invoice and check_invoice both fail,
@@ -175,13 +174,12 @@ async fn test_fake_melt_payment_fail_and_check() {
     let melt = wallet.melt(&melt_quote.id).await;
     assert!(melt.is_err());
 
-    let pending = wallet
+    assert!(!wallet
         .localstore
         .get_proofs(None, None, Some(vec![State::Pending]), None)
         .await
-        .unwrap();
-
-    assert!(!pending.is_empty());
+        .unwrap()
+        .is_empty());
 }
 
 /// Tests that when the ln backend returns a failed status but does not error,
@@ -222,6 +220,16 @@ async fn test_fake_melt_payment_return_fail_status() {
     let melt = wallet.melt(&melt_quote.id).await;
     assert!(melt.is_err());
 
+    wallet.check_all_pending_proofs().await.unwrap();
+
+    let pending = wallet
+        .localstore
+        .get_proofs(None, None, Some(vec![State::Pending]), None)
+        .await
+        .unwrap();
+
+    assert!(pending.is_empty());
+
     let fake_description = FakeInvoiceDescription {
         pay_invoice_state: MeltQuoteState::Unknown,
         check_payment_state: MeltQuoteState::Unknown,
@@ -237,13 +245,14 @@ async fn test_fake_melt_payment_return_fail_status() {
     let melt = wallet.melt(&melt_quote.id).await;
     assert!(melt.is_err());
 
-    let pending = wallet
+    wallet.check_all_pending_proofs().await.unwrap();
+
+    assert!(!wallet
         .localstore
         .get_proofs(None, None, Some(vec![State::Pending]), None)
         .await
-        .unwrap();
-
-    assert!(pending.is_empty());
+        .unwrap()
+        .is_empty());
 }
 
 /// Tests that when the ln backend returns an error with unknown status,
@@ -282,7 +291,7 @@ async fn test_fake_melt_payment_error_unknown() {
 
     // The melt should error at the payment invoice command
     let melt = wallet.melt(&melt_quote.id).await;
-    assert_eq!(melt.unwrap_err().to_string(), "Payment failed");
+    assert!(melt.is_err());
 
     let fake_description = FakeInvoiceDescription {
         pay_invoice_state: MeltQuoteState::Unknown,
@@ -297,15 +306,14 @@ async fn test_fake_melt_payment_error_unknown() {
 
     // The melt should error at the payment invoice command
     let melt = wallet.melt(&melt_quote.id).await;
-    assert_eq!(melt.unwrap_err().to_string(), "Payment failed");
+    assert!(melt.is_err());
 
-    let pending = wallet
+    assert!(!wallet
         .localstore
         .get_proofs(None, None, Some(vec![State::Pending]), None)
         .await
-        .unwrap();
-
-    assert!(pending.is_empty());
+        .unwrap()
+        .is_empty());
 }
 
 /// Tests that when the ln backend returns an error but the second check returns paid,
@@ -331,6 +339,8 @@ async fn test_fake_melt_payment_err_paid() {
         .expect("payment")
         .expect("no error");
 
+    let old_balance = wallet.total_balance().await.expect("balance");
+
     let fake_description = FakeInvoiceDescription {
         pay_invoice_state: MeltQuoteState::Failed,
         check_payment_state: MeltQuoteState::Paid,
@@ -343,10 +353,23 @@ async fn test_fake_melt_payment_err_paid() {
     let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
 
     // The melt should error at the payment invoice command
-    let melt = wallet.melt(&melt_quote.id).await;
-    assert!(melt.is_err());
+    let melt = wallet.melt(&melt_quote.id).await.unwrap();
+
+    assert!(melt.fee_paid == Amount::ZERO);
+    assert!(melt.amount == Amount::from(7));
+
+    // melt failed, but there is new code to reclaim unspent proofs
+    assert_eq!(
+        old_balance - melt.amount,
+        wallet.total_balance().await.expect("new balance")
+    );
 
-    attempt_to_swap_pending(&wallet).await.unwrap();
+    assert!(wallet
+        .localstore
+        .get_proofs(None, None, Some(vec![State::Pending]), None)
+        .await
+        .unwrap()
+        .is_empty());
 }
 
 /// Tests that change outputs in a melt quote are correctly handled
@@ -860,7 +883,7 @@ async fn test_fake_mint_multiple_unit_melt() {
 
     let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
-    let proofs = proof_streams
+    let mut proofs = proof_streams
         .next()
         .await
         .expect("payment")
@@ -883,12 +906,15 @@ async fn test_fake_mint_multiple_unit_melt() {
     let mut proof_streams =
         wallet_usd.proof_stream(mint_quote.clone(), SplitTarget::default(), None);
 
-    let usd_proofs = proof_streams
+    let mut usd_proofs = proof_streams
         .next()
         .await
         .expect("payment")
         .expect("no error");
 
+    usd_proofs.reverse();
+    proofs.reverse();
+
     {
         let inputs: Proofs = vec![
             proofs.first().expect("There is a proof").clone(),
@@ -1373,3 +1399,145 @@ async fn test_fake_mint_duplicate_proofs_melt() {
         }
     }
 }
+
+/// Tests that wallet automatically recovers proofs after a failed melt operation
+/// by swapping them to new proofs, preventing loss of funds
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_wallet_proof_recovery_after_failed_melt() {
+    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");
+
+    // Mint 100 sats
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+    let _roof_streams = wallet
+        .wait_and_mint_quote(
+            mint_quote.clone(),
+            SplitTarget::default(),
+            None,
+            Duration::from_secs(1000),
+        )
+        .await;
+
+    assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(100));
+
+    // Create a melt quote that will fail
+    let fake_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Unknown,
+        check_payment_state: MeltQuoteState::Unpaid,
+        pay_err: true,
+        check_err: false,
+    };
+
+    let invoice = create_fake_invoice(1000, serde_json::to_string(&fake_description).unwrap());
+    let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap();
+
+    // Attempt to melt - this should fail but trigger proof recovery
+    let melt_result = wallet.melt(&melt_quote.id).await;
+    assert!(melt_result.is_err(), "Melt should have failed");
+
+    // Verify wallet still has balance (proofs recovered)
+    assert_eq!(
+        wallet.total_balance().await.unwrap(),
+        Amount::from(100),
+        "Balance should be recovered"
+    );
+
+    // Verify we can still spend the recovered proofs
+    let valid_invoice = create_fake_invoice(7000, "".to_string());
+    let valid_melt_quote = wallet
+        .melt_quote(valid_invoice.to_string(), None)
+        .await
+        .unwrap();
+
+    let successful_melt = wallet.melt(&valid_melt_quote.id).await;
+    assert!(
+        successful_melt.is_ok(),
+        "Should be able to spend recovered proofs"
+    );
+}
+
+/// Tests that wallet automatically recovers proofs after a failed swap operation
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_wallet_proof_recovery_after_failed_swap() {
+    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");
+
+    // Mint 100 sats
+    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 initial_proofs = proof_streams
+        .next()
+        .await
+        .expect("payment")
+        .expect("no error");
+
+    let initial_ys: Vec<_> = initial_proofs.iter().map(|p| p.y().unwrap()).collect();
+
+    assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(100));
+
+    let unspent_proofs = wallet.get_unspent_proofs().await.unwrap();
+
+    // Create an invalid swap by manually constructing a request that will fail
+    // We'll use the wallet's swap with invalid parameters to trigger a failure
+    let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Create invalid swap request (requesting more than we have)
+    let preswap = PreMintSecrets::random(
+        active_keyset_id,
+        1000.into(), // More than the 100 we have
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .unwrap();
+
+    let swap_request = SwapRequest::new(unspent_proofs.clone(), preswap.blinded_messages());
+
+    // Use HTTP client directly to bypass wallet's validation and trigger recovery
+    let http_client = HttpClient::new(MINT_URL.parse().unwrap(), None);
+    let response = http_client.post_swap(swap_request).await;
+    assert!(response.is_err(), "Swap should have failed");
+
+    // Note: The HTTP client doesn't trigger the wallet's try_proof_operation wrapper
+    // So we need to test through the wallet's own methods
+    // After the failed HTTP request, the proofs are still in the wallet's database
+
+    // Verify balance is still available after the failed operation
+    assert_eq!(
+        wallet.total_balance().await.unwrap(),
+        Amount::from(100),
+        "Balance should still be available"
+    );
+
+    // Verify we can perform a successful swap operation
+    let successful_swap = wallet
+        .swap(None, SplitTarget::None, unspent_proofs, None, false)
+        .await;
+
+    assert!(
+        successful_swap.is_ok(),
+        "Should be able to swap after failed operation"
+    );
+
+    // Verify the proofs were swapped to new ones
+    let final_proofs = wallet.get_unspent_proofs().await.unwrap();
+    let final_ys: Vec<_> = final_proofs.iter().map(|p| p.y().unwrap()).collect();
+
+    // The Ys should be different after the successful swap
+    assert!(
+        initial_ys.iter().any(|y| !final_ys.contains(y)),
+        "Proofs should have been swapped to new ones"
+    );
+}

+ 189 - 31
crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs

@@ -20,9 +20,10 @@ use std::time::Duration;
 use bip39::Mnemonic;
 use cashu::{MeltRequest, PreMintSecrets};
 use cdk::amount::{Amount, SplitTarget};
+use cdk::mint_url::MintUrl;
 use cdk::nuts::nut00::ProofsMethods;
 use cdk::nuts::{CurrencyUnit, MeltQuoteState, NotificationPayload, State};
-use cdk::wallet::{HttpClient, MintConnector, Wallet};
+use cdk::wallet::{HttpClient, MintConnector, MultiMintWallet, Wallet};
 use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest};
 use cdk_sqlite::wallet::memory;
 use futures::{SinkExt, StreamExt};
@@ -40,36 +41,41 @@ fn get_test_temp_dir() -> PathBuf {
     }
 }
 
-async fn get_notification<T: StreamExt<Item = Result<Message, E>> + Unpin, E: Debug>(
+async fn get_notifications<T: StreamExt<Item = Result<Message, E>> + Unpin, E: Debug>(
     reader: &mut T,
     timeout_to_wait: Duration,
-) -> (String, NotificationPayload<String>) {
-    let msg = timeout(timeout_to_wait, reader.next())
-        .await
-        .expect("timeout")
-        .unwrap()
-        .unwrap();
-
-    let mut response: serde_json::Value =
-        serde_json::from_str(msg.to_text().unwrap()).expect("valid json");
-
-    let mut params_raw = response
-        .as_object_mut()
-        .expect("object")
-        .remove("params")
-        .expect("valid params");
-
-    let params_map = params_raw.as_object_mut().expect("params is object");
-
-    (
-        params_map
-            .remove("subId")
+    total: usize,
+) -> Vec<(String, NotificationPayload<String>)> {
+    let mut results = Vec::new();
+    for _ in 0..total {
+        let msg = timeout(timeout_to_wait, reader.next())
+            .await
+            .expect("timeout")
             .unwrap()
-            .as_str()
-            .unwrap()
-            .to_string(),
-        serde_json::from_value(params_map.remove("payload").unwrap()).unwrap(),
-    )
+            .unwrap();
+
+        let mut response: serde_json::Value =
+            serde_json::from_str(msg.to_text().unwrap()).expect("valid json");
+
+        let mut params_raw = response
+            .as_object_mut()
+            .expect("object")
+            .remove("params")
+            .expect("valid params");
+
+        let params_map = params_raw.as_object_mut().expect("params is object");
+
+        results.push((
+            params_map
+                .remove("subId")
+                .unwrap()
+                .as_str()
+                .unwrap()
+                .to_string(),
+            serde_json::from_value(params_map.remove("payload").unwrap()).unwrap(),
+        ))
+    }
+    results
 }
 
 /// Tests a complete mint-melt round trip with WebSocket notifications
@@ -180,7 +186,11 @@ async fn test_happy_mint_melt_round_trip() {
     assert_eq!(tx.amount, melt.amount);
     assert_eq!(tx.metadata, metadata);
 
-    let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
+    let mut notifications = get_notifications(&mut reader, Duration::from_millis(15000), 3).await;
+    notifications.reverse();
+
+    let (sub_id, payload) = notifications.pop().unwrap();
+
     // first message is the current state
     assert_eq!("test-sub", sub_id);
     let payload = match payload {
@@ -193,7 +203,7 @@ async fn test_happy_mint_melt_round_trip() {
     assert_eq!(payload.state, MeltQuoteState::Unpaid);
 
     // get current state
-    let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
+    let (sub_id, payload) = notifications.pop().unwrap();
     assert_eq!("test-sub", sub_id);
     let payload = match payload {
         NotificationPayload::MeltQuoteBolt11Response(melt) => melt,
@@ -203,7 +213,7 @@ async fn test_happy_mint_melt_round_trip() {
     assert_eq!(payload.state, MeltQuoteState::Pending);
 
     // get current state
-    let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await;
+    let (sub_id, payload) = notifications.pop().unwrap();
     assert_eq!("test-sub", sub_id);
     let payload = match payload {
         NotificationPayload::MeltQuoteBolt11Response(melt) => melt,
@@ -347,6 +357,154 @@ async fn test_restore() {
     }
 }
 
+/// Tests that the melt quote status can be checked after a melt has completed
+///
+/// This test verifies:
+/// 1. Mint tokens
+/// 2. Create a melt quote and execute the melt
+/// 3. Check the melt quote status via the wallet
+/// 4. Verify the quote is in the Paid state
+///
+/// This ensures the mint correctly reports the melt quote status after completion.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_quote_status_after_melt() {
+    let wallet = Wallet::new(
+        &get_mint_url_from_env(),
+        CurrencyUnit::Sat,
+        Arc::new(memory::empty().await.unwrap()),
+        Mnemonic::generate(12).unwrap().to_seed_normalized(""),
+        None,
+    )
+    .expect("failed to create new wallet");
+
+    let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap();
+
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
+    pay_if_regtest(&get_test_temp_dir(), &invoice)
+        .await
+        .unwrap();
+
+    let proofs = wallet
+        .wait_and_mint_quote(
+            mint_quote.clone(),
+            SplitTarget::default(),
+            None,
+            tokio::time::Duration::from_secs(60),
+        )
+        .await
+        .expect("mint failed");
+
+    let mint_amount = proofs.total_amount().unwrap();
+    assert_eq!(mint_amount, 100.into());
+
+    let invoice = create_invoice_for_env(Some(50)).await.unwrap();
+
+    let melt_quote = wallet.melt_quote(invoice, None).await.unwrap();
+
+    let melt_response = wallet.melt(&melt_quote.id).await.unwrap();
+    assert_eq!(melt_response.state, MeltQuoteState::Paid);
+
+    let quote_status = wallet.melt_quote_status(&melt_quote.id).await.unwrap();
+    assert_eq!(
+        quote_status.state,
+        MeltQuoteState::Paid,
+        "Melt quote should be in Paid state after successful melt"
+    );
+
+    let db_quote = wallet
+        .localstore
+        .get_melt_quote(&melt_quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+
+    assert_eq!(
+        db_quote.state,
+        MeltQuoteState::Paid,
+        "Melt quote should be in Paid state after successful melt"
+    );
+}
+
+/// Tests that the melt quote status can be checked via MultiMintWallet after a melt has completed
+///
+/// This test verifies the same flow as test_melt_quote_status_after_melt but using
+/// the MultiMintWallet abstraction:
+/// 1. Create a MultiMintWallet and add a mint
+/// 2. Mint tokens via the multi mint wallet
+/// 3. Create a melt quote and execute the melt
+/// 4. Check the melt quote status via check_melt_quote
+/// 5. Verify the quote is in the Paid state
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_melt_quote_status_after_melt_multi_mint_wallet() {
+    let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
+    let localstore = Arc::new(memory::empty().await.unwrap());
+
+    let multi_mint_wallet = MultiMintWallet::new(localstore.clone(), seed, CurrencyUnit::Sat)
+        .await
+        .expect("failed to create multi mint wallet");
+
+    let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url");
+    multi_mint_wallet
+        .add_mint(mint_url.clone())
+        .await
+        .expect("failed to add mint");
+
+    let mint_quote = multi_mint_wallet
+        .mint_quote(&mint_url, 100.into(), None)
+        .await
+        .unwrap();
+
+    let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap();
+    pay_if_regtest(&get_test_temp_dir(), &invoice)
+        .await
+        .unwrap();
+
+    let _proofs = multi_mint_wallet
+        .wait_for_mint_quote(&mint_url, &mint_quote.id, SplitTarget::default(), None, 60)
+        .await
+        .expect("mint failed");
+
+    let balance = multi_mint_wallet.total_balance().await.unwrap();
+    assert_eq!(balance, 100.into());
+
+    let invoice = create_invoice_for_env(Some(50)).await.unwrap();
+
+    let melt_quote = multi_mint_wallet
+        .melt_quote(&mint_url, invoice, None)
+        .await
+        .unwrap();
+
+    let melt_response = multi_mint_wallet
+        .melt_with_mint(&mint_url, &melt_quote.id)
+        .await
+        .unwrap();
+    assert_eq!(melt_response.state, MeltQuoteState::Paid);
+
+    let quote_status = multi_mint_wallet
+        .check_melt_quote(&mint_url, &melt_quote.id)
+        .await
+        .unwrap();
+    assert_eq!(
+        quote_status.state,
+        MeltQuoteState::Paid,
+        "Melt quote should be in Paid state after successful melt (via MultiMintWallet)"
+    );
+
+    use cdk_common::database::WalletDatabase;
+
+    let db_quote = localstore
+        .get_melt_quote(&melt_quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+
+    assert_eq!(
+        db_quote.state,
+        MeltQuoteState::Paid,
+        "Melt quote should be in Paid state after successful melt"
+    );
+}
+
 /// Tests that change outputs in a melt quote are correctly handled
 ///
 /// This test verifies the following workflow:

+ 73 - 3
crates/cdk-integration-tests/tests/integration_tests_pure.rs

@@ -183,6 +183,19 @@ async fn test_mint_nut06() {
         .expect("Failed to get balance");
     assert_eq!(Amount::from(64), balance_alice);
 
+    // Verify keyset amounts after minting
+    let keyset_id = mint_bob.pubkeys().keysets.first().unwrap().id;
+    let total_issued = mint_bob.total_issued().await.unwrap();
+    let issued_amount = total_issued
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        issued_amount,
+        Amount::from(64),
+        "Should have issued 64 sats"
+    );
+
     let transaction = wallet_alice
         .list_transactions(None)
         .await
@@ -552,7 +565,11 @@ async fn test_swap_overpay_underpay_fee() {
         .expect("Failed to create test mint");
 
     mint_bob
-        .rotate_keyset(CurrencyUnit::Sat, 32, 1)
+        .rotate_keyset(
+            CurrencyUnit::Sat,
+            cdk_integration_tests::standard_keyset_amounts(32),
+            1,
+        )
         .await
         .unwrap();
 
@@ -627,7 +644,11 @@ async fn test_mint_enforce_fee() {
         .expect("Failed to create test mint");
 
     mint_bob
-        .rotate_keyset(CurrencyUnit::Sat, 32, 1)
+        .rotate_keyset(
+            CurrencyUnit::Sat,
+            cdk_integration_tests::standard_keyset_amounts(32),
+            1,
+        )
         .await
         .unwrap();
 
@@ -736,7 +757,11 @@ async fn test_mint_change_with_fee_melt() {
         .expect("Failed to create test mint");
 
     mint_bob
-        .rotate_keyset(CurrencyUnit::Sat, 32, 1)
+        .rotate_keyset(
+            CurrencyUnit::Sat,
+            cdk_integration_tests::standard_keyset_amounts(32),
+            1,
+        )
         .await
         .unwrap();
 
@@ -753,6 +778,28 @@ async fn test_mint_change_with_fee_melt() {
     .await
     .expect("Failed to fund wallet");
 
+    let keyset_id = mint_bob.pubkeys().keysets.first().unwrap().id;
+
+    // Check amounts after minting
+    let total_issued = mint_bob.total_issued().await.unwrap();
+    let total_redeemed = mint_bob.total_redeemed().await.unwrap();
+    let initial_issued = total_issued.get(&keyset_id).copied().unwrap_or_default();
+    let initial_redeemed = total_redeemed
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        initial_issued,
+        Amount::from(100),
+        "Should have issued 100 sats, got {:?}",
+        total_issued
+    );
+    assert_eq!(
+        initial_redeemed,
+        Amount::ZERO,
+        "Should have redeemed 0 sats initially, "
+    );
+
     let proofs = wallet_alice
         .get_unspent_proofs()
         .await
@@ -771,6 +818,29 @@ async fn test_mint_change_with_fee_melt() {
         .unwrap();
 
     assert_eq!(w.change.unwrap().total_amount().unwrap(), 97.into());
+
+    // Check amounts after melting
+    // Melting redeems 100 sats and issues 97 sats as change
+    let total_issued = mint_bob.total_issued().await.unwrap();
+    let total_redeemed = mint_bob.total_redeemed().await.unwrap();
+    let after_issued = total_issued
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let after_redeemed = total_redeemed
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        after_issued,
+        Amount::from(197),
+        "Should have issued 197 sats total (100 initial + 97 change)"
+    );
+    assert_eq!(
+        after_redeemed,
+        Amount::from(100),
+        "Should have redeemed 100 sats from the melt"
+    );
 }
 /// Tests concurrent double-spending attempts by trying to use the same proofs
 /// in 3 swap transactions simultaneously using tokio tasks

+ 177 - 2
crates/cdk-integration-tests/tests/mint.rs

@@ -74,7 +74,13 @@ async fn test_correct_keyset() {
         .expect("There is a keyset for unit");
     let old_keyset_info = mint.get_keyset_info(active).expect("There is keyset");
 
-    mint.rotate_keyset(CurrencyUnit::Sat, 32, 0).await.unwrap();
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        0,
+    )
+    .await
+    .unwrap();
 
     let active = mint.get_active_keysets();
 
@@ -86,7 +92,13 @@ async fn test_correct_keyset() {
 
     assert_ne!(keyset_info.id, old_keyset_info.id);
 
-    mint.rotate_keyset(CurrencyUnit::Sat, 32, 0).await.unwrap();
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        0,
+    )
+    .await
+    .unwrap();
 
     let active = mint.get_active_keysets();
 
@@ -98,3 +110,166 @@ async fn test_correct_keyset() {
 
     assert_ne!(new_keyset_info.id, keyset_info.id);
 }
+
+/// Test concurrent payment processing to verify race condition fix
+///
+/// This test simulates the real-world race condition where multiple concurrent
+/// payment notifications arrive for the same payment_id. Before the fix, this
+/// would cause "Payment ID already exists" errors. After the fix, all but one
+/// should gracefully handle the duplicate and return a Duplicate error.
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn test_concurrent_duplicate_payment_handling() {
+    use cashu::PaymentMethod;
+    use cdk::cdk_database::{MintDatabase, MintQuotesDatabase};
+    use cdk::mint::MintQuote;
+    use cdk::Amount;
+    use cdk_common::payment::PaymentIdentifier;
+    use tokio::task::JoinSet;
+
+    // Create a test mint with in-memory database
+    let mnemonic = Mnemonic::generate(12).unwrap();
+    let fee_reserve = FeeReserve {
+        min_fee_reserve: 1.into(),
+        percent_fee_reserve: 1.0,
+    };
+
+    let database = Arc::new(memory::empty().await.expect("valid db instance"));
+
+    let fake_wallet = FakeWallet::new(
+        fee_reserve,
+        HashMap::default(),
+        HashSet::default(),
+        0,
+        CurrencyUnit::Sat,
+    );
+
+    let mut mint_builder = MintBuilder::new(database.clone());
+
+    mint_builder = mint_builder
+        .with_name("concurrent test mint".to_string())
+        .with_description("testing concurrent payment handling".to_string());
+
+    mint_builder
+        .add_payment_processor(
+            CurrencyUnit::Sat,
+            PaymentMethod::Bolt11,
+            MintMeltLimits::new(1, 5_000),
+            Arc::new(fake_wallet),
+        )
+        .await
+        .unwrap();
+
+    let mint = mint_builder
+        .build_with_seed(database.clone(), &mnemonic.to_seed_normalized(""))
+        .await
+        .unwrap();
+
+    let quote_ttl = QuoteTTL::new(10000, 10000);
+    mint.set_quote_ttl(quote_ttl).await.unwrap();
+
+    // Create a mint quote
+    let current_time = cdk::util::unix_time();
+    let mint_quote = MintQuote::new(
+        None,
+        "concurrent_test_invoice".to_string(),
+        CurrencyUnit::Sat,
+        Some(Amount::from(1000)),
+        current_time + 3600, // expires in 1 hour
+        PaymentIdentifier::CustomId("test_lookup_id".to_string()),
+        None,
+        Amount::ZERO,
+        Amount::ZERO,
+        PaymentMethod::Bolt11,
+        current_time,
+        vec![],
+        vec![],
+    );
+
+    // Add the quote to the database
+    {
+        let mut tx = MintDatabase::begin_transaction(&*database).await.unwrap();
+        tx.add_mint_quote(mint_quote.clone()).await.unwrap();
+        tx.commit().await.unwrap();
+    }
+
+    // Simulate 10 concurrent payment notifications with the SAME payment_id
+    let payment_id = "duplicate_payment_test_12345";
+    let mut join_set = JoinSet::new();
+
+    for i in 0..10 {
+        let db_clone = database.clone();
+        let quote_id = mint_quote.id.clone();
+        let payment_id_clone = payment_id.to_string();
+
+        join_set.spawn(async move {
+            let mut tx = MintDatabase::begin_transaction(&*db_clone).await.unwrap();
+            let result = tx
+                .increment_mint_quote_amount_paid(&quote_id, Amount::from(10), payment_id_clone)
+                .await;
+
+            if result.is_ok() {
+                tx.commit().await.unwrap();
+            }
+
+            (i, result)
+        });
+    }
+
+    // Collect results
+    let mut success_count = 0;
+    let mut duplicate_errors = 0;
+    let mut other_errors = Vec::new();
+
+    while let Some(result) = join_set.join_next().await {
+        let (task_id, db_result) = result.unwrap();
+        match db_result {
+            Ok(_) => success_count += 1,
+            Err(e) => {
+                let err_str = format!("{:?}", e);
+                if err_str.contains("Duplicate") {
+                    duplicate_errors += 1;
+                } else {
+                    other_errors.push((task_id, err_str));
+                }
+            }
+        }
+    }
+
+    // Verify results
+    assert_eq!(
+        success_count, 1,
+        "Exactly one task should successfully process the payment (got {})",
+        success_count
+    );
+    assert_eq!(
+        duplicate_errors, 9,
+        "Nine tasks should receive Duplicate error (got {})",
+        duplicate_errors
+    );
+    assert!(
+        other_errors.is_empty(),
+        "No unexpected errors should occur. Got: {:?}",
+        other_errors
+    );
+
+    // Verify the quote was incremented exactly once
+    let final_quote = MintQuotesDatabase::get_mint_quote(&*database, &mint_quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should exist");
+
+    assert_eq!(
+        final_quote.amount_paid(),
+        Amount::from(10),
+        "Quote amount should be incremented exactly once"
+    );
+    assert_eq!(
+        final_quote.payments.len(),
+        1,
+        "Should have exactly one payment recorded"
+    );
+    assert_eq!(
+        final_quote.payments[0].payment_id, payment_id,
+        "Payment ID should match"
+    );
+}

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

@@ -9,9 +9,15 @@ use cdk::wallet::{ReceiveOptions, SendKind, SendOptions, Wallet};
 use cdk_integration_tests::init_regtest::get_temp_dir;
 use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest};
 use cdk_sqlite::wallet::memory;
+use tracing_subscriber::EnvFilter;
 
 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
 async fn test_swap() {
+    // Set up logging
+    let default_filter = "debug";
+    let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn";
+    let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter));
+    tracing_subscriber::fmt().with_env_filter(env_filter).init();
     let seed = Mnemonic::generate(12).unwrap().to_seed_normalized("");
     let wallet = Wallet::new(
         &get_mint_url_from_env(),
@@ -39,6 +45,8 @@ async fn test_swap() {
 
     println!("{:?}", proofs);
 
+    println!("{:?}", wallet.get_mint_keysets().await.unwrap());
+
     let send = wallet
         .prepare_send(
             4.into(),

+ 311 - 4
crates/cdk-integration-tests/tests/test_swap_flow.rs

@@ -58,6 +58,29 @@ async fn test_swap_happy_path() {
         .expect("Could not get proofs");
 
     let keyset_id = get_keyset_id(&mint).await;
+
+    // Check initial amounts after minting
+    let total_issued = mint.total_issued().await.unwrap();
+    let total_redeemed = mint.total_redeemed().await.unwrap();
+    let initial_issued = total_issued
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let initial_redeemed = total_redeemed
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        initial_issued,
+        Amount::from(100),
+        "Should have issued 100 sats"
+    );
+    assert_eq!(
+        initial_redeemed,
+        Amount::ZERO,
+        "Should have redeemed 0 sats initially"
+    );
+
     let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
 
     // Create swap request for same amount (100 sats)
@@ -117,6 +140,29 @@ async fn test_swap_happy_path() {
         swap_response.signatures.len(),
         "All signatures should be saved"
     );
+
+    // Check keyset amounts after swap
+    // Swap redeems old proofs (100 sats) and issues new proofs (100 sats)
+    let total_issued = mint.total_issued().await.unwrap();
+    let total_redeemed = mint.total_redeemed().await.unwrap();
+    let after_issued = total_issued
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let after_redeemed = total_redeemed
+        .get(&keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    assert_eq!(
+        after_issued,
+        Amount::from(200),
+        "Should have issued 200 sats total (initial 100 + swap 100)"
+    );
+    assert_eq!(
+        after_redeemed,
+        Amount::from(100),
+        "Should have redeemed 100 sats from the swap"
+    );
 }
 
 /// Tests that duplicate blinded messages are rejected:
@@ -611,9 +657,13 @@ async fn test_swap_with_fees() {
         .expect("Failed to create test wallet");
 
     // Rotate to keyset with 1 sat per proof fee
-    mint.rotate_keyset(CurrencyUnit::Sat, 32, 1)
-        .await
-        .expect("Failed to rotate keyset");
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        1,
+    )
+    .await
+    .expect("Failed to rotate keyset");
 
     // Fund with 1000 sats as individual 1-sat proofs using the fee-based keyset
     // Wait a bit for keyset to be available
@@ -815,7 +865,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"),
@@ -901,3 +951,260 @@ async fn test_swap_proof_state_consistency() {
         }
     }
 }
+
+/// Tests that wallet correctly increments keyset counters when receiving proofs
+/// from multiple keysets and then performing operations with them.
+///
+/// This test validates:
+/// 1. Wallet can receive proofs from multiple different keysets
+/// 2. Counter is correctly incremented for the target keyset during swap
+/// 3. Database maintains separate counters for each keyset
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_wallet_multi_keyset_counter_updates() {
+    setup_tracing();
+    let mint = create_and_start_test_mint()
+        .await
+        .expect("Failed to create test mint");
+    let wallet = create_test_wallet_for_mint(mint.clone())
+        .await
+        .expect("Failed to create test wallet");
+
+    // Fund wallet with initial 100 sats using first keyset
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let first_keyset_id = get_keyset_id(&mint).await;
+
+    // Rotate to a second keyset
+    mint.rotate_keyset(
+        CurrencyUnit::Sat,
+        cdk_integration_tests::standard_keyset_amounts(32),
+        0,
+    )
+    .await
+    .expect("Failed to rotate keyset");
+
+    // Wait for keyset rotation to propagate
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    // Refresh wallet keysets to know about the new keyset
+    wallet
+        .refresh_keysets()
+        .await
+        .expect("Failed to refresh wallet keysets");
+
+    // Fund wallet again with 100 sats using second keyset
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet with second keyset");
+
+    let second_keyset_id = mint
+        .pubkeys()
+        .keysets
+        .iter()
+        .find(|k| k.id != first_keyset_id)
+        .expect("Should have second keyset")
+        .id;
+
+    // Verify we now have proofs from two different keysets
+    let all_proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keysets_in_use: std::collections::HashSet<_> =
+        all_proofs.iter().map(|p| p.keyset_id).collect();
+
+    assert_eq!(
+        keysets_in_use.len(),
+        2,
+        "Should have proofs from 2 different keysets"
+    );
+    assert!(
+        keysets_in_use.contains(&first_keyset_id),
+        "Should have proofs from first keyset"
+    );
+    assert!(
+        keysets_in_use.contains(&second_keyset_id),
+        "Should have proofs from second keyset"
+    );
+
+    // Get initial total issued and redeemed for both keysets before swap
+    let total_issued_before = mint.total_issued().await.unwrap();
+    let total_redeemed_before = mint.total_redeemed().await.unwrap();
+
+    let first_keyset_issued_before = total_issued_before
+        .get(&first_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let first_keyset_redeemed_before = total_redeemed_before
+        .get(&first_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+
+    let second_keyset_issued_before = total_issued_before
+        .get(&second_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let second_keyset_redeemed_before = total_redeemed_before
+        .get(&second_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+
+    tracing::info!(
+        "Before swap - First keyset: issued={}, redeemed={}",
+        first_keyset_issued_before,
+        first_keyset_redeemed_before
+    );
+    tracing::info!(
+        "Before swap - Second keyset: issued={}, redeemed={}",
+        second_keyset_issued_before,
+        second_keyset_redeemed_before
+    );
+
+    // Both keysets should have issued 100 sats
+    assert_eq!(
+        first_keyset_issued_before,
+        Amount::from(100),
+        "First keyset should have issued 100 sats"
+    );
+    assert_eq!(
+        second_keyset_issued_before,
+        Amount::from(100),
+        "Second keyset should have issued 100 sats"
+    );
+    // Neither should have redeemed anything yet
+    assert_eq!(
+        first_keyset_redeemed_before,
+        Amount::ZERO,
+        "First keyset should have redeemed 0 sats before swap"
+    );
+    assert_eq!(
+        second_keyset_redeemed_before,
+        Amount::ZERO,
+        "Second keyset should have redeemed 0 sats before swap"
+    );
+
+    // Now perform a swap with all proofs - this should only increment the counter
+    // for the active (second) keyset, not for the first keyset
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    let total_amount = all_proofs.total_amount().expect("Should get total amount");
+
+    // Create swap using the active (second) keyset
+    let preswap = PreMintSecrets::random(
+        second_keyset_id,
+        total_amount,
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request = SwapRequest::new(all_proofs.clone(), preswap.blinded_messages());
+
+    // Execute the swap
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Swap should succeed");
+
+    // Verify response
+    assert_eq!(
+        swap_response.signatures.len(),
+        preswap.blinded_messages().len(),
+        "Should receive signature for each blinded message"
+    );
+
+    // All the new proofs should be from the second (active) keyset
+    let keys = mint
+        .pubkeys()
+        .keysets
+        .iter()
+        .find(|k| k.id == second_keyset_id)
+        .expect("Should find second keyset")
+        .keys
+        .clone();
+
+    let new_proofs = construct_proofs(
+        swap_response.signatures,
+        preswap.rs(),
+        preswap.secrets(),
+        &keys,
+    )
+    .expect("Failed to construct proofs");
+
+    // Verify all new proofs use the second keyset
+    for proof in &new_proofs {
+        assert_eq!(
+            proof.keyset_id, second_keyset_id,
+            "All new proofs should use the active (second) keyset"
+        );
+    }
+
+    // Verify total issued and redeemed after swap
+    let total_issued_after = mint.total_issued().await.unwrap();
+    let total_redeemed_after = mint.total_redeemed().await.unwrap();
+
+    let first_keyset_issued_after = total_issued_after
+        .get(&first_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let first_keyset_redeemed_after = total_redeemed_after
+        .get(&first_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+
+    let second_keyset_issued_after = total_issued_after
+        .get(&second_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+    let second_keyset_redeemed_after = total_redeemed_after
+        .get(&second_keyset_id)
+        .copied()
+        .unwrap_or(Amount::ZERO);
+
+    tracing::info!(
+        "After swap - First keyset: issued={}, redeemed={}",
+        first_keyset_issued_after,
+        first_keyset_redeemed_after
+    );
+    tracing::info!(
+        "After swap - Second keyset: issued={}, redeemed={}",
+        second_keyset_issued_after,
+        second_keyset_redeemed_after
+    );
+
+    // After swap:
+    // - First keyset: issued stays 100, redeemed increases by 100 (all its proofs were spent in swap)
+    // - Second keyset: issued increases by 200 (original 100 + new 100 from swap output),
+    //                  redeemed increases by 100 (its proofs from first funding were spent)
+    assert_eq!(
+        first_keyset_issued_after,
+        Amount::from(100),
+        "First keyset issued should stay 100 sats (no new issuance)"
+    );
+    assert_eq!(
+        first_keyset_redeemed_after,
+        Amount::from(100),
+        "First keyset should have redeemed 100 sats (all its proofs spent in swap)"
+    );
+
+    assert_eq!(
+        second_keyset_issued_after,
+        Amount::from(300),
+        "Second keyset should have issued 300 sats total (100 initial + 100 the second funding + 100 from swap output from the old keyset)"
+    );
+    assert_eq!(
+        second_keyset_redeemed_after,
+        Amount::from(100),
+        "Second keyset should have redeemed 100 sats (its proofs from initial funding spent in swap)"
+    );
+
+    // The test verifies that:
+    // 1. We can have proofs from multiple keysets in a wallet
+    // 2. Swap operation processes inputs from any keyset but creates outputs using active keyset
+    // 3. The keyset_counter table correctly handles counters for different keysets independently
+    // 4. The database upsert logic in increment_keyset_counter works for multiple keysets
+    // 5. Total issued and redeemed are tracked correctly per keyset during multi-keyset swaps
+}

+ 4 - 2
crates/cdk-ldk-node/src/lib.rs

@@ -731,7 +731,8 @@ impl MintPayment for CdkLdkNode {
 
                 let total_spent = payment_details
                     .amount_msat
-                    .ok_or(Error::CouldNotGetAmountSpent)?;
+                    .ok_or(Error::CouldNotGetAmountSpent)?
+                    + payment_details.fee_paid_msat.unwrap_or_default();
 
                 let total_spent = to_unit(total_spent, &CurrencyUnit::Msat, unit)?;
 
@@ -805,7 +806,8 @@ impl MintPayment for CdkLdkNode {
 
                 let total_spent = payment_details
                     .amount_msat
-                    .ok_or(Error::CouldNotGetAmountSpent)?;
+                    .ok_or(Error::CouldNotGetAmountSpent)?
+                    + payment_details.fee_paid_msat.unwrap_or_default();
 
                 let total_spent = to_unit(total_spent, &CurrencyUnit::Msat, unit)?;
 

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

@@ -158,7 +158,13 @@ pub async fn payments_page(
                         get_invoice_status(payment.status, payment.direction, payment_type)
                     };
 
-                    @let amount_str = payment.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string());
+                    @let amount_str = {
+                        match (payment.amount_msat, payment.fee_paid_msat) {
+                            (Some(amount), Some(fee)) => format_msats_as_btc(amount + fee),
+                            (Some(amount), None) => format_msats_as_btc(amount),
+                            _ => "Unknown".to_string()
+                        }
+                    };
 
                     (payment_list_item(
                         &payment.id.to_string(),

+ 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).

+ 4 - 3
crates/cdk-lnbits/src/lib.rs

@@ -11,7 +11,7 @@ use std::sync::Arc;
 
 use anyhow::anyhow;
 use async_trait::async_trait;
-use cdk_common::amount::{to_unit, Amount};
+use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT};
 use cdk_common::common::FeeReserve;
 use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState};
 use cdk_common::payment::{
@@ -244,7 +244,8 @@ impl MintPayment for LNbits {
                 let relative_fee_reserve =
                     (self.fee_reserve.percent_fee_reserve * u64::from(amount_msat) as f32) as u64;
 
-                let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
+                let absolute_fee_reserve: u64 =
+                    u64::from(self.fee_reserve.min_fee_reserve) * MSAT_IN_SAT;
 
                 let fee = max(relative_fee_reserve, absolute_fee_reserve);
 
@@ -253,7 +254,7 @@ impl MintPayment for LNbits {
                         *bolt11_options.bolt11.payment_hash().as_ref(),
                     )),
                     amount: to_unit(amount_msat, &CurrencyUnit::Msat, unit)?,
-                    fee: fee.into(),
+                    fee: to_unit(fee, &CurrencyUnit::Msat, unit)?,
                     state: MeltQuoteState::Unpaid,
                     unit: unit.clone(),
                 })

+ 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)
 

+ 14 - 5
crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs

@@ -16,9 +16,9 @@ pub struct RotateNextKeysetCommand {
     #[arg(short, long)]
     #[arg(default_value = "sat")]
     unit: String,
-    /// The maximum order (power of 2) for tokens that can be minted with this keyset
+    /// The amounts that can be minted with this keyset (e.g., "1,2,4,8,16")
     #[arg(short, long)]
-    max_order: Option<u8>,
+    amounts: Option<String>,
     /// The input fee in parts per thousand to apply when minting with this keyset
     #[arg(short, long)]
     input_fee_ppk: Option<u64>,
@@ -36,10 +36,19 @@ pub async fn rotate_next_keyset(
     client: &mut CdkMintClient<Channel>,
     sub_command_args: &RotateNextKeysetCommand,
 ) -> Result<()> {
+    let amounts = if let Some(amounts_str) = &sub_command_args.amounts {
+        amounts_str
+            .split(',')
+            .map(|s| s.trim().parse::<u64>())
+            .collect::<Result<Vec<u64>, _>>()?
+    } else {
+        vec![]
+    };
+
     let response = client
         .rotate_next_keyset(Request::new(RotateNextKeysetRequest {
             unit: sub_command_args.unit.clone(),
-            max_order: sub_command_args.max_order.map(|m| m.into()),
+            amounts,
             input_fee_ppk: sub_command_args.input_fee_ppk,
         }))
         .await?;
@@ -47,8 +56,8 @@ pub async fn rotate_next_keyset(
     let response = response.into_inner();
 
     println!(
-        "Rotated to new keyset {} for unit {} with a max order of {} and fee of {}",
-        response.id, response.unit, response.max_order, response.input_fee_ppk
+        "Rotated to new keyset {} for unit {} with amounts {:?} and fee of {}",
+        response.id, response.unit, response.amounts, response.input_fee_ppk
     );
 
     Ok(())

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

@@ -122,7 +122,7 @@ message UpdateNut04QuoteRequest {
 
 message RotateNextKeysetRequest {
     string unit = 1;
-    optional uint32 max_order = 2;
+    repeated uint64 amounts = 2;
     optional uint64 input_fee_ppk = 3;
 }
 
@@ -130,6 +130,6 @@ message RotateNextKeysetRequest {
 message RotateNextKeysetResponse {
     string id = 1;
     string unit = 2;
-    uint32 max_order = 3;
+    repeated uint64 amounts = 3;
     uint64 input_fee_ppk = 4;
 }

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

@@ -730,20 +730,22 @@ impl CdkMint for MintRPCServer {
         let unit = CurrencyUnit::from_str(&request.unit)
             .map_err(|_| Status::invalid_argument("Invalid unit".to_string()))?;
 
+        let amounts = if request.amounts.is_empty() {
+            return Err(Status::invalid_argument("amounts cannot be empty"));
+        } else {
+            request.amounts
+        };
+
         let keyset_info = self
             .mint
-            .rotate_keyset(
-                unit,
-                request.max_order.map(|a| a as u8).unwrap_or(32),
-                request.input_fee_ppk.unwrap_or(0),
-            )
+            .rotate_keyset(unit, amounts, request.input_fee_ppk.unwrap_or(0))
             .await
             .map_err(|_| Status::invalid_argument("Could not rotate keyset".to_string()))?;
 
         Ok(Response::new(RotateNextKeysetResponse {
             id: keyset_info.id.to_string(),
             unit: keyset_info.unit.to_string(),
-            max_order: keyset_info.max_order.into(),
+            amounts: keyset_info.amounts,
             input_fee_ppk: keyset_info.input_fee_ppk,
         }))
     }

+ 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 - 11
crates/cdk-mintd/example.config.toml

@@ -94,27 +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.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;

+ 555 - 452
crates/cdk-redb/src/wallet/mod.rs

@@ -54,6 +54,29 @@ pub struct WalletRedbDatabase {
     db: Arc<Database>,
 }
 
+/// Redb Wallet Transaction
+pub struct RedbWalletTransaction {
+    write_txn: Option<redb::WriteTransaction>,
+}
+
+impl RedbWalletTransaction {
+    /// Create a new transaction
+    fn new(write_txn: redb::WriteTransaction) -> Self {
+        Self {
+            write_txn: Some(write_txn),
+        }
+    }
+
+    /// Get a mutable reference to the write transaction
+    fn txn(&mut self) -> Result<&mut redb::WriteTransaction, Error> {
+        self.write_txn.as_mut().ok_or_else(|| {
+            Error::CDKDatabase(database::Error::Internal(
+                "Transaction already consumed".to_owned(),
+            ))
+        })
+    }
+}
+
 impl WalletRedbDatabase {
     /// Create new [`WalletRedbDatabase`]
     pub fn new(path: &Path) -> Result<Self, Error> {
@@ -63,7 +86,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 +194,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:?}"),
                 )));
             }
         }
@@ -189,45 +212,6 @@ impl WalletDatabase for WalletRedbDatabase {
     type Err = database::Error;
 
     #[instrument(skip(self))]
-    async fn add_mint(
-        &self,
-        mint_url: MintUrl,
-        mint_info: Option<MintInfo>,
-    ) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        {
-            let mut table = write_txn.open_table(MINTS_TABLE).map_err(Error::from)?;
-            table
-                .insert(
-                    mint_url.to_string().as_str(),
-                    serde_json::to_string(&mint_info)
-                        .map_err(Error::from)?
-                        .as_str(),
-                )
-                .map_err(Error::from)?;
-        }
-        write_txn.commit().map_err(Error::from)?;
-
-        Ok(())
-    }
-
-    #[instrument(skip(self))]
-    async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        {
-            let mut table = write_txn.open_table(MINTS_TABLE).map_err(Error::from)?;
-            table
-                .remove(mint_url.to_string().as_str())
-                .map_err(Error::from)?;
-        }
-        write_txn.commit().map_err(Error::from)?;
-
-        Ok(())
-    }
-
-    #[instrument(skip(self))]
     async fn get_mint(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, Self::Err> {
         let read_txn = self.db.begin_read().map_err(Into::<Error>::into)?;
         let table = read_txn.open_table(MINTS_TABLE).map_err(Error::from)?;
@@ -262,149 +246,6 @@ impl WalletDatabase for WalletRedbDatabase {
     }
 
     #[instrument(skip(self))]
-    async fn update_mint_url(
-        &self,
-        old_mint_url: MintUrl,
-        new_mint_url: MintUrl,
-    ) -> Result<(), Self::Err> {
-        // Update proofs table
-        {
-            let proofs = self
-                .get_proofs(Some(old_mint_url.clone()), None, None, None)
-                .await
-                .map_err(Error::from)?;
-
-            // Proofs with new url
-            let updated_proofs: Vec<ProofInfo> = proofs
-                .clone()
-                .into_iter()
-                .map(|mut p| {
-                    p.mint_url = new_mint_url.clone();
-                    p
-                })
-                .collect();
-
-            if !updated_proofs.is_empty() {
-                self.update_proofs(updated_proofs, vec![]).await?;
-            }
-        }
-
-        // Update mint quotes
-        {
-            let quotes = self.get_mint_quotes().await?;
-
-            let unix_time = unix_time();
-
-            let quotes: Vec<MintQuote> = quotes
-                .into_iter()
-                .filter_map(|mut q| {
-                    if q.expiry < unix_time {
-                        q.mint_url = new_mint_url.clone();
-                        Some(q)
-                    } else {
-                        None
-                    }
-                })
-                .collect();
-
-            for quote in quotes {
-                self.add_mint_quote(quote).await?;
-            }
-        }
-
-        Ok(())
-    }
-
-    #[instrument(skip(self))]
-    async fn add_mint_keysets(
-        &self,
-        mint_url: MintUrl,
-        keysets: Vec<KeySetInfo>,
-    ) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        let mut existing_u32 = false;
-
-        {
-            let mut table = write_txn
-                .open_multimap_table(MINT_KEYSETS_TABLE)
-                .map_err(Error::from)?;
-            let mut keysets_table = write_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
-            let mut u32_table = write_txn
-                .open_table(KEYSET_U32_MAPPING)
-                .map_err(Error::from)?;
-
-            for keyset in keysets {
-                // Check if keyset already exists
-                let existing_keyset = {
-                    let existing_keyset = keysets_table
-                        .get(keyset.id.to_bytes().as_slice())
-                        .map_err(Error::from)?;
-
-                    existing_keyset.map(|r| r.value().to_string())
-                };
-
-                let existing = u32_table
-                    .insert(u32::from(keyset.id), keyset.id.to_string().as_str())
-                    .map_err(Error::from)?;
-
-                match existing {
-                    None => existing_u32 = false,
-                    Some(id) => {
-                        let id = Id::from_str(id.value())?;
-
-                        if id == keyset.id {
-                            existing_u32 = false;
-                        } else {
-                            println!("Breaking here");
-                            existing_u32 = true;
-                            break;
-                        }
-                    }
-                }
-
-                let keyset = if let Some(existing_keyset) = existing_keyset {
-                    let mut existing_keyset: KeySetInfo = serde_json::from_str(&existing_keyset)?;
-
-                    existing_keyset.active = keyset.active;
-                    existing_keyset.input_fee_ppk = keyset.input_fee_ppk;
-
-                    existing_keyset
-                } else {
-                    table
-                        .insert(
-                            mint_url.to_string().as_str(),
-                            keyset.id.to_bytes().as_slice(),
-                        )
-                        .map_err(Error::from)?;
-
-                    keyset
-                };
-
-                keysets_table
-                    .insert(
-                        keyset.id.to_bytes().as_slice(),
-                        serde_json::to_string(&keyset)
-                            .map_err(Error::from)?
-                            .as_str(),
-                    )
-                    .map_err(Error::from)?;
-            }
-        }
-
-        if existing_u32 {
-            tracing::warn!("Keyset already exists for keyset id");
-            write_txn.abort().map_err(Error::from)?;
-
-            return Err(database::Error::Duplicate);
-        }
-
-        write_txn.commit().map_err(Error::from)?;
-
-        Ok(())
-    }
-
-    #[instrument(skip(self))]
     async fn get_mint_keysets(
         &self,
         mint_url: MintUrl,
@@ -462,27 +303,6 @@ impl WalletDatabase for WalletRedbDatabase {
     }
 
     #[instrument(skip_all)]
-    async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        {
-            let mut table = write_txn
-                .open_table(MINT_QUOTES_TABLE)
-                .map_err(Error::from)?;
-            table
-                .insert(
-                    quote.id.as_str(),
-                    serde_json::to_string(&quote).map_err(Error::from)?.as_str(),
-                )
-                .map_err(Error::from)?;
-        }
-
-        write_txn.commit().map_err(Error::from)?;
-
-        Ok(())
-    }
-
-    #[instrument(skip_all)]
     async fn get_mint_quote(&self, quote_id: &str) -> Result<Option<MintQuote>, Self::Err> {
         let read_txn = self.db.begin_read().map_err(Into::<Error>::into)?;
         let table = read_txn
@@ -512,43 +332,6 @@ impl WalletDatabase for WalletRedbDatabase {
     }
 
     #[instrument(skip_all)]
-    async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        {
-            let mut table = write_txn
-                .open_table(MINT_QUOTES_TABLE)
-                .map_err(Error::from)?;
-            table.remove(quote_id).map_err(Error::from)?;
-        }
-
-        write_txn.commit().map_err(Error::from)?;
-
-        Ok(())
-    }
-
-    #[instrument(skip_all)]
-    async fn add_melt_quote(&self, quote: wallet::MeltQuote) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        {
-            let mut table = write_txn
-                .open_table(MELT_QUOTES_TABLE)
-                .map_err(Error::from)?;
-            table
-                .insert(
-                    quote.id.as_str(),
-                    serde_json::to_string(&quote).map_err(Error::from)?.as_str(),
-                )
-                .map_err(Error::from)?;
-        }
-
-        write_txn.commit().map_err(Error::from)?;
-
-        Ok(())
-    }
-
-    #[instrument(skip_all)]
     async fn get_melt_quote(&self, quote_id: &str) -> Result<Option<wallet::MeltQuote>, Self::Err> {
         let read_txn = self.db.begin_read().map_err(Error::from)?;
         let table = read_txn
@@ -577,78 +360,179 @@ impl WalletDatabase for WalletRedbDatabase {
             .collect())
     }
 
-    #[instrument(skip_all)]
-    async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
+    #[instrument(skip(self), fields(keyset_id = %keyset_id))]
+    async fn get_keys(&self, keyset_id: &Id) -> Result<Option<Keys>, Self::Err> {
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
+        let table = read_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
 
+        if let Some(mint_info) = table
+            .get(keyset_id.to_string().as_str())
+            .map_err(Error::from)?
         {
-            let mut table = write_txn
-                .open_table(MELT_QUOTES_TABLE)
-                .map_err(Error::from)?;
-            table.remove(quote_id).map_err(Error::from)?;
+            return Ok(serde_json::from_str(mint_info.value()).map_err(Error::from)?);
         }
 
-        write_txn.commit().map_err(Error::from)?;
-
-        Ok(())
+        Ok(None)
     }
 
     #[instrument(skip_all)]
-    async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        keyset.verify_id()?;
-
-        let existing_keys;
-        let existing_u32;
+    async fn get_proofs(
+        &self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<State>>,
+        spending_conditions: Option<Vec<SpendingConditions>>,
+    ) -> Result<Vec<ProofInfo>, Self::Err> {
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
 
-        {
-            let mut table = write_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
+        let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
 
-            existing_keys = table
-                .insert(
-                    keyset.id.to_string().as_str(),
-                    serde_json::to_string(&keyset.keys)
-                        .map_err(Error::from)?
-                        .as_str(),
-                )
-                .map_err(Error::from)?
-                .is_some();
+        let proofs: Vec<ProofInfo> = table
+            .iter()
+            .map_err(Error::from)?
+            .flatten()
+            .filter_map(|(_k, v)| {
+                let mut proof = None;
 
-            let mut table = write_txn
-                .open_table(KEYSET_U32_MAPPING)
-                .map_err(Error::from)?;
+                if let Ok(proof_info) = serde_json::from_str::<ProofInfo>(v.value()) {
+                    if proof_info.matches_conditions(&mint_url, &unit, &state, &spending_conditions)
+                    {
+                        proof = Some(proof_info)
+                    }
+                }
 
-            let existing = table
-                .insert(u32::from(keyset.id), keyset.id.to_string().as_str())
-                .map_err(Error::from)?;
+                proof
+            })
+            .collect();
 
-            match existing {
-                None => existing_u32 = false,
-                Some(id) => {
-                    let id = Id::from_str(id.value())?;
+        Ok(proofs)
+    }
 
-                    existing_u32 = id != keyset.id;
-                }
-            }
+    #[instrument(skip(self, ys))]
+    async fn get_proofs_by_ys(&self, ys: Vec<PublicKey>) -> Result<Vec<ProofInfo>, Self::Err> {
+        if ys.is_empty() {
+            return Ok(Vec::new());
         }
 
-        if existing_keys || existing_u32 {
-            tracing::warn!("Keys already exist for keyset id");
-            write_txn.abort().map_err(Error::from)?;
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
+        let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
 
-            return Err(database::Error::Duplicate);
+        let mut proofs = Vec::new();
+
+        for y in ys {
+            if let Some(proof) = table.get(y.to_bytes().as_slice()).map_err(Error::from)? {
+                let proof_info =
+                    serde_json::from_str::<ProofInfo>(proof.value()).map_err(Error::from)?;
+                proofs.push(proof_info);
+            }
         }
 
-        write_txn.commit().map_err(Error::from)?;
+        Ok(proofs)
+    }
 
-        Ok(())
+    async fn get_balance(
+        &self,
+        mint_url: Option<MintUrl>,
+        unit: Option<CurrencyUnit>,
+        state: Option<Vec<State>>,
+    ) -> Result<u64, database::Error> {
+        // For redb, we still need to fetch all proofs and sum them
+        // since redb doesn't have SQL aggregation
+        let proofs = self.get_proofs(mint_url, unit, state, None).await?;
+        Ok(proofs.iter().map(|p| u64::from(p.proof.amount)).sum())
     }
 
-    #[instrument(skip(self), fields(keyset_id = %keyset_id))]
-    async fn get_keys(&self, keyset_id: &Id) -> Result<Option<Keys>, Self::Err> {
+    #[instrument(skip(self))]
+    async fn get_transaction(
+        &self,
+        transaction_id: TransactionId,
+    ) -> Result<Option<Transaction>, Self::Err> {
         let read_txn = self.db.begin_read().map_err(Error::from)?;
-        let table = read_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
+        let table = read_txn
+            .open_table(TRANSACTIONS_TABLE)
+            .map_err(Error::from)?;
+
+        if let Some(transaction) = table.get(transaction_id.as_slice()).map_err(Error::from)? {
+            return Ok(serde_json::from_str(transaction.value()).map_err(Error::from)?);
+        }
+
+        Ok(None)
+    }
+
+    #[instrument(skip(self))]
+    async fn list_transactions(
+        &self,
+        mint_url: Option<MintUrl>,
+        direction: Option<TransactionDirection>,
+        unit: Option<CurrencyUnit>,
+    ) -> Result<Vec<Transaction>, Self::Err> {
+        let read_txn = self.db.begin_read().map_err(Error::from)?;
+
+        let table = read_txn
+            .open_table(TRANSACTIONS_TABLE)
+            .map_err(Error::from)?;
+
+        let transactions: Vec<Transaction> = table
+            .iter()
+            .map_err(Error::from)?
+            .flatten()
+            .filter_map(|(_k, v)| {
+                let mut transaction = None;
+
+                if let Ok(tx) = serde_json::from_str::<Transaction>(v.value()) {
+                    if tx.matches_conditions(&mint_url, &direction, &unit) {
+                        transaction = Some(tx)
+                    }
+                }
+
+                transaction
+            })
+            .collect();
+
+        Ok(transactions)
+    }
+
+    async fn begin_db_transaction(
+        &self,
+    ) -> Result<
+        Box<dyn cdk_common::database::WalletDatabaseTransaction<Self::Err> + Send + Sync>,
+        Self::Err,
+    > {
+        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        Ok(Box::new(RedbWalletTransaction::new(write_txn)))
+    }
+}
+
+#[async_trait]
+impl cdk_common::database::WalletDatabaseTransaction<database::Error> for RedbWalletTransaction {
+    #[instrument(skip(self), fields(keyset_id = %keyset_id))]
+    async fn get_keyset_by_id(
+        &mut self,
+        keyset_id: &Id,
+    ) -> Result<Option<KeySetInfo>, database::Error> {
+        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let table = txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
+
+        let result = match table
+            .get(keyset_id.to_bytes().as_slice())
+            .map_err(Error::from)?
+        {
+            Some(keyset) => {
+                let keyset: KeySetInfo =
+                    serde_json::from_str(keyset.value()).map_err(Error::from)?;
+
+                Ok(Some(keyset))
+            }
+            None => Ok(None),
+        };
+
+        result
+    }
+
+    #[instrument(skip(self), fields(keyset_id = %keyset_id))]
+    async fn get_keys(&mut self, keyset_id: &Id) -> Result<Option<Keys>, database::Error> {
+        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let table = txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
 
         if let Some(mint_info) = table
             .get(keyset_id.to_string().as_str())
@@ -660,65 +544,321 @@ impl WalletDatabase for WalletRedbDatabase {
         Ok(None)
     }
 
-    #[instrument(skip(self), fields(keyset_id = %keyset_id))]
-    async fn remove_keys(&self, keyset_id: &Id) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
+    #[instrument(skip(self))]
+    async fn add_mint(
+        &mut self,
+        mint_url: MintUrl,
+        mint_info: Option<MintInfo>,
+    ) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(MINTS_TABLE).map_err(Error::from)?;
+        table
+            .insert(
+                mint_url.to_string().as_str(),
+                serde_json::to_string(&mint_info)
+                    .map_err(Error::from)?
+                    .as_str(),
+            )
+            .map_err(Error::from)?;
+        Ok(())
+    }
 
-        {
-            let mut table = write_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
+    #[instrument(skip(self))]
+    async fn remove_mint(&mut self, mint_url: MintUrl) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(MINTS_TABLE).map_err(Error::from)?;
+        table
+            .remove(mint_url.to_string().as_str())
+            .map_err(Error::from)?;
+        Ok(())
+    }
 
-            table
-                .remove(keyset_id.to_string().as_str())
+    #[instrument(skip(self))]
+    async fn update_mint_url(
+        &mut self,
+        old_mint_url: MintUrl,
+        new_mint_url: MintUrl,
+    ) -> Result<(), database::Error> {
+        // Update proofs table
+        {
+            let proofs = self
+                .get_proofs(Some(old_mint_url.clone()), None, None, None)
+                .await
                 .map_err(Error::from)?;
+
+            // Proofs with new url
+            let updated_proofs: Vec<ProofInfo> = proofs
+                .clone()
+                .into_iter()
+                .map(|mut p| {
+                    p.mint_url = new_mint_url.clone();
+                    p
+                })
+                .collect();
+
+            if !updated_proofs.is_empty() {
+                self.update_proofs(updated_proofs, vec![]).await?;
+            }
         }
 
-        write_txn.commit().map_err(Error::from)?;
+        // Update mint quotes
+        {
+            let read_txn = self.txn()?;
+            let mut table = read_txn
+                .open_table(MINT_QUOTES_TABLE)
+                .map_err(Error::from)?;
+
+            let unix_time = unix_time();
+
+            let quotes = table
+                .iter()
+                .map_err(Error::from)?
+                .flatten()
+                .filter_map(|(_, quote)| {
+                    let mut q: MintQuote = serde_json::from_str(quote.value())
+                        .inspect_err(|err| {
+                            tracing::warn!(
+                                "Failed to deserialize {}  with error {}",
+                                quote.value(),
+                                err
+                            )
+                        })
+                        .ok()?;
+                    if q.expiry < unix_time {
+                        q.mint_url = new_mint_url.clone();
+                        Some(q)
+                    } else {
+                        None
+                    }
+                })
+                .collect::<Vec<_>>();
+
+            for quote in quotes {
+                table
+                    .insert(
+                        quote.id.as_str(),
+                        serde_json::to_string(&quote).map_err(Error::from)?.as_str(),
+                    )
+                    .map_err(Error::from)?;
+            }
+        }
 
         Ok(())
     }
 
-    #[instrument(skip(self, added, deleted_ys))]
-    async fn update_proofs(
-        &self,
-        added: Vec<ProofInfo>,
-        deleted_ys: Vec<PublicKey>,
-    ) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
+    #[instrument(skip(self))]
+    async fn add_mint_keysets(
+        &mut self,
+        mint_url: MintUrl,
+        keysets: Vec<KeySetInfo>,
+    ) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn
+            .open_multimap_table(MINT_KEYSETS_TABLE)
+            .map_err(Error::from)?;
+        let mut keysets_table = txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
+        let mut u32_table = txn.open_table(KEYSET_U32_MAPPING).map_err(Error::from)?;
 
-        {
-            let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
+        let mut existing_u32 = false;
+
+        for keyset in keysets {
+            // Check if keyset already exists
+            let existing_keyset = {
+                let existing_keyset = keysets_table
+                    .get(keyset.id.to_bytes().as_slice())
+                    .map_err(Error::from)?;
+
+                existing_keyset.map(|r| r.value().to_string())
+            };
+
+            let existing = u32_table
+                .insert(u32::from(keyset.id), keyset.id.to_string().as_str())
+                .map_err(Error::from)?;
+
+            match existing {
+                None => existing_u32 = false,
+                Some(id) => {
+                    let id = Id::from_str(id.value())?;
+
+                    if id == keyset.id {
+                        existing_u32 = false;
+                    } else {
+                        existing_u32 = true;
+                        break;
+                    }
+                }
+            }
 
-            for proof_info in added.iter() {
+            let keyset = if let Some(existing_keyset) = existing_keyset {
+                let mut existing_keyset: KeySetInfo = serde_json::from_str(&existing_keyset)?;
+
+                existing_keyset.active = keyset.active;
+                existing_keyset.input_fee_ppk = keyset.input_fee_ppk;
+
+                existing_keyset
+            } else {
                 table
                     .insert(
-                        proof_info.y.to_bytes().as_slice(),
-                        serde_json::to_string(&proof_info)
-                            .map_err(Error::from)?
-                            .as_str(),
+                        mint_url.to_string().as_str(),
+                        keyset.id.to_bytes().as_slice(),
                     )
                     .map_err(Error::from)?;
-            }
 
-            for y in deleted_ys.iter() {
-                table.remove(y.to_bytes().as_slice()).map_err(Error::from)?;
+                keyset
+            };
+
+            keysets_table
+                .insert(
+                    keyset.id.to_bytes().as_slice(),
+                    serde_json::to_string(&keyset)
+                        .map_err(Error::from)?
+                        .as_str(),
+                )
+                .map_err(Error::from)?;
+        }
+
+        if existing_u32 {
+            tracing::warn!("Keyset already exists for keyset id");
+            return Err(database::Error::Duplicate);
+        }
+
+        Ok(())
+    }
+
+    #[instrument(skip_all)]
+    async fn get_mint_quote(
+        &mut self,
+        quote_id: &str,
+    ) -> Result<Option<MintQuote>, database::Error> {
+        let txn = self.txn()?;
+        let table = txn.open_table(MINT_QUOTES_TABLE).map_err(Error::from)?;
+
+        if let Some(mint_info) = table.get(quote_id).map_err(Error::from)? {
+            return Ok(serde_json::from_str(mint_info.value()).map_err(Error::from)?);
+        }
+
+        Ok(None)
+    }
+
+    #[instrument(skip_all)]
+    async fn add_mint_quote(&mut self, quote: MintQuote) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(MINT_QUOTES_TABLE).map_err(Error::from)?;
+        table
+            .insert(
+                quote.id.as_str(),
+                serde_json::to_string(&quote).map_err(Error::from)?.as_str(),
+            )
+            .map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip_all)]
+    async fn remove_mint_quote(&mut self, quote_id: &str) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(MINT_QUOTES_TABLE).map_err(Error::from)?;
+        table.remove(quote_id).map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip_all)]
+    async fn get_melt_quote(
+        &mut self,
+        quote_id: &str,
+    ) -> Result<Option<wallet::MeltQuote>, database::Error> {
+        let txn = self.txn()?;
+        let table = txn.open_table(MELT_QUOTES_TABLE).map_err(Error::from)?;
+
+        if let Some(mint_info) = table.get(quote_id).map_err(Error::from)? {
+            return Ok(serde_json::from_str(mint_info.value()).map_err(Error::from)?);
+        }
+
+        Ok(None)
+    }
+
+    #[instrument(skip_all)]
+    async fn add_melt_quote(&mut self, quote: wallet::MeltQuote) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(MELT_QUOTES_TABLE).map_err(Error::from)?;
+        table
+            .insert(
+                quote.id.as_str(),
+                serde_json::to_string(&quote).map_err(Error::from)?.as_str(),
+            )
+            .map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip_all)]
+    async fn remove_melt_quote(&mut self, quote_id: &str) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(MELT_QUOTES_TABLE).map_err(Error::from)?;
+        table.remove(quote_id).map_err(Error::from)?;
+        Ok(())
+    }
+
+    #[instrument(skip_all)]
+    async fn add_keys(&mut self, keyset: KeySet) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+
+        keyset.verify_id()?;
+
+        let mut table = txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
+
+        let existing_keys = table
+            .insert(
+                keyset.id.to_string().as_str(),
+                serde_json::to_string(&keyset.keys)
+                    .map_err(Error::from)?
+                    .as_str(),
+            )
+            .map_err(Error::from)?
+            .is_some();
+
+        let mut table = txn.open_table(KEYSET_U32_MAPPING).map_err(Error::from)?;
+
+        let existing = table
+            .insert(u32::from(keyset.id), keyset.id.to_string().as_str())
+            .map_err(Error::from)?;
+
+        let existing_u32 = match existing {
+            None => false,
+            Some(id) => {
+                let id = Id::from_str(id.value())?;
+                id != keyset.id
             }
+        };
+
+        if existing_keys || existing_u32 {
+            tracing::warn!("Keys already exist for keyset id");
+            return Err(database::Error::Duplicate);
         }
-        write_txn.commit().map_err(Error::from)?;
+
+        Ok(())
+    }
+
+    #[instrument(skip(self), fields(keyset_id = %keyset_id))]
+    async fn remove_keys(&mut self, keyset_id: &Id) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
+
+        table
+            .remove(keyset_id.to_string().as_str())
+            .map_err(Error::from)?;
 
         Ok(())
     }
 
     #[instrument(skip_all)]
     async fn get_proofs(
-        &self,
+        &mut self,
         mint_url: Option<MintUrl>,
         unit: Option<CurrencyUnit>,
         state: Option<Vec<State>>,
         spending_conditions: Option<Vec<SpendingConditions>>,
-    ) -> Result<Vec<ProofInfo>, Self::Err> {
-        let read_txn = self.db.begin_read().map_err(Error::from)?;
-
-        let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
+    ) -> Result<Vec<ProofInfo>, database::Error> {
+        let txn = self.txn()?;
+        let table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
 
         let proofs: Vec<ProofInfo> = table
             .iter()
@@ -741,27 +881,40 @@ impl WalletDatabase for WalletRedbDatabase {
         Ok(proofs)
     }
 
-    async fn get_balance(
-        &self,
-        mint_url: Option<MintUrl>,
-        unit: Option<CurrencyUnit>,
-        state: Option<Vec<State>>,
-    ) -> Result<u64, database::Error> {
-        // For redb, we still need to fetch all proofs and sum them
-        // since redb doesn't have SQL aggregation
-        let proofs = self.get_proofs(mint_url, unit, state, None).await?;
-        Ok(proofs.iter().map(|p| u64::from(p.proof.amount)).sum())
+    #[instrument(skip(self, added, deleted_ys))]
+    async fn update_proofs(
+        &mut self,
+        added: Vec<ProofInfo>,
+        deleted_ys: Vec<PublicKey>,
+    ) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
+
+        for proof_info in added.iter() {
+            table
+                .insert(
+                    proof_info.y.to_bytes().as_slice(),
+                    serde_json::to_string(&proof_info)
+                        .map_err(Error::from)?
+                        .as_str(),
+                )
+                .map_err(Error::from)?;
+        }
+
+        for y in deleted_ys.iter() {
+            table.remove(y.to_bytes().as_slice()).map_err(Error::from)?;
+        }
+
+        Ok(())
     }
 
     async fn update_proofs_state(
-        &self,
+        &mut self,
         ys: Vec<PublicKey>,
         state: State,
     ) -> Result<(), database::Error> {
-        let read_txn = self.db.begin_read().map_err(Error::from)?;
-        let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
-
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
+        let txn = self.txn()?;
+        let mut table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
 
         for y in ys {
             let y_slice = y.to_bytes();
@@ -772,149 +925,99 @@ impl WalletDatabase for WalletRedbDatabase {
 
             let mut proof_info =
                 serde_json::from_str::<ProofInfo>(proof.value()).map_err(Error::from)?;
+            drop(proof);
 
             proof_info.state = state;
 
-            {
-                let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
-                table
-                    .insert(
-                        y_slice.as_slice(),
-                        serde_json::to_string(&proof_info)
-                            .map_err(Error::from)?
-                            .as_str(),
-                    )
-                    .map_err(Error::from)?;
-            }
+            table
+                .insert(
+                    y_slice.as_slice(),
+                    serde_json::to_string(&proof_info)
+                        .map_err(Error::from)?
+                        .as_str(),
+                )
+                .map_err(Error::from)?;
         }
 
-        write_txn.commit().map_err(Error::from)?;
-
         Ok(())
     }
 
     #[instrument(skip(self), fields(keyset_id = %keyset_id))]
-    async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<u32, Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        let current_counter;
-        let new_counter;
-        {
-            let table = write_txn.open_table(KEYSET_COUNTER).map_err(Error::from)?;
-            let counter = table
-                .get(keyset_id.to_string().as_str())
-                .map_err(Error::from)?;
-
-            current_counter = match counter {
-                Some(c) => c.value(),
-                None => 0,
-            };
-
-            new_counter = current_counter + count;
-        }
+    async fn increment_keyset_counter(
+        &mut self,
+        keyset_id: &Id,
+        count: u32,
+    ) -> Result<u32, database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(KEYSET_COUNTER).map_err(Error::from)?;
+        let current_counter = table
+            .get(keyset_id.to_string().as_str())
+            .map_err(Error::from)?
+            .map(|x| x.value())
+            .unwrap_or_default();
 
-        {
-            let mut table = write_txn.open_table(KEYSET_COUNTER).map_err(Error::from)?;
+        let new_counter = current_counter + count;
 
-            table
-                .insert(keyset_id.to_string().as_str(), new_counter)
-                .map_err(Error::from)?;
-        }
-        write_txn.commit().map_err(Error::from)?;
+        table
+            .insert(keyset_id.to_string().as_str(), new_counter)
+            .map_err(Error::from)?;
 
         Ok(new_counter)
     }
 
     #[instrument(skip(self))]
-    async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> {
+    async fn add_transaction(&mut self, transaction: Transaction) -> Result<(), database::Error> {
         let id = transaction.id();
-
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        {
-            let mut table = write_txn
-                .open_table(TRANSACTIONS_TABLE)
-                .map_err(Error::from)?;
-            table
-                .insert(
-                    id.as_slice(),
-                    serde_json::to_string(&transaction)
-                        .map_err(Error::from)?
-                        .as_str(),
-                )
-                .map_err(Error::from)?;
-        }
-
-        write_txn.commit().map_err(Error::from)?;
-
+        let txn = self.txn()?;
+        let mut table = txn.open_table(TRANSACTIONS_TABLE).map_err(Error::from)?;
+        table
+            .insert(
+                id.as_slice(),
+                serde_json::to_string(&transaction)
+                    .map_err(Error::from)?
+                    .as_str(),
+            )
+            .map_err(Error::from)?;
         Ok(())
     }
 
     #[instrument(skip(self))]
-    async fn get_transaction(
-        &self,
+    async fn remove_transaction(
+        &mut self,
         transaction_id: TransactionId,
-    ) -> Result<Option<Transaction>, Self::Err> {
-        let read_txn = self.db.begin_read().map_err(Error::from)?;
-        let table = read_txn
-            .open_table(TRANSACTIONS_TABLE)
+    ) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+        let mut table = txn.open_table(TRANSACTIONS_TABLE).map_err(Error::from)?;
+        table
+            .remove(transaction_id.as_slice())
             .map_err(Error::from)?;
-
-        if let Some(transaction) = table.get(transaction_id.as_slice()).map_err(Error::from)? {
-            return Ok(serde_json::from_str(transaction.value()).map_err(Error::from)?);
-        }
-
-        Ok(None)
+        Ok(())
     }
+}
 
-    #[instrument(skip(self))]
-    async fn list_transactions(
-        &self,
-        mint_url: Option<MintUrl>,
-        direction: Option<TransactionDirection>,
-        unit: Option<CurrencyUnit>,
-    ) -> Result<Vec<Transaction>, Self::Err> {
-        let read_txn = self.db.begin_read().map_err(Error::from)?;
-
-        let table = read_txn
-            .open_table(TRANSACTIONS_TABLE)
-            .map_err(Error::from)?;
-
-        let transactions: Vec<Transaction> = table
-            .iter()
-            .map_err(Error::from)?
-            .flatten()
-            .filter_map(|(_k, v)| {
-                let mut transaction = None;
-
-                if let Ok(tx) = serde_json::from_str::<Transaction>(v.value()) {
-                    if tx.matches_conditions(&mint_url, &direction, &unit) {
-                        transaction = Some(tx)
-                    }
-                }
-
-                transaction
-            })
-            .collect();
+#[async_trait]
+impl cdk_common::database::DbTransactionFinalizer for RedbWalletTransaction {
+    type Err = database::Error;
 
-        Ok(transactions)
+    async fn commit(mut self: Box<Self>) -> Result<(), database::Error> {
+        if let Some(txn) = self.write_txn.take() {
+            txn.commit().map_err(Error::from)?;
+        }
+        Ok(())
     }
 
-    #[instrument(skip(self))]
-    async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err> {
-        let write_txn = self.db.begin_write().map_err(Error::from)?;
-
-        {
-            let mut table = write_txn
-                .open_table(TRANSACTIONS_TABLE)
-                .map_err(Error::from)?;
-            table
-                .remove(transaction_id.as_slice())
-                .map_err(Error::from)?;
+    async fn rollback(mut self: Box<Self>) -> Result<(), database::Error> {
+        if let Some(txn) = self.write_txn.take() {
+            txn.abort().map_err(Error::from)?;
         }
-
-        write_txn.commit().map_err(Error::from)?;
-
         Ok(())
     }
 }
+
+impl Drop for RedbWalletTransaction {
+    fn drop(&mut self) {
+        if let Some(txn) = self.write_txn.take() {
+            let _ = txn.abort();
+        }
+    }
+}

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

@@ -151,7 +151,6 @@ pub fn create_new_keyset<C: secp256k1::Signing>(
         final_expiry: keyset.final_expiry,
         derivation_path,
         derivation_path_index,
-        max_order: 0,
         amounts: amounts.to_owned(),
         input_fee_ppk,
     };

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

@@ -111,7 +111,6 @@ impl From<SignatoryKeySet> for MintKeySetInfo {
             input_fee_ppk: val.input_fee_ppk,
             derivation_path: Default::default(),
             derivation_path_index: Default::default(),
-            max_order: 0,
             amounts: val.amounts,
             final_expiry: val.final_expiry,
             valid_from: 0,
@@ -148,7 +147,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());

+ 2 - 0
crates/cdk-sql-common/src/mint/auth/migrations/postgres/20251122000000_drop_max_order.sql

@@ -0,0 +1,2 @@
+-- Drop max_order column from keyset table
+ALTER TABLE keyset DROP COLUMN IF EXISTS max_order;

+ 28 - 0
crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20251122000000_drop_max_order.sql

@@ -0,0 +1,28 @@
+-- Drop max_order column from keyset table
+-- SQLite doesn't support DROP COLUMN directly, so we need to recreate the table
+
+-- Create new table without max_order
+CREATE TABLE keyset_new (
+    id TEXT PRIMARY KEY,
+    unit TEXT NOT NULL,
+    active BOOL NOT NULL,
+    valid_from INTEGER NOT NULL,
+    valid_to INTEGER,
+    derivation_path TEXT NOT NULL,
+    derivation_path_index INTEGER NOT NULL
+);
+
+-- Copy data from old table to new table
+INSERT INTO keyset_new (id, unit, active, valid_from, valid_to, derivation_path, derivation_path_index)
+SELECT id, unit, active, valid_from, valid_to, derivation_path, derivation_path_index
+FROM keyset;
+
+-- Drop old table
+DROP TABLE keyset;
+
+-- Rename new table to original name
+ALTER TABLE keyset_new RENAME TO keyset;
+
+-- Recreate indexes
+CREATE INDEX IF NOT EXISTS unit_index ON keyset(unit);
+CREATE INDEX IF NOT EXISTS active_index ON keyset(active);

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

@@ -88,11 +88,11 @@ where
         INSERT INTO
             keyset (
                 id, unit, active, valid_from, valid_to, derivation_path,
-                max_order, derivation_path_index
+                amounts, input_fee_ppk, derivation_path_index
             )
         VALUES (
             :id, :unit, :active, :valid_from, :valid_to, :derivation_path,
-            :max_order, :derivation_path_index
+            :amounts, :input_fee_ppk, :derivation_path_index
         )
         ON CONFLICT(id) DO UPDATE SET
             unit = excluded.unit,
@@ -100,7 +100,8 @@ where
             valid_from = excluded.valid_from,
             valid_to = excluded.valid_to,
             derivation_path = excluded.derivation_path,
-            max_order = excluded.max_order,
+            amounts = excluded.amounts,
+            input_fee_ppk = excluded.input_fee_ppk,
             derivation_path_index = excluded.derivation_path_index
         "#,
         )?
@@ -110,7 +111,8 @@ where
         .bind("valid_from", keyset.valid_from as i64)
         .bind("valid_to", keyset.final_expiry.map(|v| v as i64))
         .bind("derivation_path", keyset.derivation_path.to_string())
-        .bind("max_order", keyset.max_order)
+        .bind("amounts", serde_json::to_string(&keyset.amounts).ok())
+        .bind("input_fee_ppk", keyset.input_fee_ppk as i64)
         .bind("derivation_path_index", keyset.derivation_path_index)
         .execute(&self.inner)
         .await?;
@@ -286,7 +288,6 @@ where
                 valid_to,
                 derivation_path,
                 derivation_path_index,
-                max_order,
                 amounts,
                 input_fee_ppk
             FROM
@@ -311,7 +312,6 @@ where
                 valid_to,
                 derivation_path,
                 derivation_path_index,
-                max_order,
                 amounts,
                 input_fee_ppk
             FROM

+ 2 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql

@@ -17,8 +17,10 @@ CREATE TABLE IF NOT EXISTS saga_state (
     state TEXT NOT NULL,
     blinded_secrets TEXT NOT NULL,
     input_ys TEXT NOT NULL,
+    quote_id TEXT,
     created_at BIGINT NOT NULL,
     updated_at BIGINT NOT NULL
 );
 
 CREATE INDEX IF NOT EXISTS idx_saga_state_operation_kind ON saga_state(operation_kind);
+CREATE INDEX IF NOT EXISTS idx_saga_state_quote_id ON saga_state(quote_id);

+ 25 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20251102000000_create_keyset_amounts.sql

@@ -0,0 +1,25 @@
+-- Create keyset_amounts table with total_issued and total_redeemed columns
+CREATE TABLE IF NOT EXISTS keyset_amounts (
+    keyset_id TEXT PRIMARY KEY NOT NULL,
+    total_issued BIGINT NOT NULL DEFAULT 0,
+    total_redeemed BIGINT NOT NULL DEFAULT 0
+);
+
+-- Prefill with issued and redeemed amounts using FULL OUTER JOIN
+INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+SELECT
+    COALESCE(bs.keyset_id, p.keyset_id) as keyset_id,
+    COALESCE(bs.total_issued, 0) as total_issued,
+    COALESCE(p.total_redeemed, 0) as total_redeemed
+FROM (
+    SELECT keyset_id, SUM(amount) as total_issued
+    FROM blind_signature
+    WHERE c IS NOT NULL
+    GROUP BY keyset_id
+) bs
+FULL OUTER JOIN (
+    SELECT keyset_id, SUM(amount) as total_redeemed
+    FROM proof
+    WHERE state = 'SPENT'
+    GROUP BY keyset_id
+) p ON bs.keyset_id = p.keyset_id;

+ 2 - 0
crates/cdk-sql-common/src/mint/migrations/postgres/20251122000000_drop_max_order.sql

@@ -0,0 +1,2 @@
+-- Drop max_order column from keyset table
+ALTER TABLE keyset DROP COLUMN IF EXISTS max_order;

+ 2 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql

@@ -17,8 +17,10 @@ CREATE TABLE IF NOT EXISTS saga_state (
     state TEXT NOT NULL,
     blinded_secrets TEXT NOT NULL,
     input_ys TEXT NOT NULL,
+    quote_id TEXT,
     created_at INTEGER NOT NULL,
     updated_at INTEGER NOT NULL
 );
 
 CREATE INDEX IF NOT EXISTS idx_saga_state_operation_kind ON saga_state(operation_kind);
+CREATE INDEX IF NOT EXISTS idx_saga_state_quote_id ON saga_state(quote_id);

+ 30 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20251102000000_create_keyset_amounts.sql

@@ -0,0 +1,30 @@
+-- Create keyset_amounts table with total_issued and total_redeemed columns
+CREATE TABLE IF NOT EXISTS keyset_amounts (
+    keyset_id TEXT PRIMARY KEY NOT NULL,
+    total_issued INTEGER NOT NULL DEFAULT 0,
+    total_redeemed INTEGER NOT NULL DEFAULT 0
+);
+
+-- Prefill with issued amounts
+INSERT OR IGNORE INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+SELECT keyset_id, SUM(amount) as total_issued, 0 as total_redeemed
+FROM blind_signature
+WHERE c IS NOT NULL
+GROUP BY keyset_id;
+
+-- Update with redeemed amounts
+UPDATE keyset_amounts
+SET total_redeemed = (
+    SELECT COALESCE(SUM(amount), 0)
+    FROM proof
+    WHERE proof.keyset_id = keyset_amounts.keyset_id
+    AND proof.state = 'SPENT'
+);
+
+-- Insert keysets that only have redeemed amounts (no issued)
+INSERT OR IGNORE INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+SELECT keyset_id, 0 as total_issued, SUM(amount) as total_redeemed
+FROM proof
+WHERE state = 'SPENT'
+AND keyset_id NOT IN (SELECT keyset_id FROM keyset_amounts)
+GROUP BY keyset_id;

+ 30 - 0
crates/cdk-sql-common/src/mint/migrations/sqlite/20251122000000_drop_max_order.sql

@@ -0,0 +1,30 @@
+-- Drop max_order column from keyset table
+-- SQLite doesn't support DROP COLUMN directly, so we need to recreate the table
+
+-- Create new table without max_order
+CREATE TABLE keyset_new (
+    id TEXT PRIMARY KEY,
+    unit TEXT NOT NULL,
+    active BOOL NOT NULL,
+    valid_from INTEGER NOT NULL,
+    valid_to INTEGER,
+    derivation_path TEXT NOT NULL,
+    input_fee_ppk INTEGER,
+    derivation_path_index INTEGER,
+    amounts TEXT
+);
+
+-- Copy data from old table to new table
+INSERT INTO keyset_new (id, unit, active, valid_from, valid_to, derivation_path, input_fee_ppk, derivation_path_index, amounts)
+SELECT id, unit, active, valid_from, valid_to, derivation_path, input_fee_ppk, derivation_path_index, amounts
+FROM keyset;
+
+-- Drop old table
+DROP TABLE keyset;
+
+-- Rename new table to original name
+ALTER TABLE keyset_new RENAME TO keyset;
+
+-- Recreate indexes
+CREATE INDEX IF NOT EXISTS unit_index ON keyset(unit);
+CREATE INDEX IF NOT EXISTS active_index ON keyset(active);

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

@@ -17,7 +17,7 @@ use async_trait::async_trait;
 use bitcoin::bip32::DerivationPath;
 use cdk_common::database::mint::{validate_kvstore_params, SagaDatabase, SagaTransaction};
 use cdk_common::database::{
-    self, ConversionError, Error, MintDatabase, MintDbWriterFinalizer, MintKeyDatabaseTransaction,
+    self, ConversionError, DbTransactionFinalizer, Error, MintDatabase, MintKeyDatabaseTransaction,
     MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, MintQuotesTransaction,
     MintSignatureTransaction, MintSignaturesDatabase,
 };
@@ -219,6 +219,23 @@ where
             .execute(&self.inner)
             .await?;
 
+        if new_state == State::Spent {
+            query(
+                r#"
+                INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+                SELECT keyset_id, 0, COALESCE(SUM(amount), 0)
+                FROM proof
+                WHERE y IN (:ys)
+                GROUP BY keyset_id
+                ON CONFLICT (keyset_id)
+                DO UPDATE SET total_redeemed = keyset_amounts.total_redeemed + EXCLUDED.total_redeemed
+                "#,
+            )?
+            .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect())
+            .execute(&self.inner)
+            .await?;
+        }
+
         Ok(ys.iter().map(|y| current_states.remove(y)).collect())
     }
 
@@ -280,7 +297,7 @@ impl<RM> database::MintTransaction<'_, Error> for SQLTransaction<RM> where RM: D
 {}
 
 #[async_trait]
-impl<RM> MintDbWriterFinalizer for SQLTransaction<RM>
+impl<RM> DbTransactionFinalizer for SQLTransaction<RM>
 where
     RM: DatabasePool + 'static,
 {
@@ -376,6 +393,193 @@ WHERE quote_id=:quote_id
     .collect()
 }
 
+// Inline helper functions that work with both connections and transactions
+#[inline]
+async fn get_mint_quote_inner<T>(
+    executor: &T,
+    quote_id: &QuoteId,
+    for_update: bool,
+) -> Result<Option<MintQuote>, Error>
+where
+    T: DatabaseExecutor,
+{
+    let payments = get_mint_quote_payments(executor, quote_id).await?;
+    let issuance = get_mint_quote_issuance(executor, quote_id).await?;
+
+    let for_update_clause = if for_update { "FOR UPDATE" } else { "" };
+    let query_str = format!(
+        r#"
+        SELECT
+            id,
+            amount,
+            unit,
+            request,
+            expiry,
+            request_lookup_id,
+            pubkey,
+            created_time,
+            amount_paid,
+            amount_issued,
+            payment_method,
+            request_lookup_id_kind
+        FROM
+            mint_quote
+        WHERE id = :id
+        {for_update_clause}
+        "#
+    );
+
+    query(&query_str)?
+        .bind("id", quote_id.to_string())
+        .fetch_one(executor)
+        .await?
+        .map(|row| sql_row_to_mint_quote(row, payments, issuance))
+        .transpose()
+}
+
+#[inline]
+async fn get_mint_quote_by_request_inner<T>(
+    executor: &T,
+    request: &str,
+    for_update: bool,
+) -> Result<Option<MintQuote>, Error>
+where
+    T: DatabaseExecutor,
+{
+    let for_update_clause = if for_update { "FOR UPDATE" } else { "" };
+    let query_str = format!(
+        r#"
+        SELECT
+            id,
+            amount,
+            unit,
+            request,
+            expiry,
+            request_lookup_id,
+            pubkey,
+            created_time,
+            amount_paid,
+            amount_issued,
+            payment_method,
+            request_lookup_id_kind
+        FROM
+            mint_quote
+        WHERE request = :request
+        {for_update_clause}
+        "#
+    );
+
+    let mut mint_quote = query(&query_str)?
+        .bind("request", request.to_string())
+        .fetch_one(executor)
+        .await?
+        .map(|row| sql_row_to_mint_quote(row, vec![], vec![]))
+        .transpose()?;
+
+    if let Some(quote) = mint_quote.as_mut() {
+        let payments = get_mint_quote_payments(executor, &quote.id).await?;
+        let issuance = get_mint_quote_issuance(executor, &quote.id).await?;
+        quote.issuance = issuance;
+        quote.payments = payments;
+    }
+
+    Ok(mint_quote)
+}
+
+#[inline]
+async fn get_mint_quote_by_request_lookup_id_inner<T>(
+    executor: &T,
+    request_lookup_id: &PaymentIdentifier,
+    for_update: bool,
+) -> Result<Option<MintQuote>, Error>
+where
+    T: DatabaseExecutor,
+{
+    let for_update_clause = if for_update { "FOR UPDATE" } else { "" };
+    let query_str = format!(
+        r#"
+        SELECT
+            id,
+            amount,
+            unit,
+            request,
+            expiry,
+            request_lookup_id,
+            pubkey,
+            created_time,
+            amount_paid,
+            amount_issued,
+            payment_method,
+            request_lookup_id_kind
+        FROM
+            mint_quote
+        WHERE request_lookup_id = :request_lookup_id
+        AND request_lookup_id_kind = :request_lookup_id_kind
+        {for_update_clause}
+        "#
+    );
+
+    let mut mint_quote = query(&query_str)?
+        .bind("request_lookup_id", request_lookup_id.to_string())
+        .bind("request_lookup_id_kind", request_lookup_id.kind())
+        .fetch_one(executor)
+        .await?
+        .map(|row| sql_row_to_mint_quote(row, vec![], vec![]))
+        .transpose()?;
+
+    if let Some(quote) = mint_quote.as_mut() {
+        let payments = get_mint_quote_payments(executor, &quote.id).await?;
+        let issuance = get_mint_quote_issuance(executor, &quote.id).await?;
+        quote.issuance = issuance;
+        quote.payments = payments;
+    }
+
+    Ok(mint_quote)
+}
+
+#[inline]
+async fn get_melt_quote_inner<T>(
+    executor: &T,
+    quote_id: &QuoteId,
+    for_update: bool,
+) -> Result<Option<mint::MeltQuote>, Error>
+where
+    T: DatabaseExecutor,
+{
+    let for_update_clause = if for_update { "FOR UPDATE" } else { "" };
+    let query_str = format!(
+        r#"
+        SELECT
+            id,
+            unit,
+            amount,
+            request,
+            fee_reserve,
+            expiry,
+            state,
+            payment_preimage,
+            request_lookup_id,
+            created_time,
+            paid_time,
+            payment_method,
+            options,
+            request_lookup_id_kind
+        FROM
+            melt_quote
+        WHERE
+            id=:id
+        {for_update_clause}
+        "#
+    );
+
+    query(&query_str)?
+        .bind("id", quote_id.to_string())
+        .fetch_one(executor)
+        .await?
+        .map(sql_row_to_melt_quote)
+        .transpose()
+}
+
 #[async_trait]
 impl<RM> MintKeyDatabaseTransaction<'_, Error> for SQLTransaction<RM>
 where
@@ -387,11 +591,11 @@ where
         INSERT INTO
             keyset (
                 id, unit, active, valid_from, valid_to, derivation_path,
-                max_order, amounts, input_fee_ppk, derivation_path_index
+                amounts, input_fee_ppk, derivation_path_index
             )
         VALUES (
             :id, :unit, :active, :valid_from, :valid_to, :derivation_path,
-            :max_order, :amounts, :input_fee_ppk, :derivation_path_index
+            :amounts, :input_fee_ppk, :derivation_path_index
         )
         ON CONFLICT(id) DO UPDATE SET
             unit = excluded.unit,
@@ -399,7 +603,6 @@ where
             valid_from = excluded.valid_from,
             valid_to = excluded.valid_to,
             derivation_path = excluded.derivation_path,
-            max_order = excluded.max_order,
             amounts = excluded.amounts,
             input_fee_ppk = excluded.input_fee_ppk,
             derivation_path_index = excluded.derivation_path_index
@@ -411,7 +614,6 @@ where
         .bind("valid_from", keyset.valid_from as i64)
         .bind("valid_to", keyset.final_expiry.map(|v| v as i64))
         .bind("derivation_path", keyset.derivation_path.to_string())
-        .bind("max_order", keyset.max_order)
         .bind("amounts", serde_json::to_string(&keyset.amounts).ok())
         .bind("input_fee_ppk", keyset.input_fee_ppk as i64)
         .bind("derivation_path_index", keyset.derivation_path_index)
@@ -503,7 +705,6 @@ where
                 valid_to,
                 derivation_path,
                 derivation_path_index,
-                max_order,
                 amounts,
                 input_fee_ppk
             FROM
@@ -528,7 +729,6 @@ where
                 valid_to,
                 derivation_path,
                 derivation_path_index,
-                max_order,
                 amounts,
                 input_fee_ppk
             FROM
@@ -1088,153 +1288,28 @@ VALUES (:quote_id, :amount, :timestamp);
     }
 
     async fn get_mint_quote(&mut self, quote_id: &QuoteId) -> Result<Option<MintQuote>, Self::Err> {
-        let payments = get_mint_quote_payments(&self.inner, quote_id).await?;
-        let issuance = get_mint_quote_issuance(&self.inner, quote_id).await?;
-
-        Ok(query(
-            r#"
-            SELECT
-                id,
-                amount,
-                unit,
-                request,
-                expiry,
-                request_lookup_id,
-                pubkey,
-                created_time,
-                amount_paid,
-                amount_issued,
-                payment_method,
-                request_lookup_id_kind
-            FROM
-                mint_quote
-            WHERE id = :id
-            FOR UPDATE
-            "#,
-        )?
-        .bind("id", quote_id.to_string())
-        .fetch_one(&self.inner)
-        .await?
-        .map(|row| sql_row_to_mint_quote(row, payments, issuance))
-        .transpose()?)
+        get_mint_quote_inner(&self.inner, quote_id, true).await
     }
 
     async fn get_melt_quote(
         &mut self,
         quote_id: &QuoteId,
     ) -> Result<Option<mint::MeltQuote>, Self::Err> {
-        Ok(query(
-            r#"
-            SELECT
-                id,
-                unit,
-                amount,
-                request,
-                fee_reserve,
-                expiry,
-                state,
-                payment_preimage,
-                request_lookup_id,
-                created_time,
-                paid_time,
-                payment_method,
-                options,
-                request_lookup_id
-            FROM
-                melt_quote
-            WHERE
-                id=:id
-            "#,
-        )?
-        .bind("id", quote_id.to_string())
-        .fetch_one(&self.inner)
-        .await?
-        .map(sql_row_to_melt_quote)
-        .transpose()?)
+        get_melt_quote_inner(&self.inner, quote_id, true).await
     }
 
     async fn get_mint_quote_by_request(
         &mut self,
         request: &str,
     ) -> Result<Option<MintQuote>, Self::Err> {
-        let mut mint_quote = query(
-            r#"
-            SELECT
-                id,
-                amount,
-                unit,
-                request,
-                expiry,
-                request_lookup_id,
-                pubkey,
-                created_time,
-                amount_paid,
-                amount_issued,
-                payment_method,
-                request_lookup_id_kind
-            FROM
-                mint_quote
-            WHERE request = :request
-            FOR UPDATE
-            "#,
-        )?
-        .bind("request", request.to_string())
-        .fetch_one(&self.inner)
-        .await?
-        .map(|row| sql_row_to_mint_quote(row, vec![], vec![]))
-        .transpose()?;
-
-        if let Some(quote) = mint_quote.as_mut() {
-            let payments = get_mint_quote_payments(&self.inner, &quote.id).await?;
-            let issuance = get_mint_quote_issuance(&self.inner, &quote.id).await?;
-            quote.issuance = issuance;
-            quote.payments = payments;
-        }
-
-        Ok(mint_quote)
+        get_mint_quote_by_request_inner(&self.inner, request, true).await
     }
 
     async fn get_mint_quote_by_request_lookup_id(
         &mut self,
         request_lookup_id: &PaymentIdentifier,
     ) -> Result<Option<MintQuote>, Self::Err> {
-        let mut mint_quote = query(
-            r#"
-            SELECT
-                id,
-                amount,
-                unit,
-                request,
-                expiry,
-                request_lookup_id,
-                pubkey,
-                created_time,
-                amount_paid,
-                amount_issued,
-                payment_method,
-                request_lookup_id_kind
-            FROM
-                mint_quote
-            WHERE request_lookup_id = :request_lookup_id
-            AND request_lookup_id_kind = :request_lookup_id_kind
-            FOR UPDATE
-            "#,
-        )?
-        .bind("request_lookup_id", request_lookup_id.to_string())
-        .bind("request_lookup_id_kind", request_lookup_id.kind())
-        .fetch_one(&self.inner)
-        .await?
-        .map(|row| sql_row_to_mint_quote(row, vec![], vec![]))
-        .transpose()?;
-
-        if let Some(quote) = mint_quote.as_mut() {
-            let payments = get_mint_quote_payments(&self.inner, &quote.id).await?;
-            let issuance = get_mint_quote_issuance(&self.inner, &quote.id).await?;
-            quote.issuance = issuance;
-            quote.payments = payments;
-        }
-
-        Ok(mint_quote)
+        get_mint_quote_by_request_lookup_id_inner(&self.inner, request_lookup_id, true).await
     }
 }
 
@@ -1253,36 +1328,7 @@ where
         let start_time = std::time::Instant::now();
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
 
-        let result = async {
-            let payments = get_mint_quote_payments(&*conn, quote_id).await?;
-            let issuance = get_mint_quote_issuance(&*conn, quote_id).await?;
-
-            query(
-                r#"
-                SELECT
-                    id,
-                    amount,
-                    unit,
-                    request,
-                    expiry,
-                    request_lookup_id,
-                    pubkey,
-                    created_time,
-                    amount_paid,
-                    amount_issued,
-                    payment_method,
-                    request_lookup_id_kind
-                FROM
-                    mint_quote
-                WHERE id = :id"#,
-            )?
-            .bind("id", quote_id.to_string())
-            .fetch_one(&*conn)
-            .await?
-            .map(|row| sql_row_to_mint_quote(row, payments, issuance))
-            .transpose()
-        }
-        .await;
+        let result = get_mint_quote_inner(&*conn, quote_id, false).await;
 
         #[cfg(feature = "prometheus")]
         {
@@ -1305,39 +1351,7 @@ where
         request: &str,
     ) -> Result<Option<MintQuote>, Self::Err> {
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
-        let mut mint_quote = query(
-            r#"
-            SELECT
-                id,
-                amount,
-                unit,
-                request,
-                expiry,
-                request_lookup_id,
-                pubkey,
-                created_time,
-                amount_paid,
-                amount_issued,
-                payment_method,
-                request_lookup_id_kind
-            FROM
-                mint_quote
-            WHERE request = :request"#,
-        )?
-        .bind("request", request.to_owned())
-        .fetch_one(&*conn)
-        .await?
-        .map(|row| sql_row_to_mint_quote(row, vec![], vec![]))
-        .transpose()?;
-
-        if let Some(quote) = mint_quote.as_mut() {
-            let payments = get_mint_quote_payments(&*conn, &quote.id).await?;
-            let issuance = get_mint_quote_issuance(&*conn, &quote.id).await?;
-            quote.issuance = issuance;
-            quote.payments = payments;
-        }
-
-        Ok(mint_quote)
+        get_mint_quote_by_request_inner(&*conn, request, false).await
     }
 
     async fn get_mint_quote_by_request_lookup_id(
@@ -1345,43 +1359,7 @@ where
         request_lookup_id: &PaymentIdentifier,
     ) -> Result<Option<MintQuote>, Self::Err> {
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
-        let mut mint_quote = query(
-            r#"
-            SELECT
-                id,
-                amount,
-                unit,
-                request,
-                expiry,
-                request_lookup_id,
-                pubkey,
-                created_time,
-                amount_paid,
-                amount_issued,
-                payment_method,
-                request_lookup_id_kind
-            FROM
-                mint_quote
-            WHERE request_lookup_id = :request_lookup_id
-            AND request_lookup_id_kind = :request_lookup_id_kind
-            "#,
-        )?
-        .bind("request_lookup_id", request_lookup_id.to_string())
-        .bind("request_lookup_id_kind", request_lookup_id.kind())
-        .fetch_one(&*conn)
-        .await?
-        .map(|row| sql_row_to_mint_quote(row, vec![], vec![]))
-        .transpose()?;
-
-        // TODO: these should use an sql join so they can be done in one query
-        if let Some(quote) = mint_quote.as_mut() {
-            let payments = get_mint_quote_payments(&*conn, &quote.id).await?;
-            let issuance = get_mint_quote_issuance(&*conn, &quote.id).await?;
-            quote.issuance = issuance;
-            quote.payments = payments;
-        }
-
-        Ok(mint_quote)
+        get_mint_quote_by_request_lookup_id_inner(&*conn, request_lookup_id, false).await
     }
 
     async fn get_mint_quotes(&self) -> Result<Vec<MintQuote>, Self::Err> {
@@ -1432,37 +1410,7 @@ where
         let start_time = std::time::Instant::now();
         let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
 
-        let result = async {
-            query(
-                r#"
-                SELECT
-                    id,
-                    unit,
-                    amount,
-                    request,
-                    fee_reserve,
-                    expiry,
-                    state,
-                    payment_preimage,
-                    request_lookup_id,
-                    created_time,
-                    paid_time,
-                    payment_method,
-                    options,
-                    request_lookup_id_kind
-                FROM
-                    melt_quote
-                WHERE
-                    id=:id
-                "#,
-            )?
-            .bind("id", quote_id.to_string())
-            .fetch_one(&*conn)
-            .await?
-            .map(sql_row_to_melt_quote)
-            .transpose()
-        }
-        .await;
+        let result = get_melt_quote_inner(&*conn, quote_id, false).await;
 
         #[cfg(feature = "prometheus")]
         {
@@ -1618,6 +1566,25 @@ where
         .into_iter()
         .unzip())
     }
+
+    /// Get total proofs redeemed by keyset id
+    async fn get_total_redeemed(&self) -> Result<HashMap<Id, Amount>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        query(
+            r#"
+            SELECT
+                keyset_id,
+                total_redeemed as amount
+            FROM
+                keyset_amounts
+        "#,
+        )?
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_hashmap_amount)
+        .collect()
+    }
 }
 
 #[async_trait]
@@ -1698,6 +1665,19 @@ where
                     .bind("signed_time", current_time as i64)
                     .execute(&self.inner)
                     .await?;
+
+                    query(
+                        r#"
+                        INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+                        VALUES (:keyset_id, :amount, 0)
+                        ON CONFLICT (keyset_id)
+                        DO UPDATE SET total_issued = keyset_amounts.total_issued + EXCLUDED.total_issued
+                        "#,
+                    )?
+                    .bind("amount", u64::from(signature.amount) as i64)
+                    .bind("keyset_id", signature.keyset_id.to_string())
+                    .execute(&self.inner)
+                    .await?;
                 }
                 Some((c, _dleq_e, _dleq_s)) => {
                     // Blind message exists: check if c is NULL
@@ -1725,6 +1705,19 @@ where
                             .bind("amount", u64::from(signature.amount) as i64)
                             .execute(&self.inner)
                             .await?;
+
+                            query(
+                                r#"
+                                INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed)
+                                VALUES (:keyset_id, :amount, 0)
+                                ON CONFLICT (keyset_id)
+                                DO UPDATE SET total_issued = keyset_amounts.total_issued + EXCLUDED.total_issued
+                                "#,
+                            )?
+                            .bind("amount", u64::from(signature.amount) as i64)
+                            .bind("keyset_id", signature.keyset_id.to_string())
+                            .execute(&self.inner)
+                            .await?;
                         }
                         _ => {
                             // Blind message already has c: Error
@@ -1907,6 +1900,25 @@ where
         .map(sql_row_to_blind_signature)
         .collect::<Result<Vec<BlindSignature>, _>>()?)
     }
+
+    /// Get total proofs redeemed by keyset id
+    async fn get_total_issued(&self) -> Result<HashMap<Id, Amount>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        query(
+            r#"
+            SELECT
+                keyset_id,
+                total_issued as amount
+            FROM
+                keyset_amounts
+        "#,
+        )?
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_hashmap_amount)
+        .collect()
+    }
 }
 
 #[async_trait]
@@ -2146,6 +2158,7 @@ where
                 state,
                 blinded_secrets,
                 input_ys,
+                quote_id,
                 created_at,
                 updated_at
             FROM
@@ -2166,17 +2179,17 @@ 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#"
             INSERT INTO saga_state
-            (operation_id, operation_kind, state, blinded_secrets, input_ys, created_at, updated_at)
+            (operation_id, operation_kind, state, blinded_secrets, input_ys, quote_id, created_at, updated_at)
             VALUES
-            (:operation_id, :operation_kind, :state, :blinded_secrets, :input_ys, :created_at, :updated_at)
+            (:operation_id, :operation_kind, :state, :blinded_secrets, :input_ys, :quote_id, :created_at, :updated_at)
             "#,
         )?
         .bind("operation_id", saga.operation_id.to_string())
@@ -2184,6 +2197,7 @@ where
         .bind("state", saga.state.state())
         .bind("blinded_secrets", blinded_secrets_json)
         .bind("input_ys", input_ys_json)
+        .bind("quote_id", saga.quote_id.as_deref())
         .bind("created_at", saga.created_at as i64)
         .bind("updated_at", current_time as i64)
         .execute(&self.inner)
@@ -2250,6 +2264,7 @@ where
                 state,
                 blinded_secrets,
                 input_ys,
+                quote_id,
                 created_at,
                 updated_at
             FROM
@@ -2297,16 +2312,14 @@ fn sql_row_to_keyset_info(row: Vec<Column>) -> Result<MintKeySetInfo, Error> {
             valid_to,
             derivation_path,
             derivation_path_index,
-            max_order,
             amounts,
             row_keyset_ppk
         ) = row
     );
 
-    let max_order: u8 = column_as_number!(max_order);
     let amounts = column_as_nullable_string!(amounts)
         .and_then(|str| serde_json::from_str(&str).ok())
-        .unwrap_or_else(|| (0..max_order).map(|m| 2u64.pow(m.into())).collect());
+        .ok_or_else(|| Error::Database("amounts field is required".to_string().into()))?;
 
     Ok(MintKeySetInfo {
         id: column_as_string!(id, Id::from_str, Id::from_bytes),
@@ -2315,7 +2328,6 @@ fn sql_row_to_keyset_info(row: Vec<Column>) -> Result<MintKeySetInfo, Error> {
         valid_from: column_as_number!(valid_from),
         derivation_path: column_as_string!(derivation_path, DerivationPath::from_str),
         derivation_path_index: column_as_nullable_number!(derivation_path_index),
-        max_order,
         amounts,
         input_fee_ppk: column_as_number!(row_keyset_ppk),
         final_expiry: column_as_nullable_number!(valid_to),
@@ -2479,6 +2491,20 @@ fn sql_row_to_proof(row: Vec<Column>) -> Result<Proof, Error> {
     })
 }
 
+fn sql_row_to_hashmap_amount(row: Vec<Column>) -> Result<(Id, Amount), Error> {
+    unpack_into!(
+        let (
+            keyset_id, amount
+        ) = row
+    );
+
+    let amount: u64 = column_as_number!(amount);
+    Ok((
+        column_as_string!(keyset_id, Id::from_str, Id::from_bytes),
+        Amount::from(amount),
+    ))
+}
+
 fn sql_row_to_proof_with_state(row: Vec<Column>) -> Result<(Proof, Option<State>), Error> {
     unpack_into!(
         let (
@@ -2539,6 +2565,7 @@ fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
             state,
             blinded_secrets,
             input_ys,
+            quote_id,
             created_at,
             updated_at
         ) = row
@@ -2546,23 +2573,35 @@ 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) => {
+            if s.is_empty() {
+                None
+            } else {
+                Some(s.clone())
+            }
+        }
+        Column::Null => None,
+        _ => None,
+    };
 
     let created_at: u64 = column_as_number!(created_at);
     let updated_at: u64 = column_as_number!(updated_at);
@@ -2573,6 +2612,7 @@ fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
         state,
         blinded_secrets,
         input_ys,
+        quote_id,
         created_at,
         updated_at,
     })
@@ -2582,77 +2622,13 @@ fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
 mod test {
     use super::*;
 
-    mod max_order_to_amounts_migrations {
+    mod keyset_amounts_tests {
         use super::*;
 
         #[test]
-        fn legacy_payload() {
-            let result = sql_row_to_keyset_info(vec![
-                Column::Text("0083a60439303340".to_owned()),
-                Column::Text("sat".to_owned()),
-                Column::Integer(1),
-                Column::Integer(1749844864),
-                Column::Null,
-                Column::Text("0'/0'/0'".to_owned()),
-                Column::Integer(0),
-                Column::Integer(32),
-                Column::Null,
-                Column::Integer(0),
-            ]);
-            assert!(result.is_ok());
-        }
-
-        #[test]
-        fn migrated_payload() {
-            let legacy = sql_row_to_keyset_info(vec![
-                Column::Text("0083a60439303340".to_owned()),
-                Column::Text("sat".to_owned()),
-                Column::Integer(1),
-                Column::Integer(1749844864),
-                Column::Null,
-                Column::Text("0'/0'/0'".to_owned()),
-                Column::Integer(0),
-                Column::Integer(32),
-                Column::Null,
-                Column::Integer(0),
-            ]);
-            assert!(legacy.is_ok());
-
+        fn keyset_with_amounts() {
             let amounts = (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>();
-            let migrated = sql_row_to_keyset_info(vec![
-                Column::Text("0083a60439303340".to_owned()),
-                Column::Text("sat".to_owned()),
-                Column::Integer(1),
-                Column::Integer(1749844864),
-                Column::Null,
-                Column::Text("0'/0'/0'".to_owned()),
-                Column::Integer(0),
-                Column::Integer(32),
-                Column::Text(serde_json::to_string(&amounts).expect("valid json")),
-                Column::Integer(0),
-            ]);
-            assert!(migrated.is_ok());
-            assert_eq!(legacy.unwrap(), migrated.unwrap());
-        }
-
-        #[test]
-        fn amounts_over_max_order() {
-            let legacy = sql_row_to_keyset_info(vec![
-                Column::Text("0083a60439303340".to_owned()),
-                Column::Text("sat".to_owned()),
-                Column::Integer(1),
-                Column::Integer(1749844864),
-                Column::Null,
-                Column::Text("0'/0'/0'".to_owned()),
-                Column::Integer(0),
-                Column::Integer(32),
-                Column::Null,
-                Column::Integer(0),
-            ]);
-            assert!(legacy.is_ok());
-
-            let amounts = (0..16).map(|x| 2u64.pow(x)).collect::<Vec<_>>();
-            let migrated = sql_row_to_keyset_info(vec![
+            let result = sql_row_to_keyset_info(vec![
                 Column::Text("0083a60439303340".to_owned()),
                 Column::Text("sat".to_owned()),
                 Column::Integer(1),
@@ -2660,14 +2636,12 @@ mod test {
                 Column::Null,
                 Column::Text("0'/0'/0'".to_owned()),
                 Column::Integer(0),
-                Column::Integer(32),
                 Column::Text(serde_json::to_string(&amounts).expect("valid json")),
                 Column::Integer(0),
             ]);
-            assert!(migrated.is_ok());
-            let migrated = migrated.unwrap();
-            assert_ne!(legacy.unwrap(), migrated);
-            assert_eq!(migrated.amounts.len(), 16);
+            assert!(result.is_ok());
+            let keyset = result.unwrap();
+            assert_eq!(keyset.amounts.len(), 32);
         }
     }
 }

+ 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;

文件差異過大導致無法顯示
+ 826 - 541
crates/cdk-sql-common/src/wallet/mod.rs


+ 2 - 0
crates/cdk-sqlite/src/lib.rs

@@ -6,6 +6,8 @@
 mod async_sqlite;
 mod common;
 
+pub use common::SqliteConnectionManager;
+
 #[cfg(feature = "mint")]
 pub mod mint;
 #[cfg(feature = "wallet")]

+ 104 - 4
crates/cdk-sqlite/src/wallet/mod.rs

@@ -36,10 +36,14 @@ mod tests {
         let mint_info = MintInfo::new().description("test");
         let mint_url = MintUrl::from_str("https://mint.xyz").unwrap();
 
-        db.add_mint(mint_url.clone(), Some(mint_info.clone()))
+        let mut tx = db.begin_db_transaction().await.expect("tx");
+
+        tx.add_mint(mint_url.clone(), Some(mint_info.clone()))
             .await
             .unwrap();
 
+        tx.commit().await.expect("commit");
+
         let res = db.get_mint(mint_url).await.unwrap();
         assert_eq!(mint_info, res.clone().unwrap());
         assert_eq!("test", &res.unwrap().description.unwrap());
@@ -94,11 +98,15 @@ mod tests {
         let proof_info =
             ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap();
 
+        let mut tx = db.begin_db_transaction().await.expect("tx");
+
         // Store the proof in the database
-        db.update_proofs(vec![proof_info.clone()], vec![])
+        tx.update_proofs(vec![proof_info.clone()], vec![])
             .await
             .unwrap();
 
+        tx.commit().await.expect("commit");
+
         // Retrieve the proof from the database
         let retrieved_proofs = db
             .get_proofs(
@@ -154,6 +162,8 @@ mod tests {
             PaymentMethod::Custom("custom".to_string()),
         ];
 
+        let mut tx = db.begin_db_transaction().await.expect("begin");
+
         for (i, payment_method) in payment_methods.iter().enumerate() {
             let quote = MintQuote {
                 id: format!("test_quote_{}", i),
@@ -170,13 +180,103 @@ mod tests {
             };
 
             // Store the quote
-            db.add_mint_quote(quote.clone()).await.unwrap();
+            tx.add_mint_quote(quote.clone()).await.unwrap();
 
             // Retrieve and verify
-            let retrieved = db.get_mint_quote(&quote.id).await.unwrap().unwrap();
+            let retrieved = tx.get_mint_quote(&quote.id).await.unwrap().unwrap();
             assert_eq!(retrieved.payment_method, *payment_method);
             assert_eq!(retrieved.amount_issued, Amount::from(0));
             assert_eq!(retrieved.amount_paid, Amount::from(0));
         }
+        tx.commit().await.expect("commit");
+    }
+
+    #[tokio::test]
+    async fn test_get_proofs_by_ys() {
+        use cdk_common::common::ProofInfo;
+        use cdk_common::mint_url::MintUrl;
+        use cdk_common::nuts::{CurrencyUnit, Id, Proof, SecretKey};
+        use cdk_common::Amount;
+
+        // Create a temporary database
+        let path = std::env::temp_dir().to_path_buf().join(format!(
+            "cdk-test-proofs-by-ys-{}.sqlite",
+            uuid::Uuid::new_v4()
+        ));
+
+        #[cfg(feature = "sqlcipher")]
+        let db = WalletSqliteDatabase::new((path, "password".to_string()))
+            .await
+            .unwrap();
+
+        #[cfg(not(feature = "sqlcipher"))]
+        let db = WalletSqliteDatabase::new(path).await.unwrap();
+
+        // Create multiple proofs
+        let keyset_id = Id::from_str("00deadbeef123456").unwrap();
+        let mint_url = MintUrl::from_str("https://example.com").unwrap();
+
+        let mut proof_infos = vec![];
+        let mut expected_ys = vec![];
+
+        // Generate valid public keys using SecretKey
+        for _i in 0..5 {
+            let secret = Secret::generate();
+
+            // Generate a valid public key from a secret key
+            let secret_key = SecretKey::generate();
+            let c = secret_key.public_key();
+
+            let proof = Proof::new(Amount::from(64), keyset_id, secret, c);
+
+            let proof_info =
+                ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap();
+
+            expected_ys.push(proof_info.y);
+            proof_infos.push(proof_info);
+        }
+
+        // Store all proofs in the database
+        db.update_proofs(proof_infos.clone(), vec![]).await.unwrap();
+
+        // Test 1: Retrieve all proofs by their Y values
+        let retrieved_proofs = db.get_proofs_by_ys(expected_ys.clone()).await.unwrap();
+
+        assert_eq!(retrieved_proofs.len(), 5);
+        for retrieved_proof in &retrieved_proofs {
+            assert!(expected_ys.contains(&retrieved_proof.y));
+        }
+
+        // Test 2: Retrieve subset of proofs (first 3)
+        let subset_ys = expected_ys[0..3].to_vec();
+        let subset_proofs = db.get_proofs_by_ys(subset_ys.clone()).await.unwrap();
+
+        assert_eq!(subset_proofs.len(), 3);
+        for retrieved_proof in &subset_proofs {
+            assert!(subset_ys.contains(&retrieved_proof.y));
+        }
+
+        // Test 3: Retrieve with non-existent Y values
+        let non_existent_secret_key = SecretKey::generate();
+        let non_existent_y = non_existent_secret_key.public_key();
+        let mixed_ys = vec![expected_ys[0], non_existent_y, expected_ys[1]];
+        let mixed_proofs = db.get_proofs_by_ys(mixed_ys).await.unwrap();
+
+        // Should only return the 2 that exist
+        assert_eq!(mixed_proofs.len(), 2);
+
+        // Test 4: Empty input returns empty result
+        let empty_result = db.get_proofs_by_ys(vec![]).await.unwrap();
+        assert_eq!(empty_result.len(), 0);
+
+        // Test 5: Verify retrieved proof data matches original
+        let single_y = vec![expected_ys[2]];
+        let single_proof = db.get_proofs_by_ys(single_y).await.unwrap();
+
+        assert_eq!(single_proof.len(), 1);
+        assert_eq!(single_proof[0].y, proof_infos[2].y);
+        assert_eq!(single_proof[0].proof.amount, proof_infos[2].proof.amount);
+        assert_eq!(single_proof[0].mint_url, proof_infos[2].mint_url);
+        assert_eq!(single_proof[0].state, proof_infos[2].state);
     }
 }

+ 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

+ 6 - 1
crates/cdk/examples/auth_wallet.rs

@@ -105,11 +105,16 @@ async fn get_access_token(mint_info: &MintInfo) -> String {
         .await
         .expect("Failed to get OIDC config")
         .token_endpoint;
+    let client_id = oidc_client
+        .get_oidc_config()
+        .await
+        .expect("Failed to get OIDC config")
+        .token_endpoint;
 
     // Create the request parameters
     let params = [
         ("grant_type", "password"),
-        ("client_id", "cashu-client"),
+        ("client_id", &client_id),
         ("username", TEST_USERNAME),
         ("password", TEST_PASSWORD),
     ];

+ 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(())
+}

+ 10 - 2
crates/cdk/examples/p2pk.rs

@@ -60,10 +60,12 @@ async fn main() -> Result<(), Error> {
     let bal = wallet.total_balance().await?;
     println!("Total balance: {}", bal);
 
+    let token_amount_to_send = Amount::from(10);
+
     // Send a token with the specified amount and spending conditions
     let prepared_send = wallet
         .prepare_send(
-            10.into(),
+            token_amount_to_send,
             SendOptions {
                 conditions: Some(spending_conditions),
                 include_fee: true,
@@ -71,7 +73,11 @@ async fn main() -> Result<(), Error> {
             },
         )
         .await?;
-    println!("Fee: {}", prepared_send.fee());
+
+    let swap_fee = prepared_send.swap_fee();
+
+    println!("Fee: {}", swap_fee);
+
     let token = prepared_send.confirm(None).await?;
 
     println!("Created token locked to pubkey: {}", secret.public_key());
@@ -88,6 +94,8 @@ async fn main() -> Result<(), Error> {
         )
         .await?;
 
+    assert!(amount == token_amount_to_send);
+
     println!("Redeemed locked token worth: {}", u64::from(amount));
 
     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)

+ 132 - 0
crates/cdk/src/fees.rs

@@ -82,4 +82,136 @@ mod tests {
         let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
         assert_eq!(sum_fee, 8.into());
     }
+
+    #[test]
+    fn test_fee_calculation_with_ppk_200() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+
+        let fee_ppk = 200;
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, fee_ppk);
+
+        let mut proofs_count = HashMap::new();
+
+        proofs_count.insert(keyset_id, 1);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "1 proof: ceil(200/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 3);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "3 proofs: ceil(600/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 5);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "5 proofs: ceil(1000/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 6);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 2.into(), "6 proofs: ceil(1200/1000) = 2 sats");
+    }
+
+    #[test]
+    fn test_fee_calculation_with_ppk_1000() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+
+        let fee_ppk = 1000;
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, fee_ppk);
+
+        let mut proofs_count = HashMap::new();
+
+        proofs_count.insert(keyset_id, 1);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "1 proof at 1000 ppk = 1 sat");
+
+        proofs_count.insert(keyset_id, 2);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 2.into(), "2 proofs at 1000 ppk = 2 sats");
+
+        proofs_count.insert(keyset_id, 10);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 10.into(), "10 proofs at 1000 ppk = 10 sats");
+    }
+
+    #[test]
+    fn test_fee_calculation_zero_fee() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+
+        let fee_ppk = 0;
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, fee_ppk);
+
+        let mut proofs_count = HashMap::new();
+
+        proofs_count.insert(keyset_id, 100);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 0.into(), "0 ppk means no fee: ceil(0/1000) = 0");
+    }
+
+    #[test]
+    fn test_fee_calculation_with_ppk_100() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+
+        let fee_ppk = 100;
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, fee_ppk);
+
+        let mut proofs_count = HashMap::new();
+
+        proofs_count.insert(keyset_id, 1);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "1 proof: ceil(100/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 10);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 1.into(), "10 proofs: ceil(1000/1000) = 1 sat");
+
+        proofs_count.insert(keyset_id, 11);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 2.into(), "11 proofs: ceil(1100/1000) = 2 sats");
+
+        proofs_count.insert(keyset_id, 91);
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(sum_fee, 10.into(), "91 proofs: ceil(9100/1000) = 10 sats");
+    }
+
+    #[test]
+    fn test_fee_calculation_unknown_keyset() {
+        let keyset_id = Id::from_str("001711afb1de20cb").unwrap();
+        let unknown_keyset_id = Id::from_str("001711afb1de20cc").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id, 100);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(unknown_keyset_id, 1);
+
+        let result = calculate_fee(&proofs_count, &keyset_fees);
+        assert!(result.is_err(), "Unknown keyset should return error");
+    }
+
+    #[test]
+    fn test_fee_calculation_multiple_keysets() {
+        let keyset_id_1 = Id::from_str("001711afb1de20cb").unwrap();
+        let keyset_id_2 = Id::from_str("001711afb1de20cc").unwrap();
+
+        let mut keyset_fees = HashMap::new();
+        keyset_fees.insert(keyset_id_1, 200);
+        keyset_fees.insert(keyset_id_2, 500);
+
+        let mut proofs_count = HashMap::new();
+        proofs_count.insert(keyset_id_1, 3);
+        proofs_count.insert(keyset_id_2, 2);
+
+        let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap();
+        assert_eq!(
+            sum_fee,
+            2.into(),
+            "3*200 + 2*500 = 1600, ceil(1600/1000) = 2"
+        );
+    }
 }

+ 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;

部分文件因文件數量過多而無法顯示