1
0

80 Komitmen a35ec4b09c ... 0a829f38e7

Pembuat SHA1 Pesan Tanggal
  Cesar Rodas 0a829f38e7 feat(database): add get_keys method to DatabaseTransaction trait 5 hari lalu
  Cesar Rodas a49466357a Feat: implement database transaction pattern across all database backends 1 Minggu lalu
  tsk fd6f848120 fix: Enable pure environment variable configuration for Lightning backends (#1299) 5 hari lalu
  tsk 141e62a8dc feat(cdk): add Lightning address support with BIP353 fallback (#1295) 5 hari lalu
  C a12fd4dbea Prevent database contention in metadata cache load operations (#1300) 5 hari lalu
  tsk 36c6037442 fix: allow starting insecure man server (#1297) 5 hari lalu
  thesimplekid 288be4a377 fix: nightly fmt pr 5 hari lalu
  thesimplekid ccb84efe6c chore: fix nightly pr 5 hari lalu
  tsk ee910578ab fix: nightly ci (#1298) 5 hari lalu
  tsk 15c315ea09 feat(cdk): add invoice decoding for bolt11 and bolt12 (#1294) 5 hari lalu
  tsk c880ef7027 refactor(cdk/wallet): extract keyset key loading into helper method (#1296) 5 hari lalu
  tsk e1c18e51d4 fix: flaky test by using wait and pay (#1292) 6 hari lalu
  tsk e7fe058188 feat: add test coverage for mutants caught in https://github.com/cashubtc/cdk/issues/1290 (#1293) 6 hari lalu
  tsk 3df35b4a38 fix: load keyset keys from database to prevent duplicate insertions (#1291) 6 hari lalu
  tsk 023e7b97b6 chore: change mutation testing ci time (#1289) 6 hari lalu
  tsk df468139cb fix: we use the nightly flake so we don't need +nightly (#1288) 6 hari lalu
  black5box 0b95cbc3f6 chore: fix some minor issues in comments (#1287) 6 hari lalu
  tsk 66f7561680 ci: reduce ci jobs (#1284) 6 hari lalu
  C 501e72f7c7 Fix missing try_proof_operation_or_reclaim wrapping of a swap (#1278) 1 Minggu lalu
  C 24d397d10b Don't read keys from the database (#1280) 1 Minggu lalu
  C 2dfac3425e Update Wallet::fetch_mint_info (#1277) 1 Minggu lalu
  gudnuf 4723e32886 fix: return actual error from get_payment_quote (#1274) 1 Minggu lalu
  tsk 836a50aaa3 fix: require 0 signatures for HTLC with no pubkeys specified (#1275) 1 Minggu lalu
  SatsAndSports 9eaa6f1c02 feat: update NUT-11 SIG_ALL message aggregation per spec 1 Minggu lalu
  tsk e5882dc2eb test: add mutation testing infrastructure and security-critical coverage (#1210) 1 Minggu lalu
  tsk 2f9100ea4f Metadata follow up (#1268) 1 Minggu lalu
  tsk c859939289 fix: nut14 disabled in info (#1269) 1 Minggu lalu
  C 32c9288940 Introduce MintMetadataCache for efficient key and metadata management (#1240) 1 Minggu lalu
  tsk 2f0ff7fe9e chore: meeting agenda fmt (#1266) 1 Minggu lalu
  asmo d002481eb6 fix: check the removed_ys argument before creating the delete query (#1198) 1 Minggu lalu
  tsk f989fb784d chore: update stable rust to 1.91.1 (#1265) 1 Minggu lalu
  github-actions[bot] 0f1f2fe5a0 chore: add weekly meeting agenda for 2025-11-12 (#1261) 1 Minggu lalu
  thesimplekid 5af6976da2 chore: rust version check open issue 1 Minggu lalu
  tsk 50cf1d83b9 chore: rust version workflow (#1262) 1 Minggu lalu
  tsk 9354c2c698 feat(ci): add nightly rustfmt automation with flexible formatting policy (#1260) 1 Minggu lalu
  tsk 4feed9f6c2 mint async melt (#1258) 1 Minggu lalu
  C e14469a8fa feat: add keyset_amounts table to track issued and redeemed amounts (#1247) 1 Minggu lalu
  C 52d796e9fe refactor: replace proof swap with state check in error recovery (#1256) 2 minggu lalu
  github-actions[bot] 82f25795ba chore: add weekly meeting agenda for 2025-11-05 (#1254) 2 minggu lalu
  C 4e0132875f fix: add proof recovery mechanism for failed wallet operations (#1250) 2 minggu lalu
  tsk d9e001bee6 refactor(cdk): implement saga pattern for melt operations (#1186) 2 minggu lalu
  thesimplekid aa8258d955 fix: time of meeting 2 minggu lalu
  codingpeanut157 c68c5288f2 PreMintSecrets: fix `into_iter()` (#1244) 2 minggu lalu
  C c6434b88d1 Fix websocket issues and mint quotes (#1246) 2 minggu lalu
  tsk dd3cb8a83a fix: lnbits fee calc (#1243) 3 minggu lalu
  gandlafbtc 0c21cb59c6 Ldk compose setup (#1242) 3 minggu lalu
  David Caseria fb07ec4b85 Include cargo config for cdk-ffi to enforce Android page sizes (#1241) 3 minggu lalu
  github-actions[bot] cc1c7f1ada chore: add weekly meeting agenda for 2025-10-29 (#1239) 3 minggu lalu
  tsk ad4df779e3 feat(cdk-lnbits): add websocket reconnection with exponential backoff (#1237) 3 minggu lalu
  tsk 3a99582c04 fix: branches for ci (#1233) 3 minggu lalu
  gandlafbtc e44c83fb99 added missing env params in docker-compose.ldk-node.yaml (#1230) 3 minggu lalu
  tsk c43ea9507e fix: use back port toekn (#1231) 3 minggu lalu
  tsk 5b1e079e5f feat: tests of backport prs (#1224) 3 minggu lalu
  tsk 26b2688c3e fix: con grup (#1220) 3 minggu lalu
  tsk eb65e7603f fix: backport token (#1219) 3 minggu lalu
  tsk 18a41e55af feat: backport bot (#1215) 3 minggu lalu
  tsk d6824dd56f feat: add auto genrated meetings template (#1218) 3 minggu lalu
  Luke a40989b6b3 Typo fix (#1217) 3 minggu lalu
  Cesar Rodas a35ec4b09c WIP 3 minggu lalu
  tsk ec6e1e2910 fix: improve Melted error handling and add debug logging (#1213) 4 minggu lalu
  Cesar Rodas 815ce771d6 Clippy 4 minggu lalu
  Cesar Rodas de71ddc6da Update with_tx 4 minggu lalu
  Cesar Rodas 40a97ba785 Address race condition and add private all functions that receive a transaction 4 minggu lalu
  Cesar Rodas b0386373d5 Update tests to take advantage of rollback instead of pending proofs 4 minggu lalu
  Cesar Rodas e93189911c Don't accept db transactions from external 1 bulan lalu
  Cesar Rodas 8ab3dbe06f Fixed SQLite test 1 bulan lalu
  Cesar Rodas e68f706d11 Fixed race conditions with melt() 1 bulan lalu
  Cesar Rodas 6e3e0e706e cargo fmt 1 bulan lalu
  Cesar Rodas 870ed941e9 Fixed parameters order and fmt 1 bulan lalu
  Cesar Rodas fa6770cb1d Fix tests 1 bulan lalu
  Cesar Rodas e082a7b719 Added get_keyset_by_id to Tx 1 bulan lalu
  Cesar Rodas 00f4fc98a4 Improved swap to accept an external transaction 1 bulan lalu
  Cesar Rodas 3a39525340 Fixing db timeout 1 bulan lalu
  Cesar Rodas ec23b8c8c7 Fixed .get_keyset_fees_and_amounts_by_id and other functions 1 bulan lalu
  Cesar Rodas e4a75f1789 Add get_mint_keysets to the tx trait 1 bulan lalu
  Cesar Rodas c7d00238cb Add get_keys from the transaction 1 bulan lalu
  Cesar Rodas b1d7ebecca Fixed tests 1 bulan lalu
  Cesar Rodas 9c4e75dfec Fixed FFI 1 bulan lalu
  Cesar Rodas 5ea135842e Implement transactions for redb 1 bulan lalu
  Cesar Rodas eb0f5d478e Add Database transaction to the Wallet trait 1 bulan lalu
100 mengubah file dengan 11821 tambahan dan 3160 penghapusan
  1. 7 0
      .backportrc.json
  2. 9 0
      .cargo-mutants.toml
  3. 39 0
      .cargo/mutants.toml
  4. 58 0
      .github/ISSUE_TEMPLATE/mutation-testing.md
  5. 148 0
      .github/scripts/generate-agenda.sh
  6. 8 0
      .github/templates/failed-backport-issue.md
  7. 68 0
      .github/workflows/backport.yml
  8. 20 91
      .github/workflows/ci.yml
  9. 83 0
      .github/workflows/mutation-testing-weekly.yml
  10. 57 0
      .github/workflows/nightly-rustfmt.yml
  11. 9 1
      .github/workflows/nutshell_itest.yml
  12. 77 0
      .github/workflows/update-rust-version.yml
  13. 62 0
      .github/workflows/weekly-meeting-agenda.yml
  14. 5 0
      .gitignore
  15. 140 0
      DEVELOPMENT.md
  16. 345 0
      crates/cashu/src/amount.rs
  17. 164 0
      crates/cashu/src/dhke.rs
  18. 1 1
      crates/cashu/src/nuts/mod.rs
  19. 8 8
      crates/cashu/src/nuts/nut00/mod.rs
  20. 26 0
      crates/cashu/src/nuts/nut03.rs
  21. 39 1
      crates/cashu/src/nuts/nut05.rs
  22. 473 0
      crates/cashu/src/nuts/nut10.rs
  23. 568 434
      crates/cashu/src/nuts/nut11/mod.rs
  24. 153 0
      crates/cashu/src/nuts/nut12.rs
  25. 383 60
      crates/cashu/src/nuts/nut14/mod.rs
  26. 96 3
      crates/cdk-axum/src/router_handlers.rs
  27. 0 2
      crates/cdk-cli/src/sub_commands/check_pending.rs
  28. 37 0
      crates/cdk-cln/README.md
  29. 11 5
      crates/cdk-common/src/common.rs
  30. 12 3
      crates/cdk-common/src/database/mint/mod.rs
  31. 4 1
      crates/cdk-common/src/database/mod.rs
  32. 9 14
      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 6
      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. 7 0
      crates/cdk-ffi/.cargo/config.toml
  40. 1 0
      crates/cdk-ffi/Cargo.toml
  41. 306 317
      crates/cdk-ffi/src/database.rs
  42. 0 8
      crates/cdk-ffi/src/error.rs
  43. 15 30
      crates/cdk-ffi/src/postgres.rs
  44. 15 30
      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. 39 0
      crates/cdk-ffi/src/wallet.rs
  49. 14 0
      crates/cdk-integration-tests/src/init_pure_tests.rs
  50. 2 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. 5 5
      crates/cdk-integration-tests/tests/fake_auth.rs
  54. 203 46
      crates/cdk-integration-tests/tests/fake_wallet.rs
  55. 59 1
      crates/cdk-integration-tests/tests/integration_tests_pure.rs
  56. 2 5
      crates/cdk-integration-tests/tests/test_fees.rs
  57. 300 1
      crates/cdk-integration-tests/tests/test_swap_flow.rs
  58. 1 1
      crates/cdk-lnbits/Cargo.toml
  59. 45 0
      crates/cdk-lnbits/README.md
  60. 53 18
      crates/cdk-lnbits/src/lib.rs
  61. 38 0
      crates/cdk-lnd/README.md
  62. 13 5
      crates/cdk-mintd/README.md
  63. 14 11
      crates/cdk-mintd/example.config.toml
  64. 456 45
      crates/cdk-mintd/src/config.rs
  65. 10 5
      crates/cdk-mintd/src/lib.rs
  66. 35 0
      crates/cdk-mintd/src/setup.rs
  67. 121 197
      crates/cdk-redb/src/wallet/mod.rs
  68. 1 1
      crates/cdk-signatory/src/signatory.rs
  69. 2 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql
  70. 25 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251102000000_create_keyset_amounts.sql
  71. 2 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql
  72. 30 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251102000000_create_keyset_amounts.sql
  73. 121 9
      crates/cdk-sql-common/src/mint/mod.rs
  74. 12 0
      crates/cdk-sql-common/src/stmt.rs
  75. 15 0
      crates/cdk-sql-common/src/wallet/migrations/postgres/20251111000000_keyset_counter_table.sql
  76. 36 0
      crates/cdk-sql-common/src/wallet/migrations/sqlite/20251111000000_keyset_counter_table.sql
  77. 428 488
      crates/cdk-sql-common/src/wallet/mod.rs
  78. 5 1
      crates/cdk-sqlite/src/wallet/mod.rs
  79. 4 0
      crates/cdk/Cargo.toml
  80. 300 0
      crates/cdk/examples/human_readable_payment.rs
  81. 1 1
      crates/cdk/examples/proof-selection.rs
  82. 129 0
      crates/cdk/src/invoice.rs
  83. 10 6
      crates/cdk/src/lib.rs
  84. 238 0
      crates/cdk/src/lightning_address.rs
  85. 50 0
      crates/cdk/src/mint/builder.rs
  86. 31 6
      crates/cdk/src/mint/ln.rs
  87. 0 1076
      crates/cdk/src/mint/melt.rs
  88. 65 0
      crates/cdk/src/mint/melt/melt_saga/compensation.rs
  89. 909 0
      crates/cdk/src/mint/melt/melt_saga/mod.rs
  90. 46 0
      crates/cdk/src/mint/melt/melt_saga/state.rs
  91. 2130 0
      crates/cdk/src/mint/melt/melt_saga/tests.rs
  92. 554 0
      crates/cdk/src/mint/melt/mod.rs
  93. 443 0
      crates/cdk/src/mint/melt/shared.rs
  94. 180 0
      crates/cdk/src/mint/melt/tests/htlc_sigall_spending_conditions_tests.rs
  95. 191 0
      crates/cdk/src/mint/melt/tests/htlc_spending_conditions_tests.rs
  96. 276 0
      crates/cdk/src/mint/melt/tests/locktime_spending_conditions_tests.rs
  97. 5 0
      crates/cdk/src/mint/melt/tests/mod.rs
  98. 168 0
      crates/cdk/src/mint/melt/tests/p2pk_sigall_spending_conditions_tests.rs
  99. 134 0
      crates/cdk/src/mint/melt/tests/p2pk_spending_conditions_tests.rs
  100. 36 148
      crates/cdk/src/mint/mod.rs

+ 7 - 0
.backportrc.json

@@ -0,0 +1,7 @@
+{
+  "repoOwner": "cashubtc",
+  "repoName": "cdk",
+  "targetBranchChoices": ["v0.10.x", "v0.11.x", "v0.12.x", "v0.13.x"],
+  "autoMerge": false,
+  "autoMergeMethod": "merge"
+}

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

+ 148 - 0
.github/scripts/generate-agenda.sh

@@ -0,0 +1,148 @@
+#!/bin/bash
+set -e
+
+# Configuration
+REPO="${GITHUB_REPOSITORY:-cashubtc/cdk}"
+DAYS_BACK="${DAYS_BACK:-7}"
+MEETING_LINK="https://meet.fulmo.org/cdk-dev"
+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 15:00 UTC")
+FILE_DATE=$(date -u +"%Y-%m-%d")
+
+echo "Generating meeting agenda for $MEETING_DATE"
+echo "Fetching data since $SINCE_DATE"
+
+# Function to format PR/issue list
+format_list() {
+    local items="$1"
+    if [ -z "$items" ]; then
+        echo "- None"
+    else
+        echo "$items" | while IFS=$'\t' read -r number title url; do
+            echo "- [#$number]($url) - $title"
+        done
+    fi
+}
+
+# Fetch merged PRs
+echo "Fetching merged PRs..."
+MERGED_PRS=$(gh pr list \
+    --repo "$REPO" \
+    --state merged \
+    --search "merged:>=$SINCE_DATE" \
+    --json number,title,url \
+    --jq '.[] | [.number, .title, .url] | @tsv' \
+    2>/dev/null || echo "")
+
+# 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 \
+    --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)
+echo "Fetching new PRs..."
+NEW_PRS=$(gh pr list \
+    --repo "$REPO" \
+    --state open \
+    --search "created:>=$SINCE_DATE" \
+    --json number,title,url \
+    --jq '.[] | [.number, .title, .url] | @tsv' \
+    2>/dev/null || echo "")
+
+# Fetch new issues
+echo "Fetching new issues..."
+NEW_ISSUES=$(gh issue list \
+    --repo "$REPO" \
+    --state open \
+    --search "created:>=$SINCE_DATE" \
+    --json number,title,url \
+    --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
+
+$MEETING_DATE
+
+Meeting Link: $MEETING_LINK
+
+## Merged
+
+$(format_list "$MERGED_PRS")
+
+## New
+
+### Issues
+
+$(format_list "$NEW_ISSUES")
+
+### PRs
+
+$(format_list "$NEW_PRS")
+
+## Recently Active
+
+$(format_list "$RECENTLY_ACTIVE_PRS")
+
+## Discussion
+
+$(format_list "$DISCUSSION_ITEMS")
+EOF
+)
+
+echo "$AGENDA"
+
+# Output to file if requested
+if [ "${OUTPUT_TO_FILE:-true}" = "true" ]; then
+    mkdir -p "$OUTPUT_DIR"
+    OUTPUT_FILE="$OUTPUT_DIR/$FILE_DATE-agenda.md"
+    echo "$AGENDA" > "$OUTPUT_FILE"
+    echo "Agenda saved to $OUTPUT_FILE"
+fi
+
+# Create GitHub Discussion if requested
+if [ "${CREATE_DISCUSSION:-false}" = "true" ]; then
+    echo "Creating GitHub discussion..."
+    DISCUSSION_TITLE="CDK Dev Meeting - $MEETING_DATE"
+
+    # Note: gh CLI doesn't have direct discussion creation yet, so we'd need to use the API
+    # For now, we'll just output instructions
+    echo "To create discussion manually, use the GitHub web interface or API"
+    echo "Title: $DISCUSSION_TITLE"
+fi
+
+# Output for GitHub Actions
+if [ -n "$GITHUB_OUTPUT" ]; then
+    echo "agenda_file=$OUTPUT_FILE" >> "$GITHUB_OUTPUT"
+    echo "meeting_date=$MEETING_DATE" >> "$GITHUB_OUTPUT"
+fi

+ 8 - 0
.github/templates/failed-backport-issue.md

@@ -0,0 +1,8 @@
+---
+title: Backport PR `#{{ env.PR_NUMBER }}` {{ env.SHORT_PR_TITLE }}
+labels: backport
+---
+PR: #{{ env.PR_NUMBER }}
+Title: {{ env.PR_TITLE }}
+
+The backport bot failed. Please open a PR to backport these changes.

+ 68 - 0
.github/workflows/backport.yml

@@ -0,0 +1,68 @@
+name: Backport merged pull request
+on:
+  pull_request_target:
+    # Run on merge (close) or if label is added after merging
+    types: [closed, labeled]
+
+# Set concurrency limit to a single backport workflow per PR
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+  cancel-in-progress: true
+
+jobs:
+  backport:
+    permissions:
+      contents: write # so it can comment
+      pull-requests: write # so it can create pull requests
+    name: Backport pull request
+    runs-on: ubuntu-latest
+
+    # Don't run on closed unmerged pull requests or if a non-backport label is added
+    if: |
+      github.event.pull_request.merged &&
+      (
+        github.event.action != 'labeled' ||
+        contains(github.event.label.name, 'backport')
+      )
+
+    outputs:
+      was_successful: ${{ steps.create-pr.outputs.was_successful }}
+
+    steps:
+      - uses: actions/checkout@v4
+      - id: create-pr
+        name: Create backport pull requests
+        uses: korthout/backport-action@v3
+        with:
+          github_token: ${{ secrets.BACKPORT_TOKEN }}
+
+  open-issue:
+    permissions:
+      contents: read
+      issues: write
+    name: Open issue for failed backports
+    runs-on: ubuntu-latest
+    needs: backport
+
+    # Open an issue only if the backport job failed
+    if: ${{ needs.backport.outputs.was_successful == 'false' }}
+
+    steps:
+      - uses: actions/checkout@v4
+      - name: Set SHORT_PR_TITLE env
+        run: |
+          SHORT_PR_TITLE=$(
+            echo '${{ github.event.pull_request.title }}' \
+            | awk '{print (length($0) > 40) ? substr($0, 1, 40) "..." : $0}'
+          )
+          echo "SHORT_PR_TITLE=$SHORT_PR_TITLE" >> "$GITHUB_ENV"
+
+      - name: Create issue
+        uses: JasonEtco/create-an-issue@v2
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          PR_NUMBER: ${{ github.event.number }}
+          PR_TITLE: ${{ github.event.pull_request.title }}
+          SHORT_PR_TITLE: ${{ env.SHORT_PR_TITLE }}
+        with:
+          filename: .github/templates/failed-backport-issue.md

+ 20 - 91
.github/workflows/ci.yml

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

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

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

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

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

+ 9 - 1
.github/workflows/nutshell_itest.yml

@@ -1,6 +1,14 @@
 name: Nutshell integration
 
-on: [push, pull_request]
+on:
+  push:
+    branches: [main]
+  pull_request:
+    branches:
+      - main
+      - 'v[0-9]*.[0-9]*.x'  # Match version branches like v0.13.x, v1.0.x, etc.
+  release:
+    types: [created]
 
 jobs:
   nutshell-integration-tests:

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

+ 62 - 0
.github/workflows/weekly-meeting-agenda.yml

@@ -0,0 +1,62 @@
+name: Weekly Meeting Agenda
+
+on:
+  schedule:
+    # Run every Wednesday at 12:00 UTC (3 hours before the 15:00 UTC meeting)
+    - cron: '0 12 * * 3'
+  workflow_dispatch:  # Allow manual triggering for testing
+
+permissions:
+  contents: write
+  pull-requests: write
+  issues: read
+
+jobs:
+  generate-agenda:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+
+      - name: Generate meeting agenda
+        id: generate
+        env:
+          GH_TOKEN: ${{ github.token }}
+          GITHUB_REPOSITORY: ${{ github.repository }}
+          OUTPUT_TO_FILE: "true"
+          CREATE_DISCUSSION: "false"
+          DAYS_BACK: "7"
+        run: |
+          bash .github/scripts/generate-agenda.sh
+
+      - name: Create Pull Request
+        env:
+          GH_TOKEN: ${{ github.token }}
+        run: |
+          MEETING_DATE=$(date -u +"%Y-%m-%d")
+          BRANCH_NAME="meeting-agenda-${MEETING_DATE}"
+
+          git config --global user.name 'github-actions[bot]'
+          git config --global user.email 'github-actions[bot]@users.noreply.github.com'
+
+          # Create and switch to new branch
+          git checkout -b "$BRANCH_NAME"
+
+          # Add and commit the agenda file
+          git add meetings/*.md
+          if git diff --cached --quiet; then
+            echo "No changes to commit"
+            exit 0
+          fi
+
+          git commit -m "chore: add weekly meeting agenda for ${MEETING_DATE}"
+
+          # Push the branch
+          git push origin "$BRANCH_NAME"
+
+          # Create pull request
+          gh pr create \
+            --title "Weekly Meeting Agenda - ${MEETING_DATE}" \
+            --body "Automated weekly meeting agenda for CDK Development Meeting on ${MEETING_DATE}." \
+            --base main \
+            --head "$BRANCH_NAME"

+ 5 - 0
.gitignore

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

+ 140 - 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
@@ -226,6 +287,85 @@ just final-check
 4. Submit a pull request
 5. Wait for review and address feedback
 
+## Backporting Changes
+
+CDK uses an automated backport bot to help maintain stable release branches. This section explains how the backport process works.
+
+### How the Backport Bot Works
+
+The backport bot creates pull requests to backport merged changes from `main` to stable release branches. **You control which branches to backport to by adding labels to your PR.**
+
+**Available Target Branches:**
+- `v0.10.x`
+- `v0.11.x`
+- `v0.12.x`
+- `v0.13.x`
+
+### Using Backport Labels
+
+To backport a PR to specific stable branches, add labels to your PR **before or after merging**:
+
+**Label Format:**
+- `backport v0.13.x` - backports to v0.13.x branch
+- `backport v0.12.x` - backports to v0.12.x branch
+- Add multiple labels to backport to multiple branches
+
+**Example Workflow:**
+1. Create and merge your PR to `main`
+2. Add label `backport v0.13.x` to the PR
+3. The bot automatically creates a backport PR for the v0.13.x branch
+4. Review and merge the backport PR
+5. Repeat for other branches as needed
+
+**When to Add Labels:**
+- Add labels before merging - backport PRs are created automatically on merge
+- Add labels after merging - backport PRs are created when you add the label
+- You can add multiple backport labels at once
+
+### When Backports Fail
+
+Sometimes the backport bot cannot automatically create a backport PR due to merge conflicts or other issues. When this happens:
+
+1. The bot automatically creates a GitHub issue labeled with `backport`
+2. The issue will contain details about the original PR and which branch(es) failed
+3. You'll need to manually create the backport PR for the failed branch
+
+**Manual Backporting Process:**
+```bash
+# Checkout the target stable branch
+git checkout v0.13.x
+git pull origin v0.13.x
+
+# Create a new branch for the backport
+git checkout -b backport-pr-NUMBER-to-v0.13.x
+
+# Cherry-pick the commits from the original PR
+git cherry-pick COMMIT_HASH
+
+# Resolve any conflicts if they occur
+# Then push and create a PR
+git push origin backport-pr-NUMBER-to-v0.13.x
+```
+
+### Best Practices for Backporting
+
+1. **Label Appropriately:** Only add backport labels for changes that should be in stable branches
+2. **Keep PRs Focused:** Smaller, focused PRs are easier to backport automatically
+3. **Review Backport PRs:** Always review automatically created backport PRs to ensure they're appropriate
+4. **Test Backports:** Run tests on backport PRs just like regular PRs
+5. **Address Conflicts Promptly:** If a backport fails, address it promptly or close the issue with an explanation
+
+### When NOT to Backport
+
+Not all changes should be backported to stable branches. **Don't add backport labels** for:
+- Breaking API changes
+- New features that aren't needed in older versions
+- Changes that don't apply to older version branches
+- Large refactorings
+- Experimental or unstable features
+
+If a backport isn't appropriate, simply don't add the backport label to the PR.
+
 ## Additional Resources
 
 - [Nix Documentation](https://nixos.org/manual/nix/stable/)

+ 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

File diff ditekan karena terlalu besar
+ 568 - 434
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());
+    }
 }

+ 383 - 60
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,8 +12,7 @@ use super::nut00::Witness;
 use super::nut10::Secret;
 use super::nut11::valid_signatures;
 use super::{Conditions, Proof};
-use crate::ensure_cdk;
-use crate::util::unix_time;
+use crate::util::{hex, unix_time};
 
 pub mod serde_htlc_witness;
 
@@ -37,9 +34,18 @@ pub enum Error {
     /// Preimage does not match
     #[error("Preimage does not match")]
     Preimage,
+    /// HTLC preimage must be valid hex encoding
+    #[error("Preimage must be valid hex encoding")]
+    InvalidHexPreimage,
+    /// HTLC preimage must be exactly 32 bytes
+    #[error("Preimage must be exactly 32 bytes (64 hex characters)")]
+    PreimageInvalidSize,
     /// 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),
@@ -62,78 +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());
-
-        let htlc_witness = match &self.witness {
-            Some(Witness::HTLCWitness(witness)) => witness,
-            _ => return Err(Error::IncorrectSecretKind),
-        };
+            .cloned()
+            .unwrap_or_default()
+            .try_into()?;
 
-        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);
-            }
+        if spending_conditions.sig_flag == super::SigFlag::SigAll {
+            return Err(Error::SigAllNotSupportedHere);
         }
 
-        if secret.kind().ne(&super::Kind::HTLC) {
+        if secret.kind() != super::Kind::HTLC {
             return Err(Error::IncorrectSecretKind);
         }
 
-        let hash_lock =
-            Sha256Hash::from_str(secret.secret_data().data()).map_err(|_| Error::InvalidHash)?;
+        // 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)?;
+
+        // 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.
 
-        let preimage_hash = Sha256Hash::hash(htlc_witness.preimage.as_bytes());
+        // 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),
+            };
+
+            // Verify preimage using shared function
+            super::nut10::verify_htlc_preimage(htlc_witness, &secret)?;
+        }
 
-        if hash_lock.ne(&preimage_hash) {
-            return Err(Error::Preimage);
+        if requirements.required_sigs == 0 {
+            return Ok(());
         }
 
-        Ok(())
+        // 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:
+
+        // 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)?;
+
+        // 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)?;
+
+        // 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
@@ -151,3 +188,289 @@ impl Proof {
         }))
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use bitcoin::hashes::sha256::Hash as Sha256Hash;
+    use bitcoin::hashes::Hash;
+
+    use super::*;
+    use crate::nuts::nut00::Witness;
+    use crate::nuts::nut10::Kind;
+    use crate::nuts::Nut10Secret;
+    use crate::secret::Secret as SecretString;
+
+    /// Tests that verify_htlc correctly accepts a valid HTLC with the correct preimage.
+    ///
+    /// This test ensures that a properly formed HTLC proof with the correct preimage
+    /// passes verification.
+    ///
+    /// Mutant testing: Combined with negative tests, this catches mutations that
+    /// replace verify_htlc with Ok(()) since the negative tests will fail.
+    #[test]
+    fn test_verify_htlc_valid() {
+        // Create a valid HTLC secret with a known preimage (32 bytes)
+        let preimage_bytes = [42u8; 32]; // 32-byte preimage
+        let hash = Sha256Hash::hash(&preimage_bytes);
+        let hash_str = hash.to_string();
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::<Vec<Vec<String>>>);
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        let htlc_witness = HTLCWitness {
+            preimage: hex::encode(&preimage_bytes),
+            signatures: None,
+        };
+
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::HTLCWitness(htlc_witness)),
+            dleq: None,
+        };
+
+        // Valid HTLC should verify successfully
+        assert!(proof.verify_htlc().is_ok());
+    }
+
+    /// Tests that verify_htlc correctly rejects an HTLC with a wrong preimage.
+    ///
+    /// This test is critical for security - if the verification function doesn't properly
+    /// check the preimage against the hash, an attacker could spend HTLC-locked funds
+    /// without knowing the correct preimage.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or remove
+    /// the preimage verification logic.
+    #[test]
+    fn test_verify_htlc_wrong_preimage() {
+        // Create an HTLC secret with a specific hash (32 bytes)
+        let correct_preimage_bytes = [42u8; 32];
+        let hash = Sha256Hash::hash(&correct_preimage_bytes);
+        let hash_str = hash.to_string();
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::<Vec<Vec<String>>>);
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        // Use a different preimage in the witness
+        let wrong_preimage_bytes = [99u8; 32]; // Different from correct preimage
+        let htlc_witness = HTLCWitness {
+            preimage: hex::encode(&wrong_preimage_bytes),
+            signatures: None,
+        };
+
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::HTLCWitness(htlc_witness)),
+            dleq: None,
+        };
+
+        // Verification should fail with wrong preimage
+        let result = proof.verify_htlc();
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::Preimage));
+    }
+
+    /// Tests that verify_htlc correctly rejects an HTLC with an invalid hash format.
+    ///
+    /// This test ensures that the verification function properly validates that the
+    /// hash in the secret data is a valid SHA256 hash.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or
+    /// remove the hash validation logic.
+    #[test]
+    fn test_verify_htlc_invalid_hash() {
+        // Create an HTLC secret with an invalid hash (not a valid hex string)
+        let invalid_hash = "not_a_valid_hash";
+
+        let nut10_secret = Nut10Secret::new(
+            Kind::HTLC,
+            invalid_hash.to_string(),
+            None::<Vec<Vec<String>>>,
+        );
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        let preimage_bytes = [42u8; 32]; // Valid 32-byte preimage
+        let htlc_witness = HTLCWitness {
+            preimage: hex::encode(&preimage_bytes),
+            signatures: None,
+        };
+
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::HTLCWitness(htlc_witness)),
+            dleq: None,
+        };
+
+        // Verification should fail with invalid hash
+        let result = proof.verify_htlc();
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::InvalidHash));
+    }
+
+    /// Tests that verify_htlc correctly rejects an HTLC with the wrong witness type.
+    ///
+    /// This test ensures that the verification function checks that the witness is
+    /// of the correct type (HTLCWitness) and not some other witness type.
+    ///
+    /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or
+    /// remove the witness type check.
+    #[test]
+    fn test_verify_htlc_wrong_witness_type() {
+        // Create an HTLC secret
+        let preimage = "test_preimage";
+        let hash = Sha256Hash::hash(preimage.as_bytes());
+        let hash_str = hash.to_string();
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::<Vec<Vec<String>>>);
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        // Create proof with wrong witness type (P2PKWitness instead of HTLCWitness)
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::P2PKWitness(super::super::nut11::P2PKWitness {
+                signatures: vec![],
+            })),
+            dleq: None,
+        };
+
+        // Verification should fail with wrong witness type
+        let result = proof.verify_htlc();
+        assert!(result.is_err());
+        assert!(matches!(result.unwrap_err(), Error::IncorrectSecretKind));
+    }
+
+    /// Tests that add_preimage correctly adds a preimage to the proof.
+    ///
+    /// This test ensures that add_preimage actually modifies the witness and doesn't
+    /// just return without doing anything.
+    ///
+    /// Mutant testing: Catches mutations that replace add_preimage with () without
+    /// actually adding the preimage.
+    #[test]
+    fn test_add_preimage() {
+        let preimage_bytes = [42u8; 32]; // 32-byte preimage
+        let hash = Sha256Hash::hash(&preimage_bytes);
+        let hash_str = hash.to_string();
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::<Vec<Vec<String>>>);
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        let mut proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: None,
+            dleq: None,
+        };
+
+        // Initially, witness should be None
+        assert!(proof.witness.is_none());
+
+        // Add preimage (hex-encoded)
+        let preimage_hex = hex::encode(&preimage_bytes);
+        proof.add_preimage(preimage_hex.clone());
+
+        // After adding, witness should be Some with HTLCWitness
+        assert!(proof.witness.is_some());
+        if let Some(Witness::HTLCWitness(witness)) = &proof.witness {
+            assert_eq!(witness.preimage, preimage_hex);
+        } else {
+            panic!("Expected HTLCWitness");
+        }
+
+        // The proof with added preimage should verify successfully
+        assert!(proof.verify_htlc().is_ok());
+    }
+
+    /// Tests that verify_htlc requires BOTH locktime expired AND no refund keys for "anyone can spend".
+    ///
+    /// This test catches the mutation that replaces `&&` with `||` at line 83.
+    /// The logic should be: (locktime expired AND no refund keys) → anyone can spend.
+    /// If mutated to OR, it would allow spending when locktime passed even if refund keys exist.
+    ///
+    /// Mutant testing: Catches mutations that replace `&&` with `||` in the locktime check.
+    #[test]
+    fn test_htlc_locktime_and_refund_keys_logic() {
+        use crate::nuts::nut01::PublicKey;
+        use crate::nuts::nut11::Conditions;
+
+        let preimage_bytes = [42u8; 32]; // 32-byte preimage
+        let hash = Sha256Hash::hash(&preimage_bytes);
+        let hash_str = hash.to_string();
+
+        // Test: Locktime has passed (locktime=1) but refund keys ARE present
+        // With correct logic (&&): Since refund_keys.is_none() is false, the "anyone can spend"
+        //                          path is NOT taken, so signature is required
+        // With mutation (||): Since locktime.lt(&unix_time()) is true, it WOULD take the
+        //                     "anyone can spend" path immediately - WRONG!
+        let refund_pubkey = PublicKey::from_hex(
+            "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+        )
+        .unwrap();
+
+        let conditions_with_refund = Conditions {
+            locktime: Some(1), // Locktime in past (current time is much larger)
+            pubkeys: None,
+            refund_keys: Some(vec![refund_pubkey]), // Refund key present
+            num_sigs: None,
+            sig_flag: crate::nuts::nut11::SigFlag::default(),
+            num_sigs_refund: None,
+        };
+
+        let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, Some(conditions_with_refund));
+        let secret: SecretString = nut10_secret.try_into().unwrap();
+
+        let htlc_witness = HTLCWitness {
+            preimage: hex::encode(&preimage_bytes),
+            signatures: None, // No signature provided
+        };
+
+        let proof = Proof {
+            amount: crate::Amount::from(1),
+            keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(),
+            secret,
+            c: crate::nuts::nut01::PublicKey::from_hex(
+                "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2",
+            )
+            .unwrap(),
+            witness: Some(Witness::HTLCWitness(htlc_witness)),
+            dleq: None,
+        };
+
+        // Should FAIL because even though locktime passed, refund keys are present
+        // so the "anyone can spend" shortcut shouldn't apply. A signature is required.
+        // With && this correctly fails. With || it would incorrectly pass.
+        let result = proof.verify_htlc();
+        assert!(
+            result.is_err(),
+            "Should fail when locktime passed but refund keys present without signature"
+        );
+    }
+}

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

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

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

@@ -10,8 +10,6 @@ pub async fn check_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> {
         println!("{i}: {mint_url}");
 
         // Get all pending proofs
-        //
-
         let pending_proofs = wallet.get_pending_proofs().await?;
         if pending_proofs.is_empty() {
             println!("No pending proofs found");

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

+ 11 - 5
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> {
@@ -41,16 +41,22 @@ impl Melted {
             None => Amount::ZERO,
         };
 
+        tracing::info!(
+            "Proofs amount: {} Amount: {} Change: {}",
+            proofs_amount,
+            quote_amount,
+            change_amount
+        );
+
         let fee_paid = proofs_amount
-            .checked_sub(amount + change_amount)
-            .ok_or(Error::AmountOverflow)
-            .unwrap();
+            .checked_sub(quote_amount + change_amount)
+            .ok_or(Error::AmountOverflow)?;
 
         Ok(Self {
             state,
             preimage,
             change: change_proofs,
-            amount,
+            amount: quote_amount,
             fee_paid,
         })
     }

+ 12 - 3
crates/cdk-common/src/database/mint/mod.rs

@@ -108,7 +108,7 @@ pub trait KeysDatabase {
     /// Mint Keys Database Error
     type Err: Into<Error> + From<Error>;
 
-    /// Beings a transaction
+    /// Begins a transaction
     async fn begin_transaction<'a>(
         &'a self,
     ) -> Result<Box<dyn KeysDatabaseTransaction<'a, Self::Err> + Send + Sync + 'a>, Error>;
@@ -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]
@@ -466,7 +475,7 @@ pub trait KVStoreDatabase {
 /// Key-Value Store Database trait
 #[async_trait]
 pub trait KVStore: KVStoreDatabase {
-    /// Beings a KV transaction
+    /// Begins a KV transaction
     async fn begin_transaction<'a>(
         &'a self,
     ) -> Result<Box<dyn KVStoreTransaction<'a, Self::Err> + Send + Sync + 'a>, Error>;
@@ -484,7 +493,7 @@ pub trait Database<Error>:
     + SignaturesDatabase<Err = Error>
     + SagaDatabase<Err = Error>
 {
-    /// Beings a transaction
+    /// Begins a transaction
     async fn begin_transaction<'a>(
         &'a self,
     ) -> Result<Box<dyn Transaction<'a, Error> + Send + Sync + 'a>, Error>;

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

@@ -18,7 +18,10 @@ pub use mint::{
 #[cfg(all(feature = "mint", feature = "auth"))]
 pub use mint::{DynMintAuthDatabase, MintAuthDatabase, MintAuthTransaction};
 #[cfg(feature = "wallet")]
-pub use wallet::{Database as WalletDatabase, DatabaseTransaction as WalletDatabaseTransaction};
+pub use wallet::{
+    Database as WalletDatabase, DatabaseTransaction as WalletDatabaseTransaction,
+    DynWalletDatabaseTransaction,
+};
 
 /// Data conversion error
 #[derive(thiserror::Error, Debug)]

+ 9 - 14
crates/cdk-common/src/database/wallet.rs

@@ -16,24 +16,16 @@ use crate::wallet::{
     self, MintQuote as WalletMintQuote, Transaction, TransactionDirection, TransactionId,
 };
 
+/// Easy to use Dynamic Database type alias
+pub type DynWalletDatabaseTransaction<'a> =
+    Box<dyn DatabaseTransaction<'a, super::Error> + Sync + Send + 'a>;
+
 /// 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 DatabaseTransaction<'a, Error>: DbTransactionFinalizer<Err = Error> {
-    /// Get mint from storage
-    async fn get_mint(&mut self, mint_url: MintUrl) -> Result<Option<MintInfo>, Error>;
-
-    /// Get [`Keys`] from storage
-    async fn get_keys(&mut self, id: &Id) -> Result<Option<Keys>, Error>;
-
-    /// Get mint keysets for mint url
-    async fn get_mint_keysets(
-        &mut self,
-        mint_url: MintUrl,
-    ) -> Result<Option<Vec<KeySetInfo>>, Error>;
-
     /// Add Mint to storage
     async fn add_mint(
         &mut self,
@@ -54,6 +46,9 @@ pub trait DatabaseTransaction<'a, Error>: DbTransactionFinalizer<Err = 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>, Self::Err>;
+
     /// Add mint keyset to storage
     async fn add_mint_keysets(
         &mut self,
@@ -125,10 +120,10 @@ pub trait Database: Debug {
     /// Wallet Database Error
     type Err: Into<Error> + From<Error>;
 
-    /// Beings a KV transaction
+    /// Begins a DB transaction
     async fn begin_db_transaction<'a>(
         &'a self,
-    ) -> Result<Box<dyn DatabaseTransaction<'a, Self::Err> + Send + Sync + 'a>, Error>;
+    ) -> Result<Box<dyn DatabaseTransaction<'a, Self::Err> + Send + Sync + 'a>, Self::Err>;
 
     /// Get mint from storage
     async fn get_mint(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, 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 - 6
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 sent to Lightning network
+    PaymentSent,
+}
+
+impl fmt::Display for MeltSagaState {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            MeltSagaState::SetupComplete => write!(f, "setup_complete"),
+            MeltSagaState::PaymentSent => write!(f, "payment_sent"),
+        }
+    }
+}
+
+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_sent" => Ok(MeltSagaState::PaymentSent),
+            _ => 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::PaymentSent => "payment_sent",
+            },
         }
     }
 }
@@ -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}"))),
         }
     }
 }

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

+ 7 - 0
crates/cdk-ffi/.cargo/config.toml

@@ -0,0 +1,7 @@
+[target.'cfg(target_os = "android")']
+rustflags = [
+    "-C", "link-arg=-z",
+    "-C", "link-arg=max-page-size=16384",
+    "-C", "link-arg=-z",
+    "-C", "link-arg=common-page-size=16384",
+]

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

@@ -28,6 +28,7 @@ tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] }
 uniffi = { version = "0.29", features = ["cli", "tokio"] }
 url = { workspace = true }
 uuid = { workspace = true, features = ["v4"] }
+cdk-common.workspace = true
 
 
 [features]

+ 306 - 317
crates/cdk-ffi/src/database.rs

@@ -4,6 +4,7 @@ use std::collections::HashMap;
 use std::sync::Arc;
 
 use cdk::cdk_database::WalletDatabase as CdkWalletDatabase;
+use cdk_common::database::{DbTransactionFinalizer, WalletDatabaseTransaction};
 
 use crate::error::FfiError;
 use crate::postgres::WalletPostgresDatabase;
@@ -168,19 +169,138 @@ impl std::fmt::Debug for WalletDatabaseBridge {
     }
 }
 
-/// Transaction bridge that wraps FFI atomic operations
-struct WalletDatabaseTransactionBridge {
-    ffi_db: Arc<dyn WalletDatabase>,
-}
-
 #[async_trait::async_trait]
-impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Error>
-    for WalletDatabaseTransactionBridge
-{
-    async fn get_keys(
-        &mut self,
-        id: &cdk::nuts::Id,
-    ) -> Result<Option<cdk::nuts::Keys>, cdk::cdk_database::Error> {
+impl CdkWalletDatabase for WalletDatabaseBridge {
+    type Err = cdk::cdk_database::Error;
+
+    // Mint Management
+    async fn get_mint(
+        &self,
+        mint_url: cdk::mint_url::MintUrl,
+    ) -> Result<Option<cdk::nuts::MintInfo>, Self::Err> {
+        let ffi_mint_url = mint_url.into();
+        let result = self
+            .ffi_db
+            .get_mint(ffi_mint_url)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        Ok(result.map(Into::into))
+    }
+
+    async fn get_mints(
+        &self,
+    ) -> Result<HashMap<cdk::mint_url::MintUrl, Option<cdk::nuts::MintInfo>>, Self::Err> {
+        let result = self
+            .ffi_db
+            .get_mints()
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+
+        let mut cdk_result = HashMap::new();
+        for (ffi_mint_url, mint_info_opt) in result {
+            let cdk_url = ffi_mint_url
+                .try_into()
+                .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+            cdk_result.insert(cdk_url, mint_info_opt.map(Into::into));
+        }
+        Ok(cdk_result)
+    }
+
+    // Keyset Management
+    async fn get_mint_keysets(
+        &self,
+        mint_url: cdk::mint_url::MintUrl,
+    ) -> Result<Option<Vec<cdk::nuts::KeySetInfo>>, Self::Err> {
+        let ffi_mint_url = mint_url.into();
+        let result = self
+            .ffi_db
+            .get_mint_keysets(ffi_mint_url)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect()))
+    }
+
+    async fn get_keyset_by_id(
+        &self,
+        keyset_id: &cdk::nuts::Id,
+    ) -> Result<Option<cdk::nuts::KeySetInfo>, Self::Err> {
+        let ffi_id = (*keyset_id).into();
+        let result = self
+            .ffi_db
+            .get_keyset_by_id(ffi_id)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        Ok(result.map(Into::into))
+    }
+
+    // Mint Quote Management
+    async fn get_mint_quote(
+        &self,
+        quote_id: &str,
+    ) -> Result<Option<cdk::wallet::MintQuote>, Self::Err> {
+        let result = self
+            .ffi_db
+            .get_mint_quote(quote_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        Ok(result
+            .map(|q| {
+                q.try_into()
+                    .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
+            })
+            .transpose()?)
+    }
+
+    async fn get_mint_quotes(&self) -> Result<Vec<cdk::wallet::MintQuote>, Self::Err> {
+        let result = self
+            .ffi_db
+            .get_mint_quotes()
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        Ok(result
+            .into_iter()
+            .map(|q| {
+                q.try_into()
+                    .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
+            })
+            .collect::<Result<Vec<_>, _>>()?)
+    }
+
+    // Melt Quote Management
+    async fn get_melt_quote(
+        &self,
+        quote_id: &str,
+    ) -> Result<Option<cdk::wallet::MeltQuote>, Self::Err> {
+        let result = self
+            .ffi_db
+            .get_melt_quote(quote_id.to_string())
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        Ok(result
+            .map(|q| {
+                q.try_into()
+                    .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
+            })
+            .transpose()?)
+    }
+
+    async fn get_melt_quotes(&self) -> Result<Vec<cdk::wallet::MeltQuote>, Self::Err> {
+        let result = self
+            .ffi_db
+            .get_melt_quotes()
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+        Ok(result
+            .into_iter()
+            .map(|q| {
+                q.try_into()
+                    .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
+            })
+            .collect::<Result<Vec<_>, _>>()?)
+    }
+
+    // Keys Management
+    async fn get_keys(&self, id: &cdk::nuts::Id) -> Result<Option<cdk::nuts::Keys>, Self::Err> {
         let ffi_id: Id = (*id).into();
         let result = self
             .ffi_db
@@ -198,19 +318,132 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
             .transpose()
     }
 
-    async fn get_mint_keysets(
-        &mut self,
-        mint_url: cdk::mint_url::MintUrl,
-    ) -> Result<Option<Vec<cdk::nuts::KeySetInfo>>, cdk::cdk_database::Error> {
-        let ffi_mint_url = mint_url.into();
+    // Proof Management
+    async fn get_proofs(
+        &self,
+        mint_url: Option<cdk::mint_url::MintUrl>,
+        unit: Option<cdk::nuts::CurrencyUnit>,
+        state: Option<Vec<cdk::nuts::State>>,
+        spending_conditions: Option<Vec<cdk::nuts::SpendingConditions>>,
+    ) -> Result<Vec<cdk::types::ProofInfo>, Self::Err> {
+        let ffi_mint_url = mint_url.map(Into::into);
+        let ffi_unit = unit.map(Into::into);
+        let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect());
+        let ffi_spending_conditions =
+            spending_conditions.map(|sc| sc.into_iter().map(Into::into).collect());
+
         let result = self
             .ffi_db
-            .get_mint_keysets(ffi_mint_url)
+            .get_proofs(ffi_mint_url, ffi_unit, ffi_state, ffi_spending_conditions)
             .await
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-        Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect()))
+
+        // Convert back to CDK ProofInfo
+        let cdk_result: Result<Vec<cdk::types::ProofInfo>, cdk::cdk_database::Error> = result
+            .into_iter()
+            .map(|info| {
+                Ok(cdk::types::ProofInfo {
+                    proof: info.proof.try_into().map_err(|e: FfiError| {
+                        cdk::cdk_database::Error::Database(e.to_string().into())
+                    })?,
+                    y: info.y.try_into().map_err(|e: FfiError| {
+                        cdk::cdk_database::Error::Database(e.to_string().into())
+                    })?,
+                    mint_url: info.mint_url.try_into().map_err(|e: FfiError| {
+                        cdk::cdk_database::Error::Database(e.to_string().into())
+                    })?,
+                    state: info.state.into(),
+                    spending_condition: info
+                        .spending_condition
+                        .map(|sc| sc.try_into())
+                        .transpose()
+                        .map_err(|e: FfiError| {
+                            cdk::cdk_database::Error::Database(e.to_string().into())
+                        })?,
+                    unit: info.unit.into(),
+                })
+            })
+            .collect();
+
+        cdk_result
+    }
+
+    async fn get_balance(
+        &self,
+        mint_url: Option<cdk::mint_url::MintUrl>,
+        unit: Option<cdk::nuts::CurrencyUnit>,
+        state: Option<Vec<cdk::nuts::State>>,
+    ) -> Result<u64, Self::Err> {
+        let ffi_mint_url = mint_url.map(Into::into);
+        let ffi_unit = unit.map(Into::into);
+        let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect());
+
+        self.ffi_db
+            .get_balance(ffi_mint_url, ffi_unit, ffi_state)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    // Transaction Management
+    async fn get_transaction(
+        &self,
+        transaction_id: cdk::wallet::types::TransactionId,
+    ) -> Result<Option<cdk::wallet::types::Transaction>, Self::Err> {
+        let ffi_id = transaction_id.into();
+        let result = self
+            .ffi_db
+            .get_transaction(ffi_id)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+
+        result
+            .map(|tx| tx.try_into())
+            .transpose()
+            .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
+
+    async fn list_transactions(
+        &self,
+        mint_url: Option<cdk::mint_url::MintUrl>,
+        direction: Option<cdk::wallet::types::TransactionDirection>,
+        unit: Option<cdk::nuts::CurrencyUnit>,
+    ) -> Result<Vec<cdk::wallet::types::Transaction>, Self::Err> {
+        let ffi_mint_url = mint_url.map(Into::into);
+        let ffi_direction = direction.map(Into::into);
+        let ffi_unit = unit.map(Into::into);
+
+        let result = self
+            .ffi_db
+            .list_transactions(ffi_mint_url, ffi_direction, ffi_unit)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+
+        result
+            .into_iter()
+            .map(|tx| tx.try_into())
+            .collect::<Result<Vec<_>, FfiError>>()
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
 
+    async fn begin_db_transaction<'a>(
+        &'a self,
+    ) -> Result<Box<dyn WalletDatabaseTransaction<'a, Self::Err> + Send + Sync + 'a>, Self::Err>
+    {
+        Ok(Box::new(WalletDatabaseTransactionBridge {
+            ffi_db: Arc::clone(&self.ffi_db),
+        }))
+    }
+}
+
+/// Transaction bridge for FFI wallet database
+struct WalletDatabaseTransactionBridge {
+    ffi_db: Arc<dyn WalletDatabase>,
+}
+
+#[async_trait::async_trait]
+impl<'a> WalletDatabaseTransaction<'a, cdk::cdk_database::Error>
+    for WalletDatabaseTransactionBridge
+{
     async fn add_mint(
         &mut self,
         mint_url: cdk::mint_url::MintUrl,
@@ -240,10 +473,10 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
         old_mint_url: cdk::mint_url::MintUrl,
         new_mint_url: cdk::mint_url::MintUrl,
     ) -> Result<(), cdk::cdk_database::Error> {
-        let ffi_old = old_mint_url.into();
-        let ffi_new = new_mint_url.into();
+        let ffi_old_mint_url = old_mint_url.into();
+        let ffi_new_mint_url = new_mint_url.into();
         self.ffi_db
-            .update_mint_url(ffi_old, ffi_new)
+            .update_mint_url(ffi_old_mint_url, ffi_new_mint_url)
             .await
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
@@ -254,26 +487,13 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
         keysets: Vec<cdk::nuts::KeySetInfo>,
     ) -> Result<(), cdk::cdk_database::Error> {
         let ffi_mint_url = mint_url.into();
-        let ffi_keysets = keysets.into_iter().map(Into::into).collect();
+        let ffi_keysets: Vec<KeySetInfo> = keysets.into_iter().map(Into::into).collect();
         self.ffi_db
             .add_mint_keysets(ffi_mint_url, ffi_keysets)
             .await
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
 
-    async fn get_mint_quote(
-        &mut self,
-        quote_id: &str,
-    ) -> Result<Option<cdk::wallet::MintQuote>, cdk::cdk_database::Error> {
-        self.ffi_db
-            .get_mint_quote(quote_id.to_string())
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?
-            .map(|q| q.try_into())
-            .transpose()
-            .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
-    }
-
     async fn add_mint_quote(
         &mut self,
         quote: cdk::wallet::MintQuote,
@@ -292,19 +512,6 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
 
-    async fn get_melt_quote(
-        &mut self,
-        quote_id: &str,
-    ) -> Result<Option<cdk::wallet::MeltQuote>, cdk::cdk_database::Error> {
-        self.ffi_db
-            .get_melt_quote(quote_id.to_string())
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?
-            .map(|q| q.try_into())
-            .transpose()
-            .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
-    }
-
     async fn add_melt_quote(
         &mut self,
         quote: cdk::wallet::MeltQuote,
@@ -327,64 +534,19 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
         &mut self,
         keyset: cdk::nuts::KeySet,
     ) -> Result<(), cdk::cdk_database::Error> {
-        let ffi_keyset = keyset.into();
-        self.ffi_db
-            .add_keys(ffi_keyset)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
-    }
-
-    async fn remove_keys(&mut self, id: &cdk::nuts::Id) -> Result<(), cdk::cdk_database::Error> {
-        let ffi_id = (*id).into();
-        self.ffi_db
-            .remove_keys(ffi_id)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
-    }
-
-    async fn get_proofs(
-        &mut self,
-        mint_url: Option<cdk::mint_url::MintUrl>,
-        unit: Option<cdk::nuts::CurrencyUnit>,
-        state: Option<Vec<cdk::nuts::State>>,
-        spending_conditions: Option<Vec<cdk::nuts::SpendingConditions>>,
-    ) -> Result<Vec<cdk::types::ProofInfo>, cdk::cdk_database::Error> {
-        let ffi_mint_url = mint_url.map(Into::into);
-        let ffi_unit = unit.map(Into::into);
-        let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect());
-        let ffi_spending = spending_conditions.map(|sc| sc.into_iter().map(Into::into).collect());
-
-        let result = self
-            .ffi_db
-            .get_proofs(ffi_mint_url, ffi_unit, ffi_state, ffi_spending)
+        let ffi_keyset: KeySet = keyset.into();
+        self.ffi_db
+            .add_keys(ffi_keyset)
             .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    }
 
-        result
-            .into_iter()
-            .map(|info| {
-                Ok::<cdk::types::ProofInfo, cdk::cdk_database::Error>(cdk::types::ProofInfo {
-                    proof: info.proof.try_into().map_err(|e: FfiError| {
-                        cdk::cdk_database::Error::Database(e.to_string().into())
-                    })?,
-                    y: info.y.try_into().map_err(|e: FfiError| {
-                        cdk::cdk_database::Error::Database(e.to_string().into())
-                    })?,
-                    mint_url: info.mint_url.try_into().map_err(|e: FfiError| {
-                        cdk::cdk_database::Error::Database(e.to_string().into())
-                    })?,
-                    state: info.state.into(),
-                    spending_condition: info
-                        .spending_condition
-                        .map(|sc| sc.try_into())
-                        .transpose()
-                        .map_err(|e: FfiError| {
-                            cdk::cdk_database::Error::Database(e.to_string().into())
-                        })?,
-                    unit: info.unit.into(),
-                })
-            })
-            .collect()
+    async fn remove_keys(&mut self, id: &cdk::nuts::Id) -> Result<(), cdk::cdk_database::Error> {
+        let ffi_id = (*id).into();
+        self.ffi_db
+            .remove_keys(ffi_id)
+            .await
+            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
 
     async fn update_proofs(
@@ -392,10 +554,10 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
         added: Vec<cdk::types::ProofInfo>,
         removed_ys: Vec<cdk::nuts::PublicKey>,
     ) -> Result<(), cdk::cdk_database::Error> {
-        let ffi_added = added.into_iter().map(Into::into).collect();
-        let ffi_removed = removed_ys.into_iter().map(Into::into).collect();
+        let ffi_added: Vec<ProofInfo> = added.into_iter().map(Into::into).collect();
+        let ffi_removed_ys: Vec<PublicKey> = removed_ys.into_iter().map(Into::into).collect();
         self.ffi_db
-            .update_proofs(ffi_added, ffi_removed)
+            .update_proofs(ffi_added, ffi_removed_ys)
             .await
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
@@ -405,7 +567,7 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
         ys: Vec<cdk::nuts::PublicKey>,
         state: cdk::nuts::State,
     ) -> Result<(), cdk::cdk_database::Error> {
-        let ffi_ys = ys.into_iter().map(Into::into).collect();
+        let ffi_ys: Vec<PublicKey> = ys.into_iter().map(Into::into).collect();
         let ffi_state = state.into();
         self.ffi_db
             .update_proofs_state(ffi_ys, ffi_state)
@@ -413,19 +575,6 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
 
-    async fn get_keyset_by_id(
-        &mut self,
-        keyset_id: &cdk::nuts::Id,
-    ) -> Result<Option<cdk::nuts::KeySetInfo>, cdk::cdk_database::Error> {
-        let ffi_id = (*keyset_id).into();
-        let result = self
-            .ffi_db
-            .get_keyset_by_id(ffi_id)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-        Ok(result.map(Into::into))
-    }
-
     async fn increment_keyset_counter(
         &mut self,
         keyset_id: &cdk::nuts::Id,
@@ -442,9 +591,9 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
         &mut self,
         transaction: cdk::wallet::types::Transaction,
     ) -> Result<(), cdk::cdk_database::Error> {
-        let ffi_tx = transaction.into();
+        let ffi_transaction = transaction.into();
         self.ffi_db
-            .add_transaction(ffi_tx)
+            .add_transaction(ffi_transaction)
             .await
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
@@ -460,113 +609,42 @@ impl<'a> cdk::cdk_database::WalletDatabaseTransaction<'a, cdk::cdk_database::Err
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
     }
 
-    async fn get_mint(
+    // Read methods needed during transactions
+    async fn get_keyset_by_id(
         &mut self,
-        mint_url: cdk::mint_url::MintUrl,
-    ) -> Result<Option<cdk::nuts::MintInfo>, cdk::cdk_database::Error> {
-        let ffi_mint_url = mint_url.into();
-        let result = self
-            .ffi_db
-            .get_mint(ffi_mint_url)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-
-        Ok(result.map(Into::into))
-    }
-}
-
-#[async_trait::async_trait]
-impl cdk::cdk_database::DbTransactionFinalizer for WalletDatabaseTransactionBridge {
-    type Err = cdk::cdk_database::Error;
-
-    async fn commit(self: Box<Self>) -> Result<(), cdk::cdk_database::Error> {
-        // No-op: FFI operations are atomic and auto-commit
-        Ok(())
-    }
-
-    async fn rollback(self: Box<Self>) -> Result<(), cdk::cdk_database::Error> {
-        // No-op: FFI operations are atomic
-        Ok(())
-    }
-}
-
-#[async_trait::async_trait]
-impl CdkWalletDatabase for WalletDatabaseBridge {
-    type Err = cdk::cdk_database::Error;
-
-    async fn begin_db_transaction<'a>(
-        &'a self,
-    ) -> Result<
-        Box<dyn cdk::cdk_database::WalletDatabaseTransaction<'a, Self::Err> + Send + Sync + 'a>,
-        cdk::cdk_database::Error,
-    > {
-        Ok(Box::new(WalletDatabaseTransactionBridge {
-            ffi_db: Arc::clone(&self.ffi_db),
-        }))
-    }
-
-    async fn get_mint(
-        &self,
-        mint_url: cdk::mint_url::MintUrl,
-    ) -> Result<Option<cdk::nuts::MintInfo>, Self::Err> {
-        let ffi_mint_url = mint_url.into();
+        keyset_id: &cdk::nuts::Id,
+    ) -> Result<Option<cdk::nuts::KeySetInfo>, cdk::cdk_database::Error> {
+        let ffi_id = (*keyset_id).into();
         let result = self
             .ffi_db
-            .get_mint(ffi_mint_url)
+            .get_keyset_by_id(ffi_id)
             .await
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
         Ok(result.map(Into::into))
     }
 
-    async fn get_mints(
-        &self,
-    ) -> Result<HashMap<cdk::mint_url::MintUrl, Option<cdk::nuts::MintInfo>>, Self::Err> {
+    async fn get_keys(
+        &mut self,
+        id: &cdk::nuts::Id,
+    ) -> Result<Option<cdk::nuts::Keys>, cdk::cdk_database::Error> {
+        let ffi_id = (*id).into();
         let result = self
             .ffi_db
-            .get_mints()
+            .get_keys(ffi_id)
             .await
             .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-
-        let mut cdk_result = HashMap::new();
-        for (ffi_mint_url, mint_info_opt) in result {
-            let cdk_url = ffi_mint_url
-                .try_into()
-                .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-            cdk_result.insert(cdk_url, mint_info_opt.map(Into::into));
+        match result {
+            Some(keys) => Ok(Some(keys.try_into().map_err(|e: FfiError| {
+                cdk::cdk_database::Error::Database(e.to_string().into())
+            })?)),
+            None => Ok(None),
         }
-        Ok(cdk_result)
-    }
-
-    async fn get_mint_keysets(
-        &self,
-        mint_url: cdk::mint_url::MintUrl,
-    ) -> Result<Option<Vec<cdk::nuts::KeySetInfo>>, Self::Err> {
-        let ffi_mint_url = mint_url.into();
-        let result = self
-            .ffi_db
-            .get_mint_keysets(ffi_mint_url)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-        Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect()))
-    }
-
-    async fn get_keyset_by_id(
-        &self,
-        keyset_id: &cdk::nuts::Id,
-    ) -> Result<Option<cdk::nuts::KeySetInfo>, Self::Err> {
-        let ffi_id = (*keyset_id).into();
-        let result = self
-            .ffi_db
-            .get_keyset_by_id(ffi_id)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-        Ok(result.map(Into::into))
     }
 
     async fn get_mint_quote(
-        &self,
+        &mut self,
         quote_id: &str,
-    ) -> Result<Option<cdk::wallet::MintQuote>, Self::Err> {
+    ) -> Result<Option<cdk::wallet::MintQuote>, cdk::cdk_database::Error> {
         let result = self
             .ffi_db
             .get_mint_quote(quote_id.to_string())
@@ -580,25 +658,10 @@ impl CdkWalletDatabase for WalletDatabaseBridge {
             .transpose()?)
     }
 
-    async fn get_mint_quotes(&self) -> Result<Vec<cdk::wallet::MintQuote>, Self::Err> {
-        let result = self
-            .ffi_db
-            .get_mint_quotes()
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-        Ok(result
-            .into_iter()
-            .map(|q| {
-                q.try_into()
-                    .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
-            })
-            .collect::<Result<Vec<_>, _>>()?)
-    }
-
     async fn get_melt_quote(
-        &self,
+        &mut self,
         quote_id: &str,
-    ) -> Result<Option<cdk::wallet::MeltQuote>, Self::Err> {
+    ) -> Result<Option<cdk::wallet::MeltQuote>, cdk::cdk_database::Error> {
         let result = self
             .ffi_db
             .get_melt_quote(quote_id.to_string())
@@ -612,46 +675,13 @@ impl CdkWalletDatabase for WalletDatabaseBridge {
             .transpose()?)
     }
 
-    async fn get_melt_quotes(&self) -> Result<Vec<cdk::wallet::MeltQuote>, Self::Err> {
-        let result = self
-            .ffi_db
-            .get_melt_quotes()
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-        Ok(result
-            .into_iter()
-            .map(|q| {
-                q.try_into()
-                    .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
-            })
-            .collect::<Result<Vec<_>, _>>()?)
-    }
-
-    async fn get_keys(&self, id: &cdk::nuts::Id) -> Result<Option<cdk::nuts::Keys>, Self::Err> {
-        let ffi_id: Id = (*id).into();
-        let result = self
-            .ffi_db
-            .get_keys(ffi_id)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-
-        // Convert FFI Keys back to CDK Keys using TryFrom
-        result
-            .map(|ffi_keys| {
-                ffi_keys
-                    .try_into()
-                    .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
-            })
-            .transpose()
-    }
-
     async fn get_proofs(
-        &self,
+        &mut self,
         mint_url: Option<cdk::mint_url::MintUrl>,
         unit: Option<cdk::nuts::CurrencyUnit>,
         state: Option<Vec<cdk::nuts::State>>,
         spending_conditions: Option<Vec<cdk::nuts::SpendingConditions>>,
-    ) -> Result<Vec<cdk::types::ProofInfo>, Self::Err> {
+    ) -> Result<Vec<cdk::types::ProofInfo>, cdk::cdk_database::Error> {
         let ffi_mint_url = mint_url.map(Into::into);
         let ffi_unit = unit.map(Into::into);
         let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect());
@@ -693,61 +723,20 @@ impl CdkWalletDatabase for WalletDatabaseBridge {
 
         cdk_result
     }
+}
 
-    async fn get_balance(
-        &self,
-        mint_url: Option<cdk::mint_url::MintUrl>,
-        unit: Option<cdk::nuts::CurrencyUnit>,
-        state: Option<Vec<cdk::nuts::State>>,
-    ) -> Result<u64, Self::Err> {
-        let ffi_mint_url = mint_url.map(Into::into);
-        let ffi_unit = unit.map(Into::into);
-        let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect());
-
-        self.ffi_db
-            .get_balance(ffi_mint_url, ffi_unit, ffi_state)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
-    }
-
-    async fn get_transaction(
-        &self,
-        transaction_id: cdk::wallet::types::TransactionId,
-    ) -> Result<Option<cdk::wallet::types::Transaction>, Self::Err> {
-        let ffi_id = transaction_id.into();
-        let result = self
-            .ffi_db
-            .get_transaction(ffi_id)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
+#[async_trait::async_trait]
+impl DbTransactionFinalizer for WalletDatabaseTransactionBridge {
+    type Err = cdk::cdk_database::Error;
 
-        result
-            .map(|tx| tx.try_into())
-            .transpose()
-            .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))
+    async fn commit(self: Box<Self>) -> Result<(), cdk::cdk_database::Error> {
+        // FFI databases handle transactions internally, no-op here
+        Ok(())
     }
 
-    async fn list_transactions(
-        &self,
-        mint_url: Option<cdk::mint_url::MintUrl>,
-        direction: Option<cdk::wallet::types::TransactionDirection>,
-        unit: Option<cdk::nuts::CurrencyUnit>,
-    ) -> Result<Vec<cdk::wallet::types::Transaction>, Self::Err> {
-        let ffi_mint_url = mint_url.map(Into::into);
-        let ffi_direction = direction.map(Into::into);
-        let ffi_unit = unit.map(Into::into);
-
-        let result = self
-            .ffi_db
-            .list_transactions(ffi_mint_url, ffi_direction, ffi_unit)
-            .await
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?;
-
-        result
-            .into_iter()
-            .map(|tx| tx.try_into())
-            .collect::<Result<Vec<_>, FfiError>>()
-            .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))
+    async fn rollback(self: Box<Self>) -> Result<(), cdk::cdk_database::Error> {
+        // FFI databases handle transactions internally, no-op here
+        Ok(())
     }
 }
 

+ 0 - 8
crates/cdk-ffi/src/error.rs

@@ -122,11 +122,3 @@ impl From<serde_json::Error> for FfiError {
         }
     }
 }
-
-impl From<cdk::cdk_database::Error> for FfiError {
-    fn from(err: cdk::cdk_database::Error) -> Self {
-        FfiError::Database {
-            msg: err.to_string(),
-        }
-    }
-}

+ 15 - 30
crates/cdk-ffi/src/postgres.rs

@@ -55,8 +55,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.add_mint(cdk_mint_url, cdk_mint_info)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -70,8 +69,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.remove_mint(cdk_mint_url)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -110,8 +108,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.update_mint_url(cdk_old_mint_url, cdk_new_mint_url)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -130,8 +127,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.add_mint_keysets(cdk_mint_url, cdk_keysets)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -169,8 +165,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.add_mint_quote(cdk_quote)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -202,8 +197,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.remove_mint_quote(&quote_id)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -219,8 +213,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.add_melt_quote(cdk_quote)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -252,8 +245,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.remove_melt_quote(&quote_id)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -270,8 +262,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.add_keys(cdk_keyset)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -296,8 +287,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.remove_keys(&cdk_id)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -339,8 +329,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.update_proofs(cdk_added, cdk_removed_ys)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -407,8 +396,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.update_proofs_state(cdk_ys, cdk_state)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -425,8 +413,7 @@ impl WalletDatabase for WalletPostgresDatabase {
             .increment_keyset_counter(&cdk_id, count)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
         Ok(result)
@@ -445,8 +432,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.add_transaction(cdk_transaction)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -493,8 +479,7 @@ impl WalletDatabase for WalletPostgresDatabase {
         tx.remove_transaction(cdk_id)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }

+ 15 - 30
crates/cdk-ffi/src/sqlite.rs

@@ -87,8 +87,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.add_mint(cdk_mint_url, cdk_mint_info)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -103,8 +102,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.remove_mint(cdk_mint_url)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -146,8 +144,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.update_mint_url(cdk_old_mint_url, cdk_new_mint_url)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -168,8 +165,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.add_mint_keysets(cdk_mint_url, cdk_keysets)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -208,8 +204,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.add_mint_quote(cdk_quote)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -241,8 +236,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.remove_mint_quote(&quote_id)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -258,8 +252,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.add_melt_quote(cdk_quote)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -291,8 +284,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.remove_melt_quote(&quote_id)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -309,8 +301,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.add_keys(cdk_keyset)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -335,8 +326,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.remove_keys(&cdk_id)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -378,8 +368,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.update_proofs(cdk_added, cdk_removed_ys)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -446,8 +435,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.update_proofs_state(cdk_ys, cdk_state)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -464,8 +452,7 @@ impl WalletDatabase for WalletSqliteDatabase {
             .increment_keyset_counter(&cdk_id, count)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
         Ok(result)
@@ -484,8 +471,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.add_transaction(cdk_transaction)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })
     }
@@ -532,8 +518,7 @@ impl WalletDatabase for WalletSqliteDatabase {
         tx.remove_transaction(cdk_id)
             .await
             .map_err(|e| FfiError::Database { msg: e.to_string() })?;
-        Box::new(tx)
-            .commit()
+        tx.commit()
             .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::*;

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

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

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

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

+ 2 - 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};
@@ -65,44 +64,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 +133,7 @@ pub async fn create_invoice_for_env(amount_sat: Option<u64>) -> Result<String> {
     }
 }
 
-// This is the ln wallet we use to send/receive ln payements as the wallet
+// This is the ln wallet we use to send/receive ln payments as the wallet
 async fn _get_lnd_client() -> LndClient {
     let temp_dir = get_work_dir();
 

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

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

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

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

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

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

+ 203 - 46
crates/cdk-integration-tests/tests/fake_wallet.rs

@@ -69,15 +69,13 @@ async fn test_fake_tokens_pending() {
 
     assert!(melt.is_err());
 
-    assert!(
-        wallet
-            .localstore
-            .get_proofs(None, None, Some(vec![State::Pending]), None)
-            .await
-            .expect("no an error")
-            .is_empty(),
-        "Database rollback removes all pending proofs"
-    );
+    // 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,
@@ -133,15 +131,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,
@@ -182,15 +173,12 @@ async fn test_fake_melt_payment_fail_and_check() {
     let melt = wallet.melt(&melt_quote.id).await;
     assert!(melt.is_err());
 
-    assert!(
-        wallet
-            .localstore
-            .get_proofs(None, None, Some(vec![State::Pending]), None)
-            .await
-            .expect("no an error")
-            .is_empty(),
-        "Database rollback removes all pending proofs"
-    );
+    assert!(!wallet
+        .localstore
+        .get_proofs(None, None, Some(vec![State::Pending]), None)
+        .await
+        .unwrap()
+        .is_empty());
 }
 
 /// Tests that when the ln backend returns a failed status but does not error,
@@ -231,6 +219,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,
@@ -246,13 +244,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,
@@ -291,7 +290,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,
@@ -306,15 +305,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,
@@ -340,6 +338,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,
@@ -352,18 +352,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!(
-        wallet
-            .localstore
-            .get_proofs(None, None, Some(vec![State::Pending]), None)
-            .await
-            .expect("no an error")
-            .is_empty(),
-        "Database rollback removes all pending proofs"
+    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")
     );
+
+    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
@@ -1390,3 +1395,155 @@ 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 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));
+
+    // 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 the proofs were swapped (different Ys)
+    let recovered_proofs = wallet.get_unspent_proofs().await.unwrap();
+    let recovered_ys: Vec<_> = recovered_proofs.iter().map(|p| p.y().unwrap()).collect();
+
+    // The Ys should be different (swapped to new proofs)
+    assert!(
+        initial_ys.iter().any(|y| !recovered_ys.contains(y)),
+        "Proofs should have been swapped to new ones"
+    );
+
+    // 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"
+    );
+}

+ 59 - 1
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
@@ -753,6 +766,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
@@ -766,11 +801,34 @@ async fn test_mint_change_with_fee_melt() {
         .unwrap();
 
     let w = wallet_alice
-        .melt_proofs_with_metadata(&melt_quote.id, proofs, HashMap::new())
+        .melt_proofs(&melt_quote.id, proofs)
         .await
         .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

+ 2 - 5
crates/cdk-integration-tests/tests/test_fees.rs

@@ -1,4 +1,3 @@
-use std::collections::HashMap;
 use std::str::FromStr;
 use std::sync::Arc;
 
@@ -109,14 +108,12 @@ async fn test_fake_melt_change_in_quote() {
     let proofs_total = proofs.total_amount().unwrap();
 
     let fee = wallet.get_proofs_fee(&proofs).await.unwrap();
-
     let melt = wallet
-        .melt_proofs_with_metadata(&melt_quote.id, proofs, HashMap::new())
+        .melt_proofs(&melt_quote.id, proofs.clone())
         .await
         .unwrap();
-
     let change = melt.change.unwrap().total_amount().unwrap();
-    let idk = proofs_total - Amount::from(invoice_amount) - change;
+    let idk = proofs.total_amount().unwrap() - Amount::from(invoice_amount) - change;
 
     println!("{}", idk);
     println!("{}", fee);

+ 300 - 1
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:
@@ -815,7 +861,7 @@ async fn test_swap_state_transition_notifications() {
             cashu::NotificationPayload::ProofState(cashu::ProofState { y, state, .. }) => {
                 state_transitions
                     .entry(y.to_string())
-                    .or_insert_with(Vec::new)
+                    .or_default()
                     .push(state);
             }
             _ => panic!("Unexpected notification type"),
@@ -901,3 +947,256 @@ 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, 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
+}

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

@@ -20,6 +20,6 @@ tokio.workspace = true
 tokio-util.workspace = true
 tracing.workspace = true
 thiserror.workspace = true
-lnbits-rs = "0.8.0"
+lnbits-rs = "0.9.1"
 serde_json.workspace = true
 rustls.workspace = true

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

+ 53 - 18
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::{
@@ -163,23 +163,57 @@ impl MintPayment for LNbits {
         let is_active = Arc::clone(&self.wait_invoice_is_active);
 
         Ok(Box::pin(futures::stream::unfold(
-            (api, cancel_token, is_active),
-            |(api, cancel_token, is_active)| async move {
+            (api, cancel_token, is_active, 0u32),
+            |(api, cancel_token, is_active, mut retry_count)| async move {
                 is_active.store(true, Ordering::SeqCst);
 
-                let receiver = api.receiver();
-                let mut receiver = receiver.lock().await;
-
-                tokio::select! {
-                    _ = cancel_token.cancelled() => {
-                        is_active.store(false, Ordering::SeqCst);
-                        tracing::info!("Waiting for lnbits invoice ending");
-                        None
-                    }
-                    msg_option = receiver.recv() => {
-                        Self::process_message(msg_option, &api, &is_active)
-                            .await
-                            .map(|response| (Event::PaymentReceived(response), (api, cancel_token, is_active)))
+                loop {
+                    tracing::debug!("LNbits: Starting wait loop, attempting to get receiver");
+                    let receiver = api.receiver();
+                    let mut receiver = receiver.lock().await;
+                    tracing::debug!("LNbits: Got receiver lock, waiting for messages");
+
+                    tokio::select! {
+                        _ = cancel_token.cancelled() => {
+                            is_active.store(false, Ordering::SeqCst);
+                            tracing::info!("Waiting for lnbits invoice ending");
+                            return None;
+                        }
+                        msg_option = receiver.recv() => {
+                            tracing::debug!("LNbits: Received message from websocket: {:?}", msg_option.as_ref().map(|_| "Some(message)"));
+                            match msg_option {
+                                Some(_) => {
+                                    // Successfully received a message, reset retry count
+                                    retry_count = 0;
+                                    let result = Self::process_message(msg_option, &api, &is_active).await;
+                                    return result.map(|response| {
+                                        (Event::PaymentReceived(response), (api, cancel_token, is_active, retry_count))
+                                    });
+                                }
+                                None => {
+                                    // Connection lost, need to reconnect
+                                    drop(receiver); // Drop the lock before reconnecting
+
+                                    tracing::warn!("LNbits websocket connection lost (receiver returned None), attempting to reconnect...");
+
+                                    // Exponential backoff: 1s, 2s, 4s, 8s, max 10s
+                                    let backoff_secs = std::cmp::min(2u64.pow(retry_count), 10);
+                                    tracing::info!("Retrying in {} seconds (attempt {})", backoff_secs, retry_count + 1);
+                                    tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
+
+                                    // Attempt to resubscribe
+                                    if let Err(err) = api.subscribe_to_websocket().await {
+                                        tracing::error!("Failed to resubscribe to LNbits websocket: {:?}", err);
+                                    } else {
+                                        tracing::info!("Successfully reconnected to LNbits websocket");
+                                    }
+
+                                    retry_count += 1;
+                                    // Continue the loop to try again
+                                    continue;
+                                }
+                            }
+                        }
                     }
                 }
             },
@@ -210,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);
 
@@ -219,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)
 

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

+ 121 - 197
crates/cdk-redb/src/wallet/mod.rs

@@ -16,9 +16,7 @@ use cdk_common::{
     database, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions,
     State,
 };
-use redb::{
-    Database, MultimapTableDefinition, ReadableMultimapTable, ReadableTable, TableDefinition,
-};
+use redb::{Database, MultimapTableDefinition, ReadableTable, TableDefinition};
 use tracing::instrument;
 
 use super::error::Error;
@@ -487,19 +485,28 @@ impl WalletDatabase for WalletRedbDatabase {
 impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
     for RedbWalletTransaction
 {
-    #[instrument(skip(self))]
-    async fn get_mint(&mut self, mint_url: MintUrl) -> Result<Option<MintInfo>, database::Error> {
+    #[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(MINTS_TABLE).map_err(Error::from)?;
+        let table = txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
 
-        if let Some(mint_info) = table
-            .get(mint_url.to_string().as_str())
+        let result = match table
+            .get(keyset_id.to_bytes().as_slice())
             .map_err(Error::from)?
         {
-            return Ok(serde_json::from_str(mint_info.value()).map_err(Error::from)?);
-        }
+            Some(keyset) => {
+                let keyset: KeySetInfo =
+                    serde_json::from_str(keyset.value()).map_err(Error::from)?;
 
-        Ok(None)
+                Ok(Some(keyset))
+            }
+            None => Ok(None),
+        };
+
+        result
     }
 
     #[instrument(skip(self), fields(keyset_id = %keyset_id))]
@@ -518,50 +525,12 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
     }
 
     #[instrument(skip(self))]
-    async fn get_mint_keysets(
-        &mut self,
-        mint_url: MintUrl,
-    ) -> Result<Option<Vec<KeySetInfo>>, database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
-        let table = txn
-            .open_multimap_table(MINT_KEYSETS_TABLE)
-            .map_err(Error::from)?;
-
-        let keyset_ids = table
-            .get(mint_url.to_string().as_str())
-            .map_err(Error::from)?
-            .flatten()
-            .map(|k| Id::from_bytes(k.value()))
-            .collect::<Result<Vec<_>, _>>()?;
-
-        let mut keysets = vec![];
-
-        let keysets_t = txn.open_table(KEYSETS_TABLE).map_err(Error::from)?;
-
-        for keyset_id in keyset_ids {
-            if let Some(keyset) = keysets_t
-                .get(keyset_id.to_bytes().as_slice())
-                .map_err(Error::from)?
-            {
-                let keyset = serde_json::from_str(keyset.value()).map_err(Error::from)?;
-
-                keysets.push(keyset);
-            }
-        }
-
-        match keysets.is_empty() {
-            true => Ok(None),
-            false => Ok(Some(keysets)),
-        }
-    }
-
-    #[instrument(skip(self, mint_info))]
     async fn add_mint(
         &mut self,
         mint_url: MintUrl,
         mint_info: Option<MintInfo>,
     ) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let txn = self.txn()?;
         let mut table = txn.open_table(MINTS_TABLE).map_err(Error::from)?;
         table
             .insert(
@@ -576,7 +545,7 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
 
     #[instrument(skip(self))]
     async fn remove_mint(&mut self, mint_url: MintUrl) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let txn = self.txn()?;
         let mut table = txn.open_table(MINTS_TABLE).map_err(Error::from)?;
         table
             .remove(mint_url.to_string().as_str())
@@ -590,71 +559,68 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         old_mint_url: MintUrl,
         new_mint_url: MintUrl,
     ) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
-
-        // Get all proofs with old mint URL
-        let proofs_table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
-        let proofs: Vec<ProofInfo> = proofs_table
-            .iter()
-            .map_err(Error::from)?
-            .flatten()
-            .filter_map(|(_k, v)| {
-                if let Ok(proof_info) = serde_json::from_str::<ProofInfo>(v.value()) {
-                    if proof_info.matches_conditions(
-                        &Some(old_mint_url.clone()),
-                        &None,
-                        &None,
-                        &None,
-                    ) {
-                        return Some(proof_info);
-                    }
-                }
-                None
-            })
-            .collect();
-
-        // Update proofs
-        drop(proofs_table);
-        let mut proofs_table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
-        for mut proof_info in proofs {
-            proof_info.mint_url = new_mint_url.clone();
-            proofs_table
-                .insert(
-                    proof_info.y.to_bytes().as_slice(),
-                    serde_json::to_string(&proof_info)
-                        .map_err(Error::from)?
-                        .as_str(),
-                )
+        // 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_table = txn.open_table(MINT_QUOTES_TABLE).map_err(Error::from)?;
-        let unix_time = unix_time();
-        let quotes: Vec<MintQuote> = quotes_table
-            .iter()
-            .map_err(Error::from)?
-            .flatten()
-            .filter_map(|(_id, quote)| {
-                if let Ok(mut q) = serde_json::from_str::<MintQuote>(quote.value()) {
-                    if q.mint_url == old_mint_url && q.expiry >= unix_time {
+        {
+            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();
-                        return Some(q);
+                        Some(q)
+                    } else {
+                        None
                     }
-                }
-                None
-            })
-            .collect();
+                })
+                .collect::<Vec<_>>();
 
-        drop(quotes_table);
-        let mut quotes_table = txn.open_table(MINT_QUOTES_TABLE).map_err(Error::from)?;
-        for quote in quotes {
-            quotes_table
-                .insert(
-                    quote.id.as_str(),
-                    serde_json::to_string(&quote).map_err(Error::from)?.as_str(),
-                )
-                .map_err(Error::from)?;
+            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(())
@@ -666,15 +632,15 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         mint_url: MintUrl,
         keysets: Vec<KeySetInfo>,
     ) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
-
-        let mut existing_u32 = false;
+        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 existing_u32 = false;
+
         for keyset in keysets {
             // Check if keyset already exists
             let existing_keyset = {
@@ -732,18 +698,19 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         }
 
         if existing_u32 {
+            tracing::warn!("Keyset already exists for keyset id");
             return Err(database::Error::Duplicate);
         }
 
         Ok(())
     }
 
-    #[instrument(skip(self))]
+    #[instrument(skip_all)]
     async fn get_mint_quote(
         &mut self,
         quote_id: &str,
     ) -> Result<Option<MintQuote>, database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        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)? {
@@ -753,9 +720,9 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         Ok(None)
     }
 
-    #[instrument(skip(self))]
+    #[instrument(skip_all)]
     async fn add_mint_quote(&mut self, quote: MintQuote) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let txn = self.txn()?;
         let mut table = txn.open_table(MINT_QUOTES_TABLE).map_err(Error::from)?;
         table
             .insert(
@@ -766,20 +733,20 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         Ok(())
     }
 
-    #[instrument(skip(self))]
+    #[instrument(skip_all)]
     async fn remove_mint_quote(&mut self, quote_id: &str) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        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(self))]
+    #[instrument(skip_all)]
     async fn get_melt_quote(
         &mut self,
         quote_id: &str,
     ) -> Result<Option<wallet::MeltQuote>, database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        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)? {
@@ -789,9 +756,9 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         Ok(None)
     }
 
-    #[instrument(skip(self))]
+    #[instrument(skip_all)]
     async fn add_melt_quote(&mut self, quote: wallet::MeltQuote) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let txn = self.txn()?;
         let mut table = txn.open_table(MELT_QUOTES_TABLE).map_err(Error::from)?;
         table
             .insert(
@@ -802,9 +769,9 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         Ok(())
     }
 
-    #[instrument(skip(self))]
+    #[instrument(skip_all)]
     async fn remove_melt_quote(&mut self, quote_id: &str) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        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(())
@@ -812,9 +779,10 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
 
     #[instrument(skip_all)]
     async fn add_keys(&mut self, keyset: KeySet) -> Result<(), database::Error> {
+        let txn = self.txn()?;
+
         keyset.verify_id()?;
 
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
         let mut table = txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?;
 
         let existing_keys = table
@@ -842,23 +810,26 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         };
 
         if existing_keys || existing_u32 {
+            tracing::warn!("Keys already exist for keyset id");
             return Err(database::Error::Duplicate);
         }
 
         Ok(())
     }
 
-    #[instrument(skip(self), fields(keyset_id = %id))]
-    async fn remove_keys(&mut self, id: &Id) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+    #[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(id.to_string().as_str()).map_err(Error::from)?;
+        table
+            .remove(keyset_id.to_string().as_str())
+            .map_err(Error::from)?;
 
         Ok(())
     }
 
-    #[instrument(skip(self))]
+    #[instrument(skip_all)]
     async fn get_proofs(
         &mut self,
         mint_url: Option<MintUrl>,
@@ -866,7 +837,7 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         state: Option<Vec<State>>,
         spending_conditions: Option<Vec<SpendingConditions>>,
     ) -> Result<Vec<ProofInfo>, database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let txn = self.txn()?;
         let table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
 
         let proofs: Vec<ProofInfo> = table
@@ -890,13 +861,13 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         Ok(proofs)
     }
 
-    #[instrument(skip(self, added, removed_ys))]
+    #[instrument(skip(self, added, deleted_ys))]
     async fn update_proofs(
         &mut self,
         added: Vec<ProofInfo>,
-        removed_ys: Vec<PublicKey>,
+        deleted_ys: Vec<PublicKey>,
     ) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let txn = self.txn()?;
         let mut table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
 
         for proof_info in added.iter() {
@@ -910,24 +881,20 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
                 .map_err(Error::from)?;
         }
 
-        for y in removed_ys.iter() {
+        for y in deleted_ys.iter() {
             table.remove(y.to_bytes().as_slice()).map_err(Error::from)?;
         }
 
         Ok(())
     }
 
-    #[instrument(skip(self, ys))]
     async fn update_proofs_state(
         &mut self,
         ys: Vec<PublicKey>,
         state: State,
     ) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
-
-        // First read all proofs
-        let table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
-        let mut proofs_to_update = Vec::new();
+        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();
@@ -938,15 +905,10 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
 
             let mut proof_info =
                 serde_json::from_str::<ProofInfo>(proof.value()).map_err(Error::from)?;
+            drop(proof);
 
             proof_info.state = state;
-            proofs_to_update.push((y_slice, proof_info));
-        }
 
-        // Now update them
-        drop(table);
-        let mut table = txn.open_table(PROOFS_TABLE).map_err(Error::from)?;
-        for (y_slice, proof_info) in proofs_to_update {
             table
                 .insert(
                     y_slice.as_slice(),
@@ -961,59 +923,24 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
     }
 
     #[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 increment_keyset_counter(
         &mut self,
         keyset_id: &Id,
         count: u32,
     ) -> Result<u32, database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
-
-        let current_counter;
-        {
-            let table = 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,
-            };
-        }
+        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 new_counter = current_counter + count;
 
-        {
-            let mut table = txn.open_table(KEYSET_COUNTER).map_err(Error::from)?;
-
-            table
-                .insert(keyset_id.to_string().as_str(), new_counter)
-                .map_err(Error::from)?;
-        }
+        table
+            .insert(keyset_id.to_string().as_str(), new_counter)
+            .map_err(Error::from)?;
 
         Ok(new_counter)
     }
@@ -1021,8 +948,7 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
     #[instrument(skip(self))]
     async fn add_transaction(&mut self, transaction: Transaction) -> Result<(), database::Error> {
         let id = transaction.id();
-
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        let txn = self.txn()?;
         let mut table = txn.open_table(TRANSACTIONS_TABLE).map_err(Error::from)?;
         table
             .insert(
@@ -1032,7 +958,6 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
                     .as_str(),
             )
             .map_err(Error::from)?;
-
         Ok(())
     }
 
@@ -1041,12 +966,11 @@ impl<'a> cdk_common::database::WalletDatabaseTransaction<'a, database::Error>
         &mut self,
         transaction_id: TransactionId,
     ) -> Result<(), database::Error> {
-        let txn = self.txn().map_err(Into::<database::Error>::into)?;
+        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)?;
-
         Ok(())
     }
 }

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

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

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

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

@@ -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())
     }
 
@@ -1618,6 +1635,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 +1734,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 +1774,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 +1969,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 +2227,7 @@ where
                 state,
                 blinded_secrets,
                 input_ys,
+                quote_id,
                 created_at,
                 updated_at
             FROM
@@ -2166,17 +2248,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 +2266,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 +2333,7 @@ where
                 state,
                 blinded_secrets,
                 input_ys,
+                quote_id,
                 created_at,
                 updated_at
             FROM
@@ -2479,6 +2563,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 +2637,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 +2645,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 +2684,7 @@ fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
         state,
         blinded_secrets,
         input_ys,
+        quote_id,
         created_at,
         updated_at,
     })

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

File diff ditekan karena terlalu besar
+ 428 - 488
crates/cdk-sql-common/src/wallet/mod.rs


+ 5 - 1
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());

+ 4 - 0
crates/cdk/Cargo.toml

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

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

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

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

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

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

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

+ 10 - 6
crates/cdk/src/lib.rs

@@ -9,16 +9,16 @@ compile_error!("The 'tor' feature is not supported on wasm32 targets (browser).
 
 pub mod cdk_database {
     //! CDK Database
+    pub use cdk_common::database::Error;
     #[cfg(all(feature = "mint", feature = "auth"))]
     pub use cdk_common::database::MintAuthDatabase;
-    pub use cdk_common::database::{DbTransactionFinalizer, Error};
+    #[cfg(feature = "wallet")]
+    pub use cdk_common::database::WalletDatabase;
     #[cfg(feature = "mint")]
     pub use cdk_common::database::{
         MintDatabase, MintKVStore, MintKVStoreDatabase, MintKVStoreTransaction, MintKeysDatabase,
         MintProofsDatabase, MintQuotesDatabase, MintSignaturesDatabase, MintTransaction,
     };
-    #[cfg(feature = "wallet")]
-    pub use cdk_common::database::{WalletDatabase, WalletDatabaseTransaction};
 }
 
 #[cfg(feature = "mint")]
@@ -26,9 +26,15 @@ pub mod mint;
 #[cfg(feature = "wallet")]
 pub mod wallet;
 
+#[cfg(test)]
+mod test_helpers;
+
 #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))]
 mod bip353;
 
+#[cfg(feature = "wallet")]
+mod lightning_address;
+
 #[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))]
 mod oidc_client;
 
@@ -48,9 +54,7 @@ pub use oidc_client::OidcClient;
 #[cfg(any(feature = "wallet", feature = "mint"))]
 pub mod event;
 pub mod fees;
-
-#[cfg(test)]
-pub mod test_helpers;
+pub mod invoice;
 
 #[doc(hidden)]
 pub use bitcoin::secp256k1;

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

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

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

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

+ 31 - 6
crates/cdk/src/mint/ln.rs

@@ -1,17 +1,28 @@
+use std::collections::HashMap;
+use std::sync::Arc;
+
 use cdk_common::amount::to_unit;
 use cdk_common::common::PaymentProcessorKey;
+use cdk_common::database::DynMintDatabase;
 use cdk_common::mint::MintQuote;
+use cdk_common::payment::DynMintPayment;
 use cdk_common::util::unix_time;
 use cdk_common::{Amount, MintQuoteState, PaymentMethod};
 use tracing::instrument;
 
+use super::subscription::PubSubManager;
 use super::Mint;
 use crate::Error;
 
 impl Mint {
-    /// Check the status of an ln payment for a quote
-    #[instrument(skip_all)]
-    pub async fn check_mint_quote_paid(&self, quote: &mut MintQuote) -> Result<(), Error> {
+    /// Static implementation of check_mint_quote_paid to avoid circular dependency to the Mint
+    #[inline(always)]
+    pub(crate) async fn check_mint_quote_payments(
+        localstore: DynMintDatabase,
+        payment_processors: Arc<HashMap<PaymentProcessorKey, DynMintPayment>>,
+        pubsub_manager: Option<Arc<PubSubManager>>,
+        quote: &mut MintQuote,
+    ) -> Result<(), Error> {
         let state = quote.state();
 
         // We can just return here and do not need to check with ln node.
@@ -23,7 +34,7 @@ impl Mint {
             return Ok(());
         }
 
-        let ln = match self.payment_processors.get(&PaymentProcessorKey::new(
+        let ln = match payment_processors.get(&PaymentProcessorKey::new(
             quote.unit.clone(),
             quote.payment_method.clone(),
         )) {
@@ -43,7 +54,7 @@ impl Mint {
             return Ok(());
         }
 
-        let mut tx = self.localstore.begin_transaction().await?;
+        let mut tx = localstore.begin_transaction().await?;
 
         // reload the quote, as it state may have changed
         *quote = tx
@@ -79,7 +90,9 @@ impl Mint {
                     .increment_mint_quote_amount_paid(&quote.id, amount_paid, payment.payment_id)
                     .await?;
 
-                self.pubsub_manager.mint_quote_payment(quote, total_paid);
+                if let Some(pubsub_manager) = pubsub_manager.as_ref() {
+                    pubsub_manager.mint_quote_payment(quote, total_paid);
+                }
             }
         }
 
@@ -87,4 +100,16 @@ impl Mint {
 
         Ok(())
     }
+
+    /// Check the status of an ln payment for a quote
+    #[instrument(skip_all)]
+    pub async fn check_mint_quote_paid(&self, quote: &mut MintQuote) -> Result<(), Error> {
+        Self::check_mint_quote_payments(
+            self.localstore.clone(),
+            self.payment_processors.clone(),
+            Some(self.pubsub_manager.clone()),
+            quote,
+        )
+        .await
+    }
 }

+ 0 - 1076
crates/cdk/src/mint/melt.rs

@@ -1,1076 +0,0 @@
-use std::str::FromStr;
-
-use anyhow::bail;
-use cdk_common::amount::amount_for_offer;
-use cdk_common::database::mint::MeltRequestInfo;
-use cdk_common::database::{self, MintTransaction};
-use cdk_common::melt::MeltQuoteRequest;
-use cdk_common::mint::{MeltPaymentRequest, Operation};
-use cdk_common::nut05::MeltMethodOptions;
-use cdk_common::payment::{
-    Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, DynMintPayment,
-    OutgoingPaymentOptions, PaymentIdentifier,
-};
-use cdk_common::quote_id::QuoteId;
-use cdk_common::{MeltOptions, MeltQuoteBolt12Request};
-#[cfg(feature = "prometheus")]
-use cdk_prometheus::METRICS;
-use lightning::offers::offer::Offer;
-use tracing::instrument;
-
-use super::{
-    CurrencyUnit, MeltQuote, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, Mint,
-    PaymentMethod, PublicKey, State,
-};
-use crate::amount::to_unit;
-use crate::cdk_payment::MakePaymentResponse;
-use crate::mint::proof_writer::ProofWriter;
-use crate::mint::verification::Verification;
-use crate::mint::SigFlag;
-use crate::nuts::nut11::{enforce_sig_flag, EnforceSigFlag};
-use crate::nuts::MeltQuoteState;
-use crate::types::PaymentProcessorKey;
-use crate::util::unix_time;
-use crate::{cdk_payment, ensure_cdk, Amount, Error};
-
-impl Mint {
-    #[instrument(skip_all)]
-    async fn check_melt_request_acceptable(
-        &self,
-        amount: Amount,
-        unit: CurrencyUnit,
-        method: PaymentMethod,
-        request: String,
-        options: Option<MeltOptions>,
-    ) -> Result<(), Error> {
-        let mint_info = self.mint_info().await?;
-        let nut05 = mint_info.nuts.nut05;
-
-        ensure_cdk!(!nut05.disabled, Error::MeltingDisabled);
-
-        let settings = nut05
-            .get_settings(&unit, &method)
-            .ok_or(Error::UnsupportedUnit)?;
-
-        let amount = match options {
-            Some(MeltOptions::Mpp { mpp: _ }) => {
-                let nut15 = mint_info.nuts.nut15;
-                // Verify there is no corresponding mint quote.
-                // Otherwise a wallet is trying to pay someone internally, but
-                // with a multi-part quote. And that's just not possible.
-                if (self.localstore.get_mint_quote_by_request(&request).await?).is_some() {
-                    return Err(Error::InternalMultiPartMeltQuote);
-                }
-                // Verify MPP is enabled for unit and method
-                if !nut15
-                    .methods
-                    .into_iter()
-                    .any(|m| m.method == method && m.unit == unit)
-                {
-                    return Err(Error::MppUnitMethodNotSupported(unit, method));
-                }
-                // Assign `amount`
-                // because should have already been converted to the partial amount
-                amount
-            }
-            Some(MeltOptions::Amountless { amountless: _ }) => {
-                if method == PaymentMethod::Bolt11
-                    && !matches!(
-                        settings.options,
-                        Some(MeltMethodOptions::Bolt11 { amountless: true })
-                    )
-                {
-                    return Err(Error::AmountlessInvoiceNotSupported(unit, method));
-                }
-
-                amount
-            }
-            None => amount,
-        };
-
-        let is_above_max = matches!(settings.max_amount, Some(max) if amount > max);
-        let is_below_min = matches!(settings.min_amount, Some(min) if amount < min);
-        match is_above_max || is_below_min {
-            true => {
-                tracing::error!(
-                    "Melt amount out of range: {} is not within {} and {}",
-                    amount,
-                    settings.min_amount.unwrap_or_default(),
-                    settings.max_amount.unwrap_or_default(),
-                );
-                Err(Error::AmountOutofLimitRange(
-                    settings.min_amount.unwrap_or_default(),
-                    settings.max_amount.unwrap_or_default(),
-                    amount,
-                ))
-            }
-            false => Ok(()),
-        }
-    }
-
-    /// Get melt quote for either BOLT11 or BOLT12
-    ///
-    /// This function accepts a `MeltQuoteRequest` enum and delegates to the
-    /// appropriate handler based on the request type.
-    #[instrument(skip_all)]
-    pub async fn get_melt_quote(
-        &self,
-        melt_quote_request: MeltQuoteRequest,
-    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
-        match melt_quote_request {
-            MeltQuoteRequest::Bolt11(bolt11_request) => {
-                self.get_melt_bolt11_quote_impl(&bolt11_request).await
-            }
-            MeltQuoteRequest::Bolt12(bolt12_request) => {
-                self.get_melt_bolt12_quote_impl(&bolt12_request).await
-            }
-        }
-    }
-
-    /// Implementation of get_melt_bolt11_quote
-    #[instrument(skip_all)]
-    async fn get_melt_bolt11_quote_impl(
-        &self,
-        melt_request: &MeltQuoteBolt11Request,
-    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
-        #[cfg(feature = "prometheus")]
-        METRICS.inc_in_flight_requests("get_melt_bolt11_quote");
-        let MeltQuoteBolt11Request {
-            request,
-            unit,
-            options,
-            ..
-        } = melt_request;
-
-        let ln = self
-            .payment_processors
-            .get(&PaymentProcessorKey::new(
-                unit.clone(),
-                PaymentMethod::Bolt11,
-            ))
-            .ok_or_else(|| {
-                tracing::info!("Could not get ln backend for {}, bolt11 ", unit);
-
-                Error::UnsupportedUnit
-            })?;
-
-        let bolt11 = Bolt11OutgoingPaymentOptions {
-            bolt11: melt_request.request.clone(),
-            max_fee_amount: None,
-            timeout_secs: None,
-            melt_options: melt_request.options,
-        };
-
-        let payment_quote = ln
-            .get_payment_quote(
-                &melt_request.unit,
-                OutgoingPaymentOptions::Bolt11(Box::new(bolt11)),
-            )
-            .await
-            .map_err(|err| {
-                tracing::error!(
-                    "Could not get payment quote for mint quote, {} bolt11, {}",
-                    unit,
-                    err
-                );
-
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("get_melt_bolt11_quote");
-                    METRICS.record_mint_operation("get_melt_bolt11_quote", false);
-                    METRICS.record_error();
-                }
-                Error::UnsupportedUnit
-            })?;
-
-        if &payment_quote.unit != unit {
-            return Err(Error::UnitMismatch);
-        }
-
-        // Validate using processor quote amount for currency conversion
-        self.check_melt_request_acceptable(
-            payment_quote.amount,
-            unit.clone(),
-            PaymentMethod::Bolt11,
-            request.to_string(),
-            *options,
-        )
-        .await?;
-
-        let melt_ttl = self.quote_ttl().await?.melt_ttl;
-
-        let quote = MeltQuote::new(
-            MeltPaymentRequest::Bolt11 {
-                bolt11: request.clone(),
-            },
-            unit.clone(),
-            payment_quote.amount,
-            payment_quote.fee,
-            unix_time() + melt_ttl,
-            payment_quote.request_lookup_id.clone(),
-            *options,
-            PaymentMethod::Bolt11,
-        );
-
-        tracing::debug!(
-            "New {} melt quote {} for {} {} with request id {:?}",
-            quote.payment_method,
-            quote.id,
-            payment_quote.amount,
-            unit,
-            payment_quote.request_lookup_id
-        );
-
-        let mut tx = self.localstore.begin_transaction().await?;
-        tx.add_melt_quote(quote.clone()).await?;
-        tx.commit().await?;
-
-        Ok(quote.into())
-    }
-
-    /// Implementation of get_melt_bolt12_quote
-    #[instrument(skip_all)]
-    async fn get_melt_bolt12_quote_impl(
-        &self,
-        melt_request: &MeltQuoteBolt12Request,
-    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
-        let MeltQuoteBolt12Request {
-            request,
-            unit,
-            options,
-        } = melt_request;
-
-        let offer = Offer::from_str(request).map_err(|_| Error::InvalidPaymentRequest)?;
-
-        let amount = match options {
-            Some(options) => match options {
-                MeltOptions::Amountless { amountless } => {
-                    to_unit(amountless.amount_msat, &CurrencyUnit::Msat, unit)?
-                }
-                _ => return Err(Error::UnsupportedUnit),
-            },
-            None => amount_for_offer(&offer, unit).map_err(|_| Error::UnsupportedUnit)?,
-        };
-
-        let ln = self
-            .payment_processors
-            .get(&PaymentProcessorKey::new(
-                unit.clone(),
-                PaymentMethod::Bolt12,
-            ))
-            .ok_or_else(|| {
-                tracing::info!("Could not get ln backend for {}, bolt12 ", unit);
-
-                Error::UnsupportedUnit
-            })?;
-
-        let offer = Offer::from_str(&melt_request.request).map_err(|_| Error::Bolt12parse)?;
-
-        let outgoing_payment_options = Bolt12OutgoingPaymentOptions {
-            offer: offer.clone(),
-            max_fee_amount: None,
-            timeout_secs: None,
-            melt_options: *options,
-        };
-
-        let payment_quote = ln
-            .get_payment_quote(
-                &melt_request.unit,
-                OutgoingPaymentOptions::Bolt12(Box::new(outgoing_payment_options)),
-            )
-            .await
-            .map_err(|err| {
-                tracing::error!(
-                    "Could not get payment quote for mint quote, {} bolt12, {}",
-                    unit,
-                    err
-                );
-
-                Error::UnsupportedUnit
-            })?;
-
-        if &payment_quote.unit != unit {
-            return Err(Error::UnitMismatch);
-        }
-
-        // Validate using processor quote amount for currency conversion
-        self.check_melt_request_acceptable(
-            payment_quote.amount,
-            unit.clone(),
-            PaymentMethod::Bolt12,
-            request.clone(),
-            *options,
-        )
-        .await?;
-
-        let payment_request = MeltPaymentRequest::Bolt12 {
-            offer: Box::new(offer),
-        };
-
-        let quote = MeltQuote::new(
-            payment_request,
-            unit.clone(),
-            payment_quote.amount,
-            payment_quote.fee,
-            unix_time() + self.quote_ttl().await?.melt_ttl,
-            payment_quote.request_lookup_id.clone(),
-            *options,
-            PaymentMethod::Bolt12,
-        );
-
-        tracing::debug!(
-            "New {} melt quote {} for {} {} with request id {:?}",
-            quote.payment_method,
-            quote.id,
-            amount,
-            unit,
-            payment_quote.request_lookup_id
-        );
-
-        let mut tx = self.localstore.begin_transaction().await?;
-        tx.add_melt_quote(quote.clone()).await?;
-        tx.commit().await?;
-
-        #[cfg(feature = "prometheus")]
-        {
-            METRICS.dec_in_flight_requests("get_melt_bolt11_quote");
-            METRICS.record_mint_operation("get_melt_bolt11_quote", true);
-        }
-
-        Ok(quote.into())
-    }
-
-    /// Check melt quote status
-    #[instrument(skip(self))]
-    pub async fn check_melt_quote(
-        &self,
-        quote_id: &QuoteId,
-    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
-        #[cfg(feature = "prometheus")]
-        METRICS.inc_in_flight_requests("check_melt_quote");
-        let quote = match self.localstore.get_melt_quote(quote_id).await {
-            Ok(Some(quote)) => quote,
-            Ok(None) => {
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("check_melt_quote");
-                    METRICS.record_mint_operation("check_melt_quote", false);
-                    METRICS.record_error();
-                }
-                return Err(Error::UnknownQuote);
-            }
-            Err(err) => {
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("check_melt_quote");
-                    METRICS.record_mint_operation("check_melt_quote", false);
-                    METRICS.record_error();
-                }
-                return Err(err.into());
-            }
-        };
-
-        let blind_signatures = match self
-            .localstore
-            .get_blind_signatures_for_quote(quote_id)
-            .await
-        {
-            Ok(signatures) => signatures,
-            Err(err) => {
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("check_melt_quote");
-                    METRICS.record_mint_operation("check_melt_quote", false);
-                    METRICS.record_error();
-                }
-                return Err(err.into());
-            }
-        };
-
-        let change = (!blind_signatures.is_empty()).then_some(blind_signatures);
-
-        let response = MeltQuoteBolt11Response {
-            quote: quote.id,
-            paid: Some(quote.state == MeltQuoteState::Paid),
-            state: quote.state,
-            expiry: quote.expiry,
-            amount: quote.amount,
-            fee_reserve: quote.fee_reserve,
-            payment_preimage: quote.payment_preimage,
-            change,
-            request: Some(quote.request.to_string()),
-            unit: Some(quote.unit.clone()),
-        };
-
-        #[cfg(feature = "prometheus")]
-        {
-            METRICS.dec_in_flight_requests("check_melt_quote");
-            METRICS.record_mint_operation("check_melt_quote", true);
-        }
-
-        Ok(response)
-    }
-
-    /// Get melt quotes
-    #[instrument(skip_all)]
-    pub async fn melt_quotes(&self) -> Result<Vec<MeltQuote>, Error> {
-        let quotes = self.localstore.get_melt_quotes().await?;
-        Ok(quotes)
-    }
-
-    /// Check melt has expected fees
-    #[instrument(skip_all)]
-    pub async fn check_melt_expected_ln_fees(
-        &self,
-        melt_quote: &MeltQuote,
-        melt_request: &MeltRequest<QuoteId>,
-    ) -> Result<Option<Amount>, Error> {
-        let quote_msats = to_unit(melt_quote.amount, &melt_quote.unit, &CurrencyUnit::Msat)
-            .expect("Quote unit is checked above that it can convert to msat");
-
-        let invoice_amount_msats = match &melt_quote.request {
-            MeltPaymentRequest::Bolt11 { bolt11 } => match bolt11.amount_milli_satoshis() {
-                Some(amount) => amount.into(),
-                None => melt_quote
-                    .options
-                    .ok_or(Error::InvoiceAmountUndefined)?
-                    .amount_msat(),
-            },
-            MeltPaymentRequest::Bolt12 { offer } => match offer.amount() {
-                Some(amount) => {
-                    let (amount, currency) = match amount {
-                        lightning::offers::offer::Amount::Bitcoin { amount_msats } => {
-                            (amount_msats, CurrencyUnit::Msat)
-                        }
-                        lightning::offers::offer::Amount::Currency {
-                            iso4217_code,
-                            amount,
-                        } => (
-                            amount,
-                            CurrencyUnit::from_str(&String::from_utf8(iso4217_code.to_vec())?)?,
-                        ),
-                    };
-
-                    to_unit(amount, &currency, &CurrencyUnit::Msat)
-                        .map_err(|_err| Error::UnsupportedUnit)?
-                }
-                None => melt_quote
-                    .options
-                    .ok_or(Error::InvoiceAmountUndefined)?
-                    .amount_msat(),
-            },
-        };
-
-        let partial_amount = match invoice_amount_msats > quote_msats {
-            true => Some(
-                to_unit(quote_msats, &CurrencyUnit::Msat, &melt_quote.unit)
-                    .map_err(|_| Error::UnsupportedUnit)?,
-            ),
-            false => None,
-        };
-
-        let amount_to_pay = match partial_amount {
-            Some(amount_to_pay) => amount_to_pay,
-            None => to_unit(invoice_amount_msats, &CurrencyUnit::Msat, &melt_quote.unit)
-                .map_err(|_| Error::UnsupportedUnit)?,
-        };
-
-        let inputs_amount_quote_unit = melt_request.inputs_amount().map_err(|_| {
-            tracing::error!("Proof inputs in melt quote overflowed");
-            Error::AmountOverflow
-        })?;
-
-        if amount_to_pay + melt_quote.fee_reserve > inputs_amount_quote_unit {
-            tracing::debug!(
-                "Not enough inputs provided: {} {} needed {} {}",
-                inputs_amount_quote_unit,
-                melt_quote.unit,
-                amount_to_pay,
-                melt_quote.unit
-            );
-
-            return Err(Error::TransactionUnbalanced(
-                inputs_amount_quote_unit.into(),
-                amount_to_pay.into(),
-                melt_quote.fee_reserve.into(),
-            ));
-        }
-
-        Ok(partial_amount)
-    }
-
-    /// Verify melt request is valid
-    #[instrument(skip_all)]
-    pub async fn verify_melt_request(
-        &self,
-        tx: &mut Box<dyn MintTransaction<'_, database::Error> + Send + Sync + '_>,
-        input_verification: Verification,
-        melt_request: &MeltRequest<QuoteId>,
-        operation: &Operation,
-    ) -> Result<(ProofWriter, MeltQuote), Error> {
-        let Verification {
-            amount: input_amount,
-            unit: input_unit,
-        } = input_verification;
-
-        let mut proof_writer =
-            ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone());
-
-        proof_writer
-            .add_proofs(
-                tx,
-                melt_request.inputs(),
-                Some(melt_request.quote_id().to_owned()),
-                operation,
-            )
-            .await?;
-
-        // Only after proof verification succeeds, proceed with quote state check
-        let (state, quote) = tx
-            .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None)
-            .await?;
-
-        if input_unit != Some(quote.unit.clone()) {
-            return Err(Error::UnitMismatch);
-        }
-
-        match state {
-            MeltQuoteState::Unpaid | MeltQuoteState::Failed => Ok(()),
-            MeltQuoteState::Pending => Err(Error::PendingQuote),
-            MeltQuoteState::Paid => Err(Error::PaidQuote),
-            MeltQuoteState::Unknown => Err(Error::UnknownPaymentState),
-        }?;
-
-        self.pubsub_manager
-            .melt_quote_status(&quote, None, None, MeltQuoteState::Pending);
-
-        let fee = self.get_proofs_fee(melt_request.inputs()).await?;
-
-        let required_total = quote.amount + quote.fee_reserve + fee;
-
-        // Check that the inputs proofs are greater then total.
-        // Transaction does not need to be balanced as wallet may not want change.
-        if input_amount < required_total {
-            tracing::info!(
-                "Swap request unbalanced: {}, outputs {}, fee {}",
-                input_amount,
-                quote.amount,
-                fee
-            );
-            return Err(Error::TransactionUnbalanced(
-                input_amount.into(),
-                quote.amount.into(),
-                (fee + quote.fee_reserve).into(),
-            ));
-        }
-
-        let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(melt_request.inputs().clone());
-
-        if sig_flag == SigFlag::SigAll {
-            melt_request.verify_sig_all()?;
-        }
-
-        if let Some(outputs) = &melt_request.outputs() {
-            if !outputs.is_empty() {
-                let Verification {
-                    amount: _,
-                    unit: output_unit,
-                } = self.verify_outputs(tx, outputs).await?;
-
-                ensure_cdk!(input_unit == output_unit, Error::UnsupportedUnit);
-            }
-        }
-
-        tracing::debug!("Verified melt quote: {}", melt_request.quote());
-        Ok((proof_writer, quote))
-    }
-
-    /// Melt Bolt11
-    #[instrument(skip_all)]
-    pub async fn melt(
-        &self,
-        melt_request: &MeltRequest<QuoteId>,
-    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
-        #[cfg(feature = "prometheus")]
-        METRICS.inc_in_flight_requests("melt_bolt11");
-
-        use std::sync::Arc;
-        async fn check_payment_state(
-            ln: DynMintPayment,
-            lookup_id: &PaymentIdentifier,
-        ) -> anyhow::Result<MakePaymentResponse> {
-            match ln.check_outgoing_payment(lookup_id).await {
-                Ok(response) => Ok(response),
-                Err(check_err) => {
-                    // If we cannot check the status of the payment we keep the proofs stuck as pending.
-                    tracing::error!(
-                        "Could not check the status of payment for {},. Proofs stuck as pending",
-                        lookup_id
-                    );
-                    tracing::error!("Checking payment error: {}", check_err);
-                    bail!("Could not check payment status")
-                }
-            }
-        }
-
-        let verification = self.verify_inputs(melt_request.inputs()).await?;
-
-        let melt_operation = Operation::new_melt();
-        let mut tx = self.localstore.begin_transaction().await?;
-
-        let (proof_writer, quote) = match self
-            .verify_melt_request(&mut tx, verification, melt_request, &melt_operation)
-            .await
-        {
-            Ok(result) => result,
-            Err(err) => {
-                tracing::debug!("Error attempting to verify melt quote: {}", err);
-
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("melt_bolt11");
-                    METRICS.record_mint_operation("melt_bolt11", false);
-                    METRICS.record_error();
-                }
-
-                return Err(err);
-            }
-        };
-
-        let inputs_fee = self.get_proofs_fee(melt_request.inputs()).await?;
-
-        tx.add_melt_request(
-            melt_request.quote_id(),
-            melt_request.inputs_amount()?,
-            inputs_fee,
-        )
-        .await?;
-
-        tx.add_blinded_messages(
-            Some(melt_request.quote_id()),
-            melt_request.outputs().as_ref().unwrap_or(&Vec::new()),
-            &melt_operation,
-        )
-        .await?;
-
-        let settled_internally_amount = match self
-            .handle_internal_melt_mint(&mut tx, &quote, melt_request)
-            .await
-        {
-            Ok(amount) => amount,
-            Err(err) => {
-                tracing::error!("Attempting to settle internally failed: {}", err);
-
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("melt_bolt11");
-                    METRICS.record_mint_operation("melt_bolt11", false);
-                    METRICS.record_error();
-                }
-
-                return Err(err);
-            }
-        };
-
-        let (tx, preimage, amount_spent_quote_unit, quote) = match settled_internally_amount {
-            Some(amount_spent) => (tx, None, amount_spent, quote),
-
-            None => {
-                // If the quote unit is SAT or MSAT we can check that the expected fees are
-                // provided. We also check if the quote is less then the invoice
-                // amount in the case that it is a mmp However, if the quote is not
-                // of a bitcoin unit we cannot do these checks as the mint
-                // is unaware of a conversion rate. In this case it is assumed that the quote is
-                // correct and the mint should pay the full invoice amount if inputs
-                // > `then quote.amount` are included. This is checked in the
-                // `verify_melt` method.
-                let _partial_amount = match quote.unit {
-                    CurrencyUnit::Sat | CurrencyUnit::Msat => {
-                        match self.check_melt_expected_ln_fees(&quote, melt_request).await {
-                            Ok(amount) => amount,
-                            Err(err) => {
-                                tracing::error!("Fee is not expected: {}", err);
-                                return Err(Error::Internal);
-                            }
-                        }
-                    }
-                    _ => None,
-                };
-
-                let ln = match self.payment_processors.get(&PaymentProcessorKey::new(
-                    quote.unit.clone(),
-                    quote.payment_method.clone(),
-                )) {
-                    Some(ln) => ln,
-                    None => {
-                        tracing::info!("Could not get ln backend for {}, bolt11 ", quote.unit);
-                        return Err(Error::UnsupportedUnit);
-                    }
-                };
-
-                // Commit before talking to the external call
-                tx.commit().await?;
-
-                let pre = match ln
-                    .make_payment(&quote.unit, quote.clone().try_into()?)
-                    .await
-                {
-                    Ok(pay)
-                        if pay.status == MeltQuoteState::Unknown
-                            || pay.status == MeltQuoteState::Failed =>
-                    {
-                        tracing::warn!("Got {} status when paying melt quote {} for {} {}. Checking with backend...", pay.status, quote.id, quote.amount, quote.unit);
-                        let check_response = if let Ok(ok) =
-                            check_payment_state(Arc::clone(ln), &pay.payment_lookup_id).await
-                        {
-                            ok
-                        } else {
-                            return Err(Error::Internal);
-                        };
-
-                        if check_response.status == MeltQuoteState::Paid {
-                            tracing::warn!("Pay invoice returned {} but check returned {}. Proofs stuck as pending", pay.status.to_string(), check_response.status.to_string());
-
-                            proof_writer.commit();
-
-                            return Err(Error::Internal);
-                        }
-
-                        check_response
-                    }
-                    Ok(pay) => pay,
-                    Err(err) => {
-                        // If the error is that the invoice was already paid we do not want to hold
-                        // hold the proofs as pending to we reset them  and return an error.
-                        if matches!(err, cdk_payment::Error::InvoiceAlreadyPaid) {
-                            tracing::debug!("Invoice already paid, resetting melt quote");
-                            return Err(Error::RequestAlreadyPaid);
-                        }
-
-                        tracing::error!("Error returned attempting to pay: {} {}", quote.id, err);
-
-                        let lookup_id = quote.request_lookup_id.as_ref().ok_or_else(|| {
-                            tracing::error!(
-                                "No payment id could not lookup payment for {} after error.",
-                                quote.id
-                            );
-                            Error::Internal
-                        })?;
-
-                        let check_response =
-                            if let Ok(ok) = check_payment_state(Arc::clone(ln), lookup_id).await {
-                                ok
-                            } else {
-                                proof_writer.commit();
-                                return Err(Error::Internal);
-                            };
-                        // If there error is something else we want to check the status of the payment ensure it is not pending or has been made.
-                        if check_response.status == MeltQuoteState::Paid {
-                            tracing::warn!("Pay invoice returned an error but check returned {}. Proofs stuck as pending", check_response.status.to_string());
-                            proof_writer.commit();
-                            return Err(Error::Internal);
-                        }
-                        check_response
-                    }
-                };
-
-                match pre.status {
-                    MeltQuoteState::Paid => (),
-                    MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => {
-                        tracing::info!(
-                            "Lightning payment for quote {} failed.",
-                            melt_request.quote()
-                        );
-                        proof_writer.rollback().await?;
-
-                        #[cfg(feature = "prometheus")]
-                        {
-                            METRICS.dec_in_flight_requests("melt_bolt11");
-                            METRICS.record_mint_operation("melt_bolt11", false);
-                            METRICS.record_error();
-                        }
-
-                        return Err(Error::PaymentFailed);
-                    }
-                    MeltQuoteState::Pending => {
-                        tracing::warn!(
-                            "LN payment pending, proofs are stuck as pending for quote: {}",
-                            melt_request.quote()
-                        );
-                        proof_writer.commit();
-                        #[cfg(feature = "prometheus")]
-                        {
-                            METRICS.dec_in_flight_requests("melt_bolt11");
-                            METRICS.record_mint_operation("melt_bolt11", false);
-                            METRICS.record_error();
-                        }
-
-                        return Err(Error::PendingQuote);
-                    }
-                }
-
-                // Convert from unit of backend to quote unit
-                // Note: this should never fail since these conversions happen earlier and would fail there.
-                // Since it will not fail and even if it does the ln payment has already been paid, proofs should still be burned
-                let amount_spent =
-                    to_unit(pre.total_spent, &pre.unit, &quote.unit).unwrap_or_default();
-
-                let payment_lookup_id = pre.payment_lookup_id;
-                let mut tx = self.localstore.begin_transaction().await?;
-
-                if Some(payment_lookup_id.clone()).as_ref() != quote.request_lookup_id.as_ref() {
-                    tracing::info!(
-                        "Payment lookup id changed post payment from {:?} to {}",
-                        &quote.request_lookup_id,
-                        payment_lookup_id
-                    );
-
-                    let mut melt_quote = quote;
-                    melt_quote.request_lookup_id = Some(payment_lookup_id.clone());
-
-                    if let Err(err) = tx
-                        .update_melt_quote_request_lookup_id(&melt_quote.id, &payment_lookup_id)
-                        .await
-                    {
-                        tracing::warn!("Could not update payment lookup id: {}", err);
-                    }
-
-                    (tx, pre.payment_proof, amount_spent, melt_quote)
-                } else {
-                    (tx, pre.payment_proof, amount_spent, quote)
-                }
-            }
-        };
-
-        // If we made it here the payment has been made.
-        // We process the melt burning the inputs and returning change
-        let res = match self
-            .process_melt_request(tx, proof_writer, quote, preimage, amount_spent_quote_unit)
-            .await
-        {
-            Ok(response) => response,
-            Err(err) => {
-                tracing::error!("Could not process melt request: {}", err);
-
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("melt_bolt11");
-                    METRICS.record_mint_operation("melt_bolt11", false);
-                    METRICS.record_error();
-                }
-
-                return Err(err);
-            }
-        };
-
-        #[cfg(feature = "prometheus")]
-        {
-            METRICS.dec_in_flight_requests("melt_bolt11");
-            METRICS.record_mint_operation("melt_bolt11", true);
-        }
-
-        Ok(res)
-    }
-
-    /// Process melt request marking proofs as spent
-    /// The melt request must be verified using [`Self::verify_melt_request`]
-    /// before calling [`Self::process_melt_request`]
-    #[instrument(skip_all)]
-    pub async fn process_melt_request(
-        &self,
-        mut tx: Box<dyn MintTransaction<'_, database::Error> + Send + Sync + '_>,
-        mut proof_writer: ProofWriter,
-        quote: MeltQuote,
-        payment_preimage: Option<String>,
-        total_spent: Amount,
-    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
-        #[cfg(feature = "prometheus")]
-        METRICS.inc_in_flight_requests("process_melt_request");
-
-        // Try to get input_ys from the stored melt request, fall back to original request if not found
-        let input_ys: Vec<_> = tx.get_proof_ys_by_quote_id(&quote.id).await?;
-
-        assert!(!input_ys.is_empty());
-
-        tracing::debug!(
-            "Updating {} proof states to Spent for quote {}",
-            input_ys.len(),
-            quote.id
-        );
-
-        let update_proof_states_result = proof_writer
-            .update_proofs_states(&mut tx, &input_ys, State::Spent)
-            .await;
-
-        if update_proof_states_result.is_err() {
-            #[cfg(feature = "prometheus")]
-            self.record_melt_quote_failure("process_melt_request");
-            return Err(update_proof_states_result.err().unwrap());
-        }
-        tracing::debug!("Successfully updated proof states to Spent");
-
-        tx.update_melt_quote_state(&quote.id, MeltQuoteState::Paid, payment_preimage.clone())
-            .await?;
-
-        let mut change = None;
-
-        let MeltRequestInfo {
-            inputs_amount,
-            inputs_fee,
-            change_outputs,
-        } = tx
-            .get_melt_request_and_blinded_messages(&quote.id)
-            .await?
-            .ok_or(Error::UnknownQuote)?;
-
-        // Check if there is change to return
-        if inputs_amount > total_spent {
-            // Check if wallet provided change outputs
-            if !change_outputs.is_empty() {
-                let outputs = change_outputs;
-
-                let blinded_messages: Vec<PublicKey> =
-                    outputs.iter().map(|b| b.blinded_secret).collect();
-
-                if tx
-                    .get_blind_signatures(&blinded_messages)
-                    .await?
-                    .iter()
-                    .flatten()
-                    .next()
-                    .is_some()
-                {
-                    tracing::info!("Output has already been signed");
-
-                    return Err(Error::BlindedMessageAlreadySigned);
-                }
-
-                let change_target = inputs_amount - total_spent - inputs_fee;
-
-                let fee_and_amounts = self
-                    .keysets
-                    .load()
-                    .iter()
-                    .filter_map(|keyset| {
-                        if keyset.active && Some(keyset.id) == outputs.first().map(|x| x.keyset_id)
-                        {
-                            Some((keyset.input_fee_ppk, keyset.amounts.clone()).into())
-                        } else {
-                            None
-                        }
-                    })
-                    .next()
-                    .unwrap_or_else(|| {
-                        (0, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into()
-                    });
-
-                let mut amounts = change_target.split(&fee_and_amounts);
-
-                if outputs.len().lt(&amounts.len()) {
-                    tracing::debug!(
-                        "Providing change requires {} blinded messages, but only {} provided",
-                        amounts.len(),
-                        outputs.len()
-                    );
-
-                    // In the case that not enough outputs are provided to return all change
-                    // Reverse sort the amounts so that the most amount of change possible is
-                    // returned. The rest is burnt
-                    amounts.sort_by(|a, b| b.cmp(a));
-                }
-
-                let mut blinded_messages = vec![];
-
-                for (amount, mut blinded_message) in amounts.iter().zip(outputs.clone()) {
-                    blinded_message.amount = *amount;
-                    blinded_messages.push(blinded_message);
-                }
-
-                // commit db transaction before calling the signatory
-                tx.commit().await?;
-
-                let change_sigs = self.blind_sign(blinded_messages).await?;
-
-                let mut tx = self.localstore.begin_transaction().await?;
-
-                tx.add_blind_signatures(
-                    &outputs[0..change_sigs.len()]
-                        .iter()
-                        .map(|o| o.blinded_secret)
-                        .collect::<Vec<PublicKey>>(),
-                    &change_sigs,
-                    Some(quote.id.clone()),
-                )
-                .await?;
-
-                change = Some(change_sigs);
-
-                proof_writer.commit();
-
-                tx.delete_melt_request(&quote.id).await?;
-
-                tx.commit().await?;
-            } else {
-                tracing::info!(
-                    "Inputs for {} {} greater then spent on melt {} but change outputs not provided.",
-                    quote.id,
-                    inputs_amount,
-                    total_spent
-                );
-                proof_writer.commit();
-                tx.delete_melt_request(&quote.id).await?;
-                tx.commit().await?;
-            }
-        } else {
-            tracing::debug!("No change required for melt {}", quote.id);
-            proof_writer.commit();
-            tx.delete_melt_request(&quote.id).await?;
-            tx.commit().await?;
-        }
-
-        self.pubsub_manager.melt_quote_status(
-            &quote,
-            payment_preimage.clone(),
-            change.clone(),
-            MeltQuoteState::Paid,
-        );
-        tracing::debug!(
-            "Melt for quote {} completed total spent {}, total inputs: {}, change given: {}",
-            quote.id,
-            total_spent,
-            inputs_amount,
-            change
-                .as_ref()
-                .map(|c| Amount::try_sum(c.iter().map(|a| a.amount))
-                    .expect("Change cannot overflow"))
-                .unwrap_or_default()
-        );
-        let response = MeltQuoteBolt11Response {
-            amount: quote.amount,
-            paid: Some(true),
-            payment_preimage,
-            change,
-            quote: quote.id,
-            fee_reserve: quote.fee_reserve,
-            state: MeltQuoteState::Paid,
-            expiry: quote.expiry,
-            request: Some(quote.request.to_string()),
-            unit: Some(quote.unit.clone()),
-        };
-
-        #[cfg(feature = "prometheus")]
-        {
-            METRICS.dec_in_flight_requests("process_melt_request");
-            METRICS.record_mint_operation("process_melt_request", true);
-        }
-
-        Ok(response)
-    }
-
-    #[cfg(feature = "prometheus")]
-    fn record_melt_quote_failure(&self, operation: &str) {
-        METRICS.dec_in_flight_requests(operation);
-        METRICS.record_mint_operation(operation, false);
-        METRICS.record_error();
-    }
-}

+ 65 - 0
crates/cdk/src/mint/melt/melt_saga/compensation.rs

@@ -0,0 +1,65 @@
+//! Compensation actions for the melt saga pattern.
+//!
+//! When a saga step fails, compensating actions are executed in reverse order (LIFO)
+//! to undo all completed steps and restore the database to its pre-saga state.
+
+use async_trait::async_trait;
+use cdk_common::database::DynMintDatabase;
+use cdk_common::{Error, PublicKey, QuoteId};
+use tracing::instrument;
+
+/// Trait for compensating actions in the saga pattern.
+///
+/// Compensating actions are registered as steps complete and executed in reverse
+/// order (LIFO) if the saga fails. Each action should be idempotent.
+#[async_trait]
+pub trait CompensatingAction: Send + Sync {
+    async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error>;
+    fn name(&self) -> &'static str;
+}
+
+/// Compensation action to remove melt setup and reset quote state.
+///
+/// This compensation is used when payment fails or finalization fails after
+/// the setup transaction has committed. It removes:
+/// - Input proofs (identified by input_ys)
+/// - Output blinded messages (identified by blinded_secrets)
+/// - Melt request tracking record
+///
+///   And resets:
+/// - Quote state from Pending back to Unpaid
+///
+/// This restores the database to its pre-melt state, allowing the user to retry.
+pub struct RemoveMeltSetup {
+    /// Y values (public keys) from the input proofs
+    pub input_ys: Vec<PublicKey>,
+    /// Blinded secrets (B values) from the change output blinded messages
+    pub blinded_secrets: Vec<PublicKey>,
+    /// Quote ID to reset state
+    pub quote_id: QuoteId,
+}
+
+#[async_trait]
+impl CompensatingAction for RemoveMeltSetup {
+    #[instrument(skip_all)]
+    async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error> {
+        tracing::info!(
+            "Compensation: Removing melt setup for quote {} ({} proofs, {} blinded messages)",
+            self.quote_id,
+            self.input_ys.len(),
+            self.blinded_secrets.len()
+        );
+
+        super::super::shared::rollback_melt_quote(
+            db,
+            &self.quote_id,
+            &self.input_ys,
+            &self.blinded_secrets,
+        )
+        .await
+    }
+
+    fn name(&self) -> &'static str {
+        "RemoveMeltSetup"
+    }
+}

+ 909 - 0
crates/cdk/src/mint/melt/melt_saga/mod.rs

@@ -0,0 +1,909 @@
+use std::collections::VecDeque;
+use std::sync::Arc;
+
+use cdk_common::amount::to_unit;
+use cdk_common::database::mint::MeltRequestInfo;
+use cdk_common::database::DynMintDatabase;
+use cdk_common::mint::{MeltSagaState, Operation, Saga};
+use cdk_common::nuts::MeltQuoteState;
+use cdk_common::{Amount, Error, ProofsMethods, PublicKey, QuoteId, State};
+#[cfg(feature = "prometheus")]
+use cdk_prometheus::METRICS;
+use tokio::sync::Mutex;
+use tracing::instrument;
+
+use self::compensation::{CompensatingAction, RemoveMeltSetup};
+use self::state::{Initial, PaymentConfirmed, SettlementDecision, SetupComplete};
+use crate::cdk_payment::MakePaymentResponse;
+use crate::mint::subscription::PubSubManager;
+use crate::mint::verification::Verification;
+use crate::mint::{MeltQuoteBolt11Response, MeltRequest};
+
+mod compensation;
+mod state;
+
+#[cfg(test)]
+mod tests;
+
+/// Saga pattern implementation for atomic melt operations.
+///
+/// # Why Use the Saga Pattern for Melt?
+///
+/// The melt operation is more complex than swap because it involves:
+/// 1. Database transactions (setup and finalize)
+/// 2. External payment operations (Lightning Network)
+/// 3. Uncertain payment states (pending/unknown)
+/// 4. Change calculation based on actual payment amount
+///
+/// Traditional ACID transactions cannot span:
+/// 1. Multiple database transactions (TX1: setup, TX2: finalize)
+/// 2. External payment operations (LN backend calls)
+/// 3. Asynchronous payment confirmation
+///
+/// The saga pattern solves this by:
+/// - Breaking the operation into discrete steps with clear state transitions
+/// - Recording compensating actions for each forward step
+/// - Automatically rolling back via compensations if any step fails
+/// - Handling payment state uncertainty explicitly
+///
+/// # Transaction Boundaries
+///
+/// - **TX1 (setup_melt)**: Atomically verifies quote, adds input proofs (pending),
+///   adds change output blinded messages, creates melt request tracking record
+/// - **Payment (make_payment)**: Non-transactional external LN payment operation
+/// - **TX2 (finalize)**: Atomically updates quote state, marks inputs spent,
+///   signs change outputs, deletes tracking record
+///
+/// # Expected Flow
+///
+/// 1. **setup_melt**: Verifies and reserves inputs, prepares change outputs
+///    - Compensation: Removes inputs, outputs, resets quote state if later steps fail
+/// 2. **make_payment**: Calls LN backend to make payment
+///    - Triggers compensation if payment fails
+///    - Special handling for pending/unknown states
+/// 3. **finalize**: Commits the melt, issues change, marks complete
+///    - Triggers compensation if finalization fails
+///    - Clears compensations on success (melt complete)
+///
+/// # Failure Handling
+///
+/// If any step fails after setup_melt, all compensating actions are executed in reverse
+/// order to restore the database to its pre-melt state. This ensures no partial melts
+/// leave the system in an inconsistent state.
+///
+/// # Payment State Complexity
+///
+/// Unlike swap, melt must handle uncertain payment states:
+/// - **Paid**: Proceed to finalize
+/// - **Failed/Unpaid**: Compensate and return error
+/// - **Pending/Unknown**: Proofs remain pending, saga cannot complete
+///   (current behavior: leave proofs pending, return error for manual intervention)
+///
+/// # Typestate Pattern
+///
+/// This saga uses the **typestate pattern** to enforce state transitions at compile-time.
+/// Each state (Initial, SetupComplete, PaymentConfirmed) is a distinct type, and operations
+/// are only available on the appropriate type:
+///
+/// ```text
+/// MeltSaga<Initial>
+///   └─> setup_melt() -> MeltSaga<SetupComplete>
+///         ├─> attempt_internal_settlement() -> SettlementDecision (conditional)
+///         └─> make_payment(SettlementDecision) -> MeltSaga<PaymentConfirmed>
+///               └─> finalize() -> MeltQuoteBolt11Response
+/// ```
+///
+/// **Benefits:**
+/// - Invalid state transitions (e.g., `finalize()` before `make_payment()`) won't compile
+/// - State-specific data (e.g., payment_result) only exists in the appropriate state type
+/// - No runtime state checks or `Option<T>` unwrapping needed
+/// - IDE autocomplete only shows valid operations for each state
+pub struct MeltSaga<S> {
+    mint: Arc<super::Mint>,
+    db: DynMintDatabase,
+    pubsub: Arc<PubSubManager>,
+    /// Compensating actions in LIFO order (most recent first)
+    compensations: Arc<Mutex<VecDeque<Box<dyn CompensatingAction>>>>,
+    /// Operation for tracking
+    operation: Operation,
+    /// Tracks if metrics were incremented (for cleanup)
+    #[cfg(feature = "prometheus")]
+    metrics_incremented: bool,
+    /// State-specific data
+    state_data: S,
+}
+
+impl MeltSaga<Initial> {
+    pub fn new(mint: Arc<super::Mint>, db: DynMintDatabase, pubsub: Arc<PubSubManager>) -> Self {
+        #[cfg(feature = "prometheus")]
+        METRICS.inc_in_flight_requests("melt_bolt11");
+
+        Self {
+            mint,
+            db,
+            pubsub,
+            compensations: Arc::new(Mutex::new(VecDeque::new())),
+            operation: Operation::new_melt(),
+            #[cfg(feature = "prometheus")]
+            metrics_incremented: true,
+            state_data: Initial,
+        }
+    }
+
+    /// Sets up the melt by atomically verifying and reserving inputs/outputs.
+    ///
+    /// This is the first transaction (TX1) in the saga and must complete before payment.
+    ///
+    /// # What This Does
+    ///
+    /// Within a single database transaction:
+    /// 1. Verifies the melt request (inputs, quote state, balance)
+    /// 2. Adds input proofs to the database with Pending state
+    /// 3. Updates quote state from Unpaid/Failed to Pending
+    /// 4. Adds change output blinded messages to the database
+    /// 5. Creates melt request tracking record
+    /// 6. Publishes proof state changes via pubsub
+    ///
+    /// # Compensation
+    ///
+    /// Registers a compensation action that will:
+    /// - Remove input proofs
+    /// - Remove blinded messages
+    /// - Reset quote state from Pending to Unpaid
+    /// - Delete melt request tracking record
+    ///
+    /// This compensation runs if payment or finalization fails.
+    ///
+    /// # Errors
+    ///
+    /// - `PendingQuote`: Quote is already in Pending state
+    /// - `PaidQuote`: Quote has already been paid
+    /// - `TokenAlreadySpent`: Input proofs have already been spent
+    /// - `UnitMismatch`: Input unit doesn't match quote unit
+    #[instrument(skip_all)]
+    pub async fn setup_melt(
+        self,
+        melt_request: &MeltRequest<QuoteId>,
+        input_verification: Verification,
+    ) -> Result<MeltSaga<SetupComplete>, Error> {
+        tracing::info!("TX1: Setting up melt (verify + inputs + outputs)");
+
+        let Verification {
+            amount: input_amount,
+            unit: input_unit,
+        } = input_verification;
+
+        let mut tx = self.db.begin_transaction().await?;
+
+        // Add proofs to the database
+        if let Err(err) = tx
+            .add_proofs(
+                melt_request.inputs().clone(),
+                Some(melt_request.quote_id().to_owned()),
+                &self.operation,
+            )
+            .await
+        {
+            tx.rollback().await?;
+            return Err(match err {
+                cdk_common::database::Error::Duplicate => Error::TokenPending,
+                cdk_common::database::Error::AttemptUpdateSpentProof => Error::TokenAlreadySpent,
+                err => Error::Database(err),
+            });
+        }
+
+        let input_ys = melt_request.inputs().ys()?;
+
+        // Update proof states to Pending
+        let original_states = match tx.update_proofs_states(&input_ys, State::Pending).await {
+            Ok(states) => states,
+            Err(cdk_common::database::Error::AttemptUpdateSpentProof)
+            | Err(cdk_common::database::Error::AttemptRemoveSpentProof) => {
+                tx.rollback().await?;
+                return Err(Error::TokenAlreadySpent);
+            }
+            Err(err) => {
+                tx.rollback().await?;
+                return Err(err.into());
+            }
+        };
+
+        // Check for forbidden states (Pending or Spent)
+        let has_forbidden_state = original_states
+            .iter()
+            .any(|state| matches!(state, Some(State::Pending) | Some(State::Spent)));
+
+        if has_forbidden_state {
+            tx.rollback().await?;
+            return Err(
+                if original_states
+                    .iter()
+                    .any(|s| matches!(s, Some(State::Pending)))
+                {
+                    Error::TokenPending
+                } else {
+                    Error::TokenAlreadySpent
+                },
+            );
+        }
+
+        // Publish proof state changes
+        for pk in input_ys.iter() {
+            self.pubsub.proof_state((*pk, State::Pending));
+        }
+
+        // Update quote state to Pending
+        let (state, quote) = tx
+            .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None)
+            .await?;
+
+        if input_unit != Some(quote.unit.clone()) {
+            tx.rollback().await?;
+            return Err(Error::UnitMismatch);
+        }
+
+        match state {
+            MeltQuoteState::Unpaid | MeltQuoteState::Failed => {}
+            MeltQuoteState::Pending => {
+                tx.rollback().await?;
+                return Err(Error::PendingQuote);
+            }
+            MeltQuoteState::Paid => {
+                tx.rollback().await?;
+                return Err(Error::PaidQuote);
+            }
+            MeltQuoteState::Unknown => {
+                tx.rollback().await?;
+                return Err(Error::UnknownPaymentState);
+            }
+        }
+
+        self.pubsub
+            .melt_quote_status(&quote, None, None, MeltQuoteState::Pending);
+
+        let fee = self.mint.get_proofs_fee(melt_request.inputs()).await?;
+
+        let required_total = quote.amount + quote.fee_reserve + fee;
+
+        if input_amount < required_total {
+            tracing::info!(
+                "Melt request unbalanced: inputs {}, amount {}, fee {}",
+                input_amount,
+                quote.amount,
+                fee
+            );
+            tx.rollback().await?;
+            return Err(Error::TransactionUnbalanced(
+                input_amount.into(),
+                quote.amount.into(),
+                (fee + quote.fee_reserve).into(),
+            ));
+        }
+
+        // Verify outputs if provided
+        if let Some(outputs) = &melt_request.outputs() {
+            if !outputs.is_empty() {
+                let output_verification = match self.mint.verify_outputs(&mut tx, outputs).await {
+                    Ok(verification) => verification,
+                    Err(err) => {
+                        tx.rollback().await?;
+                        return Err(err);
+                    }
+                };
+
+                if input_unit != output_verification.unit {
+                    tx.rollback().await?;
+                    return Err(Error::UnitMismatch);
+                }
+            }
+        }
+
+        let inputs_fee = self.mint.get_proofs_fee(melt_request.inputs()).await?;
+
+        // Add melt request tracking record
+        tx.add_melt_request(
+            melt_request.quote_id(),
+            melt_request.inputs_amount()?,
+            inputs_fee,
+        )
+        .await?;
+
+        // Add change output blinded messages
+        tx.add_blinded_messages(
+            Some(melt_request.quote_id()),
+            melt_request.outputs().as_ref().unwrap_or(&Vec::new()),
+            &self.operation,
+        )
+        .await?;
+
+        // Get blinded secrets for compensation
+        let blinded_secrets: Vec<PublicKey> = melt_request
+            .outputs()
+            .as_ref()
+            .unwrap_or(&Vec::new())
+            .iter()
+            .map(|bm| bm.blinded_secret)
+            .collect();
+
+        // Persist saga state for crash recovery (atomic with TX1)
+        let saga = Saga::new_melt(
+            *self.operation.id(),
+            MeltSagaState::SetupComplete,
+            input_ys.clone(),
+            blinded_secrets.clone(),
+            quote.id.to_string(),
+        );
+
+        if let Err(err) = tx.add_saga(&saga).await {
+            tx.rollback().await?;
+            return Err(err.into());
+        }
+
+        tx.commit().await?;
+
+        // Store blinded messages for state
+        let blinded_messages_vec = melt_request.outputs().clone().unwrap_or_default();
+
+        // Register compensation (uses LIFO via push_front)
+        let compensations = Arc::clone(&self.compensations);
+        compensations
+            .lock()
+            .await
+            .push_front(Box::new(RemoveMeltSetup {
+                input_ys: input_ys.clone(),
+                blinded_secrets,
+                quote_id: quote.id.clone(),
+            }));
+
+        // Transition to SetupComplete state
+        Ok(MeltSaga {
+            mint: self.mint,
+            db: self.db,
+            pubsub: self.pubsub,
+            compensations: self.compensations,
+            operation: self.operation,
+            #[cfg(feature = "prometheus")]
+            metrics_incremented: self.metrics_incremented,
+            state_data: SetupComplete {
+                quote,
+                input_ys,
+                blinded_messages: blinded_messages_vec,
+            },
+        })
+    }
+}
+
+impl MeltSaga<SetupComplete> {
+    /// Attempts to settle the melt internally (melt-to-mint on same mint).
+    ///
+    /// This checks if the payment request corresponds to an existing mint quote
+    /// on the same mint, and if so, settles it atomically within a transaction.
+    ///
+    /// # What This Does
+    ///
+    /// Within a single database transaction:
+    /// 1. Checks if payment request matches a mint quote on this mint
+    /// 2. If not a match or different unit: returns (self, RequiresExternalPayment)
+    /// 3. If match found: validates quote state and amount
+    /// 4. Increments the mint quote's paid amount
+    /// 5. Publishes mint quote payment notification
+    /// 6. Returns (self, Internal{amount})
+    ///
+    /// # Compensation
+    ///
+    /// If internal settlement fails, this method automatically calls compensate_all()
+    /// to roll back the setup_melt changes before returning the error. The saga is
+    /// consumed on error, so the caller cannot continue.
+    ///
+    /// # Returns
+    ///
+    /// - `Ok((self, Internal{amount}))`: Internal settlement succeeded, saga can continue
+    /// - `Ok((self, RequiresExternalPayment))`: Not an internal payment, saga can continue
+    /// - `Err(_)`: Internal settlement attempted but failed (compensations executed, saga consumed)
+    ///
+    /// # Errors
+    ///
+    /// - `RequestAlreadyPaid`: Mint quote already settled
+    /// - `InsufficientFunds`: Not enough input proofs for mint quote amount
+    /// - `Internal`: Database error during settlement
+    #[instrument(skip_all)]
+    pub async fn attempt_internal_settlement(
+        self,
+        melt_request: &MeltRequest<QuoteId>,
+    ) -> Result<(Self, SettlementDecision), Error> {
+        tracing::info!("Checking for internal settlement opportunity");
+
+        let mut tx = self.db.begin_transaction().await?;
+
+        let mint_quote = match tx
+            .get_mint_quote_by_request(&self.state_data.quote.request.to_string())
+            .await
+        {
+            Ok(Some(mint_quote)) if mint_quote.unit == self.state_data.quote.unit => mint_quote,
+            Ok(_) => {
+                tx.rollback().await?;
+                tracing::debug!("Not an internal payment or unit mismatch");
+                return Ok((self, SettlementDecision::RequiresExternalPayment));
+            }
+            Err(err) => {
+                tx.rollback().await?;
+                tracing::debug!("Error checking for mint quote: {}", err);
+                self.compensate_all().await?;
+                return Err(Error::Internal);
+            }
+        };
+
+        // Mint quote has already been settled
+        if (mint_quote.state() == cdk_common::nuts::MintQuoteState::Issued
+            || mint_quote.state() == cdk_common::nuts::MintQuoteState::Paid)
+            && mint_quote.payment_method == crate::mint::PaymentMethod::Bolt11
+        {
+            tx.rollback().await?;
+            self.compensate_all().await?;
+            return Err(Error::RequestAlreadyPaid);
+        }
+
+        let inputs_amount_quote_unit = melt_request.inputs_amount().map_err(|_| {
+            tracing::error!("Proof inputs in melt quote overflowed");
+            Error::AmountOverflow
+        })?;
+
+        if let Some(amount) = mint_quote.amount {
+            if amount > inputs_amount_quote_unit {
+                tracing::debug!(
+                    "Not enough inputs provided: {} needed {}",
+                    inputs_amount_quote_unit,
+                    amount
+                );
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(Error::InsufficientFunds);
+            }
+        }
+
+        let amount = self.state_data.quote.amount;
+
+        tracing::info!(
+            "Mint quote {} paid {} from internal payment.",
+            mint_quote.id,
+            amount
+        );
+
+        let total_paid = tx
+            .increment_mint_quote_amount_paid(
+                &mint_quote.id,
+                amount,
+                self.state_data.quote.id.to_string(),
+            )
+            .await?;
+
+        self.pubsub.mint_quote_payment(&mint_quote, total_paid);
+
+        tracing::info!(
+            "Melt quote {} paid Mint quote {}",
+            self.state_data.quote.id,
+            mint_quote.id
+        );
+
+        tx.commit().await?;
+
+        Ok((self, SettlementDecision::Internal { amount }))
+    }
+
+    /// Makes payment via Lightning Network backend or internal settlement.
+    ///
+    /// This is an external operation that happens after `setup_melt` and before `finalize`.
+    /// No database changes occur in this step (except for internal settlement case).
+    ///
+    /// # What This Does
+    ///
+    /// 1. Takes a SettlementDecision from attempt_internal_settlement
+    /// 2. If Internal: creates payment result directly
+    /// 3. If RequiresExternalPayment: calls LN backend
+    /// 4. Handles payment result states with idempotent verification
+    /// 5. Transitions to PaymentConfirmed state on success
+    ///
+    /// # Idempotent Payment Verification
+    ///
+    /// Lightning payments are asynchronous, and the LN backend may return different
+    /// states for the same payment query due to:
+    /// - Network latency between payment initiation and confirmation
+    /// - Backend database replication lag
+    /// - HTLC settlement timing
+    ///
+    /// **Critical Principle**: If `check_payment_state()` confirms the payment as Paid,
+    /// we MUST proceed to finalize, regardless of what `make_payment()` initially returned.
+    /// This ensures the saga is idempotent with respect to payment confirmation.
+    ///
+    /// # Failure Handling
+    ///
+    /// If payment is confirmed as failed/unpaid, all registered compensations are
+    /// executed to roll back the setup transaction.
+    ///
+    /// # Errors
+    ///
+    /// - `PaymentFailed`: Payment confirmed as failed/unpaid
+    /// - `PendingQuote`: Payment is pending (will be resolved by startup check)
+    #[instrument(skip_all)]
+    pub async fn make_payment(
+        self,
+        settlement: SettlementDecision,
+    ) -> Result<MeltSaga<PaymentConfirmed>, Error> {
+        tracing::info!("Making payment (external LN operation or internal settlement)");
+
+        let payment_result = match settlement {
+            SettlementDecision::Internal { amount } => {
+                tracing::info!(
+                    "Payment settled internally for {} {}",
+                    amount,
+                    self.state_data.quote.unit
+                );
+                MakePaymentResponse {
+                    status: MeltQuoteState::Paid,
+                    total_spent: amount,
+                    unit: self.state_data.quote.unit.clone(),
+                    payment_proof: None,
+                    payment_lookup_id: self
+                        .state_data
+                        .quote
+                        .request_lookup_id
+                        .clone()
+                        .unwrap_or_else(|| {
+                            cdk_common::payment::PaymentIdentifier::CustomId(
+                                self.state_data.quote.id.to_string(),
+                            )
+                        }),
+                }
+            }
+            SettlementDecision::RequiresExternalPayment => {
+                // Get LN payment processor
+                let ln = self
+                    .mint
+                    .payment_processors
+                    .get(&crate::types::PaymentProcessorKey::new(
+                        self.state_data.quote.unit.clone(),
+                        self.state_data.quote.payment_method.clone(),
+                    ))
+                    .ok_or_else(|| {
+                        tracing::info!(
+                            "Could not get ln backend for {}, {}",
+                            self.state_data.quote.unit,
+                            self.state_data.quote.payment_method
+                        );
+                        Error::UnsupportedUnit
+                    })?;
+
+                // Make payment with idempotent verification
+                let payment_response = match ln
+                    .make_payment(
+                        &self.state_data.quote.unit,
+                        self.state_data.quote.clone().try_into()?,
+                    )
+                    .await
+                {
+                    Ok(pay)
+                        if pay.status == MeltQuoteState::Unknown
+                            || pay.status == MeltQuoteState::Failed =>
+                    {
+                        tracing::warn!(
+                            "Got {} status when paying melt quote {} for {} {}. Verifying with backend...",
+                            pay.status,
+                            self.state_data.quote.id,
+                            self.state_data.quote.amount,
+                            self.state_data.quote.unit
+                        );
+
+                        let check_response = self
+                            .check_payment_state(Arc::clone(ln), &pay.payment_lookup_id)
+                            .await?;
+
+                        if check_response.status == MeltQuoteState::Paid {
+                            // Race condition: Payment succeeded during verification
+                            tracing::info!(
+                                "Payment initially returned {} but confirmed as Paid. Proceeding to finalize.",
+                                pay.status
+                            );
+                            check_response
+                        } else {
+                            check_response
+                        }
+                    }
+                    Ok(pay) => pay,
+                    Err(err) => {
+                        if matches!(err, crate::cdk_payment::Error::InvoiceAlreadyPaid) {
+                            tracing::info!("Invoice already paid, verifying payment status");
+                        } else {
+                            // Other error - check if payment actually succeeded
+                            tracing::error!(
+                                "Error returned attempting to pay: {} {}",
+                                self.state_data.quote.id,
+                                err
+                            );
+                        }
+
+                        let lookup_id = self
+                            .state_data
+                            .quote
+                            .request_lookup_id
+                            .as_ref()
+                            .ok_or_else(|| {
+                                tracing::error!(
+                                "No payment id, cannot verify payment status for {} after error",
+                                self.state_data.quote.id
+                            );
+                                Error::Internal
+                            })?;
+
+                        let check_response =
+                            self.check_payment_state(Arc::clone(ln), lookup_id).await?;
+
+                        tracing::info!(
+                            "Initial payment attempt for {} errored. Follow up check stateus: {}",
+                            self.state_data.quote.id,
+                            check_response.status
+                        );
+
+                        check_response
+                    }
+                };
+
+                match payment_response.status {
+                    MeltQuoteState::Paid => payment_response,
+                    MeltQuoteState::Unpaid | MeltQuoteState::Failed => {
+                        tracing::info!(
+                            "Lightning payment for quote {} failed.",
+                            self.state_data.quote.id
+                        );
+                        self.compensate_all().await?;
+                        return Err(Error::PaymentFailed);
+                    }
+                    MeltQuoteState::Unknown => {
+                        tracing::warn!(
+                            "LN payment unknown, proofs remain pending for quote: {}",
+                            self.state_data.quote.id
+                        );
+                        return Err(Error::PaymentFailed);
+                    }
+                    MeltQuoteState::Pending => {
+                        tracing::warn!(
+                            "LN payment pending, proofs remain pending for quote: {}",
+                            self.state_data.quote.id
+                        );
+                        return Err(Error::PendingQuote);
+                    }
+                }
+            }
+        };
+
+        // TODO: Add total spent > quote check
+
+        // Transition to PaymentConfirmed state
+        Ok(MeltSaga {
+            mint: self.mint,
+            db: self.db,
+            pubsub: self.pubsub,
+            compensations: self.compensations,
+            operation: self.operation,
+            #[cfg(feature = "prometheus")]
+            metrics_incremented: self.metrics_incremented,
+            state_data: PaymentConfirmed {
+                quote: self.state_data.quote,
+                input_ys: self.state_data.input_ys,
+                blinded_messages: self.state_data.blinded_messages,
+                payment_result,
+            },
+        })
+    }
+
+    /// Helper to check payment state with LN backend
+    async fn check_payment_state(
+        &self,
+        ln: Arc<
+            dyn cdk_common::payment::MintPayment<Err = cdk_common::payment::Error> + Send + Sync,
+        >,
+        lookup_id: &cdk_common::payment::PaymentIdentifier,
+    ) -> Result<MakePaymentResponse, Error> {
+        match ln.check_outgoing_payment(lookup_id).await {
+            Ok(response) => Ok(response),
+            Err(check_err) => {
+                tracing::error!(
+                    "Could not check the status of payment for {}. Proofs stuck as pending",
+                    lookup_id
+                );
+                tracing::error!("Checking payment error: {}", check_err);
+                Err(Error::Internal)
+            }
+        }
+    }
+}
+
+impl MeltSaga<PaymentConfirmed> {
+    /// Finalizes the melt by committing signatures and marking inputs as spent.
+    ///
+    /// This is the second and final transaction (TX2) in the saga and completes the melt.
+    ///
+    /// # What This Does
+    ///
+    /// Within a single database transaction:
+    /// 1. Updates quote state to Paid
+    /// 2. Updates payment lookup ID if changed
+    /// 3. Marks input proofs as Spent
+    /// 4. Calculates and signs change outputs (if applicable)
+    /// 5. Deletes melt request tracking record
+    /// 6. Publishes quote status changes via pubsub
+    /// 7. Clears all registered compensations (melt successfully completed)
+    ///
+    /// # Change Handling
+    ///
+    /// If inputs > total_spent:
+    /// - If change outputs were provided: sign them and return
+    /// - If no change outputs: change is burnt (logged as info)
+    ///
+    /// # Success
+    ///
+    /// On success, compensations are cleared and the melt is complete.
+    ///
+    /// # Errors
+    ///
+    /// - `TokenAlreadySpent`: Input proofs were already spent
+    /// - `BlindedMessageAlreadySigned`: Change outputs already signed
+    #[instrument(skip_all)]
+    pub async fn finalize(self) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        tracing::info!("TX2: Finalizing melt (mark spent + change)");
+
+        let total_spent = to_unit(
+            self.state_data.payment_result.total_spent,
+            &self.state_data.payment_result.unit,
+            &self.state_data.quote.unit,
+        )
+        .unwrap_or_default();
+
+        let payment_preimage = self.state_data.payment_result.payment_proof.clone();
+        let payment_lookup_id = &self.state_data.payment_result.payment_lookup_id;
+
+        let mut tx = self.db.begin_transaction().await?;
+
+        // Get melt request info first (needed for validation and change)
+        let MeltRequestInfo {
+            inputs_amount,
+            inputs_fee,
+            change_outputs,
+        } = tx
+            .get_melt_request_and_blinded_messages(&self.state_data.quote.id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
+        // Use shared core finalization logic
+        if let Err(err) = super::shared::finalize_melt_core(
+            &mut tx,
+            &self.pubsub,
+            &self.state_data.quote,
+            &self.state_data.input_ys,
+            inputs_amount,
+            inputs_fee,
+            total_spent,
+            payment_preimage.clone(),
+            payment_lookup_id,
+        )
+        .await
+        {
+            tx.rollback().await?;
+            self.compensate_all().await?;
+            return Err(err);
+        }
+
+        let needs_change = inputs_amount > total_spent;
+
+        // Handle change: either sign change outputs or just commit TX1
+        let (change, mut tx) = if !needs_change {
+            // No change required - just commit TX1
+            tracing::debug!("No change required for melt {}", self.state_data.quote.id);
+            (None, tx)
+        } else {
+            // We commit tx here as process_change can make external call to blind sign
+            // We do not want to hold db txs across external calls
+            tx.commit().await?;
+            super::shared::process_melt_change(
+                &self.mint,
+                &self.db,
+                &self.state_data.quote.id,
+                inputs_amount,
+                total_spent,
+                inputs_fee,
+                change_outputs,
+            )
+            .await?
+        };
+
+        tx.delete_melt_request(&self.state_data.quote.id).await?;
+
+        // Delete saga - melt completed successfully (best-effort)
+        if let Err(e) = tx.delete_saga(self.operation.id()).await {
+            tracing::warn!("Failed to delete saga in finalize: {}", e);
+            // Don't rollback - melt succeeded
+        }
+
+        tx.commit().await?;
+
+        self.pubsub.melt_quote_status(
+            &self.state_data.quote,
+            payment_preimage.clone(),
+            change.clone(),
+            MeltQuoteState::Paid,
+        );
+
+        tracing::debug!(
+            "Melt for quote {} completed total spent {}, total inputs: {}, change given: {}",
+            self.state_data.quote.id,
+            total_spent,
+            inputs_amount,
+            change
+                .as_ref()
+                .map(|c| Amount::try_sum(c.iter().map(|a| a.amount))
+                    .expect("Change cannot overflow"))
+                .unwrap_or_default()
+        );
+
+        self.compensations.lock().await.clear();
+
+        #[cfg(feature = "prometheus")]
+        if self.metrics_incremented {
+            METRICS.dec_in_flight_requests("melt_bolt11");
+            METRICS.record_mint_operation("melt_bolt11", true);
+        }
+
+        let response = MeltQuoteBolt11Response {
+            amount: self.state_data.quote.amount,
+            paid: Some(true),
+            payment_preimage,
+            change,
+            quote: self.state_data.quote.id,
+            fee_reserve: self.state_data.quote.fee_reserve,
+            state: MeltQuoteState::Paid,
+            expiry: self.state_data.quote.expiry,
+            request: Some(self.state_data.quote.request.to_string()),
+            unit: Some(self.state_data.quote.unit.clone()),
+        };
+
+        Ok(response)
+    }
+}
+
+impl<S> MeltSaga<S> {
+    /// Execute all compensating actions and consume the saga.
+    ///
+    /// This method takes ownership of self to ensure the saga cannot be used
+    /// after compensation has been triggered.
+    ///
+    /// This is called internally by saga methods when they need to compensate.
+    #[instrument(skip_all)]
+    async fn compensate_all(self) -> Result<(), Error> {
+        let mut compensations = self.compensations.lock().await;
+
+        if compensations.is_empty() {
+            return Ok(());
+        }
+
+        #[cfg(feature = "prometheus")]
+        if self.metrics_incremented {
+            METRICS.dec_in_flight_requests("melt_bolt11");
+            METRICS.record_mint_operation("melt_bolt11", false);
+            METRICS.record_error();
+        }
+
+        tracing::warn!("Running {} compensating actions", compensations.len());
+
+        while let Some(compensation) = compensations.pop_front() {
+            tracing::debug!("Running compensation: {}", compensation.name());
+            if let Err(e) = compensation.execute(&self.db).await {
+                tracing::error!(
+                    "Compensation {} failed: {}. Continuing...",
+                    compensation.name(),
+                    e
+                );
+            }
+        }
+
+        Ok(())
+    }
+}

+ 46 - 0
crates/cdk/src/mint/melt/melt_saga/state.rs

@@ -0,0 +1,46 @@
+use cdk_common::nuts::BlindedMessage;
+use cdk_common::{Amount, PublicKey};
+
+use crate::cdk_payment::MakePaymentResponse;
+use crate::mint::MeltQuote;
+
+/// Initial state - no data yet.
+///
+/// The melt saga starts in this state. Only the `setup_melt` method is available.
+pub struct Initial;
+
+/// Setup complete - has quote, input Ys, and blinded messages.
+///
+/// After successful setup, the saga transitions to this state.
+/// The `attempt_internal_settlement` and `make_payment` methods are available.
+pub struct SetupComplete {
+    pub quote: MeltQuote,
+    pub input_ys: Vec<PublicKey>,
+    pub blinded_messages: Vec<BlindedMessage>,
+}
+
+/// Payment confirmed - has everything including payment result.
+///
+/// After successful payment (internal or external), the saga transitions to this state.
+/// Only the `finalize` method is available.
+pub struct PaymentConfirmed {
+    pub quote: MeltQuote,
+    pub input_ys: Vec<PublicKey>,
+    #[allow(dead_code)] // Stored for completeness, accessed from DB in finalize
+    pub blinded_messages: Vec<BlindedMessage>,
+    pub payment_result: MakePaymentResponse,
+}
+
+/// Result of attempting internal settlement for a melt operation.
+///
+/// This enum represents the decision point in the melt flow:
+/// - Internal settlement succeeded → skip external Lightning payment
+/// - External payment required → proceed with Lightning Network call
+#[derive(Debug, Clone)]
+pub enum SettlementDecision {
+    /// Payment was settled internally (melt-to-mint on the same mint).
+    /// Contains the amount that was settled.
+    Internal { amount: Amount },
+    /// Payment requires external Lightning Network settlement.
+    RequiresExternalPayment,
+}

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

@@ -0,0 +1,2130 @@
+//! Tests for melt saga pattern implementation
+//!
+//! This test module covers:
+//! - Basic state transitions
+//! - Crash recovery scenarios
+//! - Saga persistence and deletion
+//! - Compensation execution
+//! - Concurrent operations
+//! - Failure handling
+
+use cdk_common::mint::{MeltSagaState, OperationKind, Saga};
+use cdk_common::nuts::MeltQuoteState;
+use cdk_common::{Amount, ProofsMethods, State};
+
+use crate::mint::melt::melt_saga::MeltSaga;
+use crate::test_helpers::mint::{create_test_mint, mint_test_proofs};
+
+// ============================================================================
+// Basic State Transition Tests
+// ============================================================================
+
+/// Test: Saga can be created in Initial state
+#[tokio::test]
+async fn test_melt_saga_initial_state_creation() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let _saga = MeltSaga::new(std::sync::Arc::new(mint.clone()), db, pubsub);
+    // Type system enforces Initial state - if this compiles, test passes
+}
+
+// ============================================================================
+// Saga Persistence Tests
+// ============================================================================
+
+/// Test: Saga state is persisted atomically with setup transaction
+#[tokio::test]
+async fn test_saga_state_persistence_after_setup() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup melt saga
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+
+    // STEP 3: Query database for saga
+    let sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+
+    // STEP 4: Find our saga
+    let persisted_saga = sagas
+        .iter()
+        .find(|s| s.operation_id == operation_id)
+        .expect("Saga should be persisted");
+
+    // STEP 5: Validate saga content
+    assert_eq!(
+        persisted_saga.operation_id, operation_id,
+        "Operation ID should match"
+    );
+    assert_eq!(
+        persisted_saga.operation_kind,
+        OperationKind::Melt,
+        "Operation kind should be Melt"
+    );
+
+    // Verify state is SetupComplete
+    match &persisted_saga.state {
+        cdk_common::mint::SagaStateEnum::Melt(state) => {
+            assert_eq!(
+                *state,
+                MeltSagaState::SetupComplete,
+                "State should be SetupComplete"
+            );
+        }
+        _ => panic!("Expected Melt saga state"),
+    }
+
+    // STEP 6: Verify input_ys are stored
+    let input_ys = proofs.ys().unwrap();
+    assert_eq!(
+        persisted_saga.input_ys.len(),
+        input_ys.len(),
+        "Should store all input Ys"
+    );
+    for y in &input_ys {
+        assert!(
+            persisted_saga.input_ys.contains(y),
+            "Input Y should be stored: {:?}",
+            y
+        );
+    }
+
+    // STEP 7: Verify timestamps are set
+    assert!(
+        persisted_saga.created_at > 0,
+        "Created timestamp should be set"
+    );
+    assert!(
+        persisted_saga.updated_at > 0,
+        "Updated timestamp should be set"
+    );
+    assert_eq!(
+        persisted_saga.created_at, persisted_saga.updated_at,
+        "Timestamps should match for new saga"
+    );
+
+    // STEP 8: Verify blinded_secrets is empty (not used for melt)
+    assert!(
+        persisted_saga.blinded_secrets.is_empty(),
+        "Melt saga should not store blinded_secrets"
+    );
+
+    // SUCCESS: Saga persisted correctly!
+}
+
+/// Test: Saga is deleted after successful finalization
+#[tokio::test]
+async fn test_saga_deletion_on_success() {
+    // STEP 1: Setup test environment (FakeWallet handles payments automatically)
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create proofs and quote
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 3: Complete full melt flow
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+
+    // Setup
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let operation_id = *setup_saga.operation.id();
+
+    // Verify saga exists
+    assert_saga_exists(&mint, &operation_id).await;
+
+    // Attempt internal settlement (will fail, go to external payment)
+    let (payment_saga, decision) = setup_saga
+        .attempt_internal_settlement(&melt_request)
+        .await
+        .unwrap();
+
+    // Make payment (FakeWallet will return success based on FakeInvoiceDescription)
+    let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
+
+    // Finalize
+    let _response = confirmed_saga.finalize().await.unwrap();
+
+    // STEP 4: Verify saga was deleted
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // STEP 5: Verify no incomplete sagas remain
+    let sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+    assert!(sagas.is_empty(), "Should have no incomplete melt sagas");
+
+    // SUCCESS: Saga cleaned up on success!
+}
+
+/// Test: Saga remains in database if finalize fails
+#[tokio::test]
+async fn test_saga_persists_on_finalize_failure() {
+    // TODO: Implement this test
+    // 1. Setup melt saga successfully
+    // 2. Simulate finalize failure (e.g., database error)
+    // 3. Verify saga still exists in database
+    // 4. Verify state is still SetupComplete
+}
+
+// ============================================================================
+// Crash Recovery Tests - SetupComplete State
+// ============================================================================
+
+/// Test: Recovery from crash after setup but before payment
+///
+/// This is the primary crash recovery scenario. If the mint crashes after
+/// setup_melt() completes but before payment is sent, the proofs should be
+/// restored on restart.
+#[tokio::test]
+async fn test_crash_recovery_setup_complete() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create test proofs (10,000 millisats = 10 sats)
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+
+    // STEP 3: Create melt quote (9,000 millisats = 9 sats)
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+
+    // STEP 4: Create melt request
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 5: Setup melt saga (this persists saga to DB)
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga
+        .setup_melt(&melt_request, verification)
+        .await
+        .expect("Setup should succeed");
+
+    // STEP 6: Verify proofs are PENDING
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 7: Verify saga was persisted
+    let operation_id = *setup_saga.operation.id();
+    assert_saga_exists(&mint, &operation_id).await;
+
+    // STEP 8: Simulate crash - drop saga without finalizing
+    drop(setup_saga);
+
+    // STEP 9: Run recovery (simulating mint restart)
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 10: Verify proofs were REMOVED (restored to client)
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    // STEP 11: Verify saga was deleted
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // STEP 12: Verify quote state reset to UNPAID
+    let recovered_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should still exist");
+    assert_eq!(
+        recovered_quote.state,
+        MeltQuoteState::Unpaid,
+        "Quote state should be reset to Unpaid after recovery"
+    );
+
+    // SUCCESS: Crash recovery works!
+}
+
+/// Test: Multiple incomplete sagas can be recovered
+///
+/// This test validates that the recovery mechanism can handle multiple
+/// incomplete sagas in a single recovery pass, ensuring batch operations work.
+#[tokio::test]
+async fn test_crash_recovery_multiple_sagas() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create multiple incomplete melt sagas (5 sagas)
+    let mut operation_ids = Vec::new();
+    let mut proof_ys_list = Vec::new();
+    let mut quote_ids = Vec::new();
+
+    for i in 0..5 {
+        // Use smaller amounts to fit within FakeWallet limits
+        let proofs = mint_test_proofs(&mint, Amount::from(5_000 + i * 100))
+            .await
+            .unwrap();
+        let input_ys = proofs.ys().unwrap();
+        let quote = create_test_melt_quote(&mint, Amount::from(4_000 + i * 100)).await;
+        let melt_request = create_test_melt_request(&proofs, &quote);
+
+        let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+        let saga = MeltSaga::new(
+            std::sync::Arc::new(mint.clone()),
+            mint.localstore(),
+            mint.pubsub_manager(),
+        );
+        let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+        operation_ids.push(*setup_saga.operation.id());
+        proof_ys_list.push(input_ys);
+        quote_ids.push(quote.id.clone());
+
+        // Drop saga to simulate crash
+        drop(setup_saga);
+    }
+
+    // STEP 3: Verify all sagas exist before recovery
+    let sagas_before = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+
+    assert_eq!(
+        sagas_before.len(),
+        5,
+        "Should have 5 incomplete sagas before recovery"
+    );
+
+    // Verify all our operation IDs are present
+    for operation_id in &operation_ids {
+        assert!(
+            sagas_before.iter().any(|s| s.operation_id == *operation_id),
+            "Saga {} should exist before recovery",
+            operation_id
+        );
+    }
+
+    // Verify all proofs are PENDING
+    for input_ys in &proof_ys_list {
+        assert_proofs_state(&mint, input_ys, Some(State::Pending)).await;
+    }
+
+    // STEP 4: Run recovery (should handle all sagas)
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 5: Verify all sagas were recovered and cleaned up
+    let sagas_after = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+
+    assert!(
+        sagas_after.is_empty(),
+        "All sagas should be deleted after recovery"
+    );
+
+    // Verify none of our operation IDs exist
+    for operation_id in &operation_ids {
+        assert_saga_not_exists(&mint, operation_id).await;
+    }
+
+    // STEP 6: Verify all proofs were removed (returned to client)
+    for input_ys in &proof_ys_list {
+        assert_proofs_state(&mint, input_ys, None).await;
+    }
+
+    // STEP 7: Verify all quotes were reset to UNPAID
+    for quote_id in &quote_ids {
+        let recovered_quote = mint
+            .localstore
+            .get_melt_quote(quote_id)
+            .await
+            .unwrap()
+            .expect("Quote should still exist");
+
+        assert_eq!(
+            recovered_quote.state,
+            MeltQuoteState::Unpaid,
+            "Quote {} should be reset to Unpaid",
+            quote_id
+        );
+    }
+
+    // SUCCESS: Multiple sagas recovered successfully!
+}
+
+/// Test: Recovery handles sagas gracefully even when data relationships exist
+///
+/// This test verifies that recovery works correctly in a standard crash scenario
+/// where all data is intact (saga, quote, proofs all exist).
+#[tokio::test]
+async fn test_crash_recovery_orphaned_saga() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Create incomplete saga
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+    let input_ys = proofs.ys().unwrap();
+
+    // Drop saga (simulate crash)
+    drop(setup_saga);
+
+    // Verify saga exists
+    assert_saga_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 3: Run recovery
+    // Recovery should handle the saga gracefully, cleaning up all state
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 4: Verify saga was cleaned up
+    assert_saga_not_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    // Verify quote was reset
+    let recovered_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(recovered_quote.state, MeltQuoteState::Unpaid);
+
+    // SUCCESS: Recovery works correctly!
+}
+
+/// Test: Recovery continues even if one saga fails
+#[tokio::test]
+async fn test_crash_recovery_partial_failure() {
+    // TODO: Implement this test
+    // 1. Create multiple incomplete sagas
+    // 2. Make one saga fail (e.g., corrupted data)
+    // 3. Run recovery
+    // 4. Verify other sagas were still recovered
+    // 5. Verify failed saga is logged but doesn't stop recovery
+}
+
+// ============================================================================
+// Startup Integration Tests
+// ============================================================================
+
+/// Test: Startup recovery is called on mint.start()
+#[tokio::test]
+async fn test_startup_recovery_integration() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create incomplete saga
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+    let input_ys = proofs.ys().unwrap();
+
+    // Drop saga (simulate crash)
+    drop(setup_saga);
+
+    // Verify saga exists after setup
+    assert_saga_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 3: Manually trigger recovery (simulating restart behavior)
+    // Note: create_test_mint() already calls mint.start(), so recovery should
+    // have run on startup. However, since we created the saga AFTER startup,
+    // we need to manually trigger recovery to simulate a restart scenario.
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 4: Verify recovery was executed
+    assert_saga_not_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    // STEP 5: Verify mint is running normally
+    // (Can perform new melt operations)
+    let new_proofs = mint_test_proofs(&mint, Amount::from(5_000)).await.unwrap();
+    let new_quote = create_test_melt_quote(&mint, Amount::from(4_000)).await;
+    let new_request = create_test_melt_request(&new_proofs, &new_quote);
+
+    let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap();
+    let new_saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let _new_setup = new_saga
+        .setup_melt(&new_request, new_verification)
+        .await
+        .unwrap();
+
+    // SUCCESS: Recovery runs on startup and mint works normally!
+}
+
+/// Test: Startup never fails due to recovery errors
+#[tokio::test]
+async fn test_startup_resilient_to_recovery_errors() {
+    // TODO: Implement this test
+    // 1. Create corrupted saga data
+    // 2. Call mint.start()
+    // 3. Verify start() completes successfully
+    // 4. Verify error was logged
+}
+
+// ============================================================================
+// Compensation Tests
+// ============================================================================
+
+/// Test: Compensation removes proofs from database
+///
+/// This test validates that when compensation runs (during crash recovery),
+/// the proofs are properly removed from the database and returned to the client.
+#[tokio::test]
+async fn test_compensation_removes_proofs() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup melt saga (this marks proofs as PENDING)
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+
+    // Verify proofs are PENDING
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 3: Simulate crash and trigger compensation via recovery
+    drop(setup_saga);
+
+    // Run recovery which triggers compensation
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 4: Verify proofs were removed from database (returned to client)
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    // STEP 5: Verify saga was cleaned up
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // STEP 6: Verify proofs can be used again in a new melt operation
+    let new_quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let new_request = create_test_melt_request(&proofs, &new_quote);
+
+    let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap();
+    let new_saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let new_setup = new_saga
+        .setup_melt(&new_request, new_verification)
+        .await
+        .expect("Should be able to reuse proofs after compensation");
+
+    // Verify new saga was created successfully
+    assert_saga_exists(&mint, new_setup.operation.id()).await;
+
+    // SUCCESS: Compensation properly removed proofs and they can be reused!
+}
+
+/// Test: Compensation removes change outputs
+///
+/// This test validates that compensation properly removes blinded messages
+/// (change outputs) from the database during rollback.
+#[tokio::test]
+async fn test_compensation_removes_change_outputs() {
+    use cdk_common::nuts::MeltRequest;
+
+    use crate::test_helpers::mint::create_test_blinded_messages;
+
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // Create input proofs (more than needed so we have change)
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(7_000)).await;
+
+    // STEP 2: Create change outputs (blinded messages)
+    // Change = 10,000 - 7,000 - fee = ~3,000 sats
+    let (blinded_messages, _premint) = create_test_blinded_messages(&mint, Amount::from(3_000))
+        .await
+        .unwrap();
+
+    let blinded_secrets: Vec<_> = blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // STEP 3: Create melt request with change outputs
+    let melt_request = MeltRequest::new(quote.id.clone(), proofs.clone(), Some(blinded_messages));
+
+    // STEP 4: Setup melt saga (this stores blinded messages)
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+
+    // STEP 5: Verify blinded messages are stored in database
+    let stored_info = {
+        let mut tx = mint.localstore.begin_transaction().await.unwrap();
+        let info = tx
+            .get_melt_request_and_blinded_messages(&quote.id)
+            .await
+            .expect("Should be able to query melt request")
+            .expect("Melt request should exist");
+        tx.rollback().await.unwrap();
+        info
+    };
+
+    assert_eq!(
+        stored_info.change_outputs.len(),
+        blinded_secrets.len(),
+        "All blinded messages should be stored"
+    );
+
+    // STEP 6: Simulate crash and trigger compensation
+    drop(setup_saga);
+
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 7: Verify blinded messages were removed
+    let result = {
+        let mut tx = mint.localstore.begin_transaction().await.unwrap();
+        let res = tx
+            .get_melt_request_and_blinded_messages(&quote.id)
+            .await
+            .expect("Query should succeed");
+        tx.rollback().await.unwrap();
+        res
+    };
+
+    assert!(
+        result.is_none(),
+        "Melt request and blinded messages should be deleted after compensation"
+    );
+
+    // STEP 8: Verify saga was cleaned up
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // SUCCESS: Compensation properly removed change outputs!
+}
+
+/// Test: Compensation resets quote state
+///
+/// This test validates that compensation properly resets the quote state
+/// from PENDING back to UNPAID, allowing the quote to be used again.
+#[tokio::test]
+async fn test_compensation_resets_quote_state() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+
+    // Verify initial quote state is UNPAID
+    assert_eq!(
+        quote.state,
+        MeltQuoteState::Unpaid,
+        "Quote should start as Unpaid"
+    );
+
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup melt saga (this changes quote state to PENDING)
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+
+    // STEP 3: Verify quote state became PENDING
+    let pending_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should exist");
+
+    assert_eq!(
+        pending_quote.state,
+        MeltQuoteState::Pending,
+        "Quote state should be Pending after setup"
+    );
+
+    // STEP 4: Simulate crash and trigger compensation
+    drop(setup_saga);
+
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 5: Verify quote state was reset to UNPAID
+    let recovered_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should still exist after compensation");
+
+    assert_eq!(
+        recovered_quote.state,
+        MeltQuoteState::Unpaid,
+        "Quote state should be reset to Unpaid after compensation"
+    );
+
+    // STEP 6: Verify saga was cleaned up
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // STEP 7: Verify quote can be used again with new melt request
+    let new_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let new_request = create_test_melt_request(&new_proofs, &recovered_quote);
+
+    let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap();
+    let new_saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let _new_setup = new_saga
+        .setup_melt(&new_request, new_verification)
+        .await
+        .expect("Should be able to reuse quote after compensation");
+
+    // SUCCESS: Quote state properly reset and can be reused!
+}
+
+/// Test: Compensation is idempotent
+///
+/// This test validates that running compensation multiple times is safe
+/// and produces consistent results. This is important because recovery
+/// might be called multiple times during debugging or startup.
+#[tokio::test]
+async fn test_compensation_idempotent() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup melt saga
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+
+    // Verify initial state
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+    assert_saga_exists(&mint, &operation_id).await;
+
+    // STEP 3: Simulate crash
+    drop(setup_saga);
+
+    // STEP 4: Run compensation first time
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("First recovery should succeed");
+
+    // Verify state after first compensation
+    assert_proofs_state(&mint, &input_ys, None).await;
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    let quote_after_first = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should exist");
+    assert_eq!(quote_after_first.state, MeltQuoteState::Unpaid);
+
+    // STEP 5: Run compensation second time (should be idempotent)
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Second recovery should succeed without errors");
+
+    // STEP 6: Verify state is unchanged after second compensation
+    assert_proofs_state(&mint, &input_ys, None).await;
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    let quote_after_second = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .expect("Quote should still exist");
+    assert_eq!(quote_after_second.state, MeltQuoteState::Unpaid);
+
+    // STEP 7: Verify both results are identical
+    assert_eq!(
+        quote_after_first.state, quote_after_second.state,
+        "Quote state should be identical after multiple compensations"
+    );
+
+    // STEP 8: Run third time to be extra sure
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Third recovery should also succeed");
+
+    // SUCCESS: Compensation is idempotent and safe to run multiple times!
+}
+
+// ============================================================================
+// Saga Content Validation Tests
+// ============================================================================
+
+/// Test: Persisted saga contains correct data
+///
+/// This test validates that all saga fields are persisted correctly,
+/// providing comprehensive validation beyond the basic persistence test.
+#[tokio::test]
+async fn test_saga_content_validation() {
+    // STEP 1: Setup test environment with known data
+    let mint = create_test_mint().await.unwrap();
+
+    // Create proofs with specific amount
+    let proof_amount = Amount::from(10_000);
+    let proofs = mint_test_proofs(&mint, proof_amount).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+
+    // Create quote with specific amount
+    let quote_amount = Amount::from(9_000);
+    let quote = create_test_melt_quote(&mint, quote_amount).await;
+
+    // Create melt request
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup melt saga
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+
+    // STEP 3: Retrieve saga from database
+    let persisted_saga = assert_saga_exists(&mint, &operation_id).await;
+
+    // STEP 4: Verify operation_id matches exactly
+    assert_eq!(
+        persisted_saga.operation_id, operation_id,
+        "Operation ID should match exactly"
+    );
+
+    // STEP 5: Verify operation_kind is Melt
+    assert_eq!(
+        persisted_saga.operation_kind,
+        OperationKind::Melt,
+        "Operation kind must be Melt"
+    );
+
+    // STEP 6: Verify state is SetupComplete
+    match &persisted_saga.state {
+        cdk_common::mint::SagaStateEnum::Melt(state) => {
+            assert_eq!(
+                *state,
+                MeltSagaState::SetupComplete,
+                "State should be SetupComplete after setup"
+            );
+        }
+        _ => panic!("Expected Melt saga state, got {:?}", persisted_saga.state),
+    }
+
+    // STEP 7: Verify input_ys are stored correctly
+    assert_eq!(
+        persisted_saga.input_ys.len(),
+        input_ys.len(),
+        "Should store all input Ys"
+    );
+
+    // Verify each Y is present and in correct order
+    for (i, expected_y) in input_ys.iter().enumerate() {
+        assert!(
+            persisted_saga.input_ys.contains(expected_y),
+            "Input Y at index {} should be stored: {:?}",
+            i,
+            expected_y
+        );
+    }
+
+    // STEP 8: Verify timestamps are set and reasonable
+    let current_timestamp = std::time::SystemTime::now()
+        .duration_since(std::time::UNIX_EPOCH)
+        .unwrap()
+        .as_secs();
+
+    assert!(
+        persisted_saga.created_at > 0,
+        "Created timestamp should be set"
+    );
+    assert!(
+        persisted_saga.updated_at > 0,
+        "Updated timestamp should be set"
+    );
+
+    // Timestamps should be recent (within last hour)
+    assert!(
+        persisted_saga.created_at <= current_timestamp,
+        "Created timestamp should not be in the future"
+    );
+    assert!(
+        persisted_saga.created_at > current_timestamp - 3600,
+        "Created timestamp should be recent (within last hour)"
+    );
+
+    // For new saga, created_at and updated_at should match
+    assert_eq!(
+        persisted_saga.created_at, persisted_saga.updated_at,
+        "Timestamps should match for newly created saga"
+    );
+
+    // STEP 9: Verify blinded_secrets is empty (not used for melt)
+    assert!(
+        persisted_saga.blinded_secrets.is_empty(),
+        "Melt saga should not use blinded_secrets field"
+    );
+
+    // SUCCESS: All saga content validated!
+}
+
+/// Test: Saga timestamps remain consistent across retrievals
+///
+/// Note: The melt saga doesn't have intermediate state updates that persist
+/// to the database. It's created in SetupComplete state and then deleted on
+/// finalize. This test validates that timestamps remain consistent when
+/// retrieving the saga multiple times from the database.
+#[tokio::test]
+async fn test_saga_state_updates_timestamp() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup melt saga and note timestamps
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+
+    // STEP 3: Retrieve saga and note timestamps
+    let saga1 = assert_saga_exists(&mint, &operation_id).await;
+    let created_at_1 = saga1.created_at;
+    let updated_at_1 = saga1.updated_at;
+
+    // STEP 4: Wait a brief moment
+    tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
+
+    // STEP 5: Retrieve saga again
+    let saga2 = assert_saga_exists(&mint, &operation_id).await;
+    let created_at_2 = saga2.created_at;
+    let updated_at_2 = saga2.updated_at;
+
+    // STEP 6: Verify timestamps remain unchanged across retrievals
+    assert_eq!(
+        created_at_1, created_at_2,
+        "Created timestamp should not change across retrievals"
+    );
+    assert_eq!(
+        updated_at_1, updated_at_2,
+        "Updated timestamp should not change across retrievals"
+    );
+
+    // STEP 7: Verify timestamps are identical for new saga
+    assert_eq!(
+        created_at_1, updated_at_1,
+        "New saga should have matching created_at and updated_at"
+    );
+
+    // SUCCESS: Timestamps are consistent!
+}
+
+// ============================================================================
+// Query Tests
+// ============================================================================
+
+/// Test: get_incomplete_sagas returns only melt sagas
+///
+/// This test validates that the database query correctly filters sagas
+/// by operation kind, only returning melt sagas when requested.
+#[tokio::test]
+async fn test_get_incomplete_sagas_filters_by_kind() {
+    use crate::mint::swap::swap_saga::SwapSaga;
+    use crate::test_helpers::mint::create_test_blinded_messages;
+
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create a melt saga
+    let melt_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&melt_proofs, &melt_quote);
+
+    let melt_verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let melt_saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let melt_setup = melt_saga
+        .setup_melt(&melt_request, melt_verification)
+        .await
+        .unwrap();
+
+    let melt_operation_id = *melt_setup.operation.id();
+
+    // STEP 3: Create a swap saga
+    let swap_proofs = mint_test_proofs(&mint, Amount::from(5_000)).await.unwrap();
+    let swap_verification = crate::mint::Verification {
+        amount: Amount::from(5_000),
+        unit: Some(cdk_common::nuts::CurrencyUnit::Sat),
+    };
+
+    let (swap_outputs, _) = create_test_blinded_messages(&mint, Amount::from(5_000))
+        .await
+        .unwrap();
+
+    let swap_saga = SwapSaga::new(&mint, mint.localstore(), mint.pubsub_manager());
+    let _swap_setup = swap_saga
+        .setup_swap(&swap_proofs, &swap_outputs, None, swap_verification)
+        .await
+        .unwrap();
+
+    // STEP 4: Query for incomplete melt sagas
+    let melt_sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+
+    // STEP 5: Verify only melt saga is returned
+    assert_eq!(melt_sagas.len(), 1, "Should return exactly one melt saga");
+
+    assert_eq!(
+        melt_sagas[0].operation_id, melt_operation_id,
+        "Returned saga should be the melt saga"
+    );
+
+    assert_eq!(
+        melt_sagas[0].operation_kind,
+        OperationKind::Melt,
+        "Returned saga should have Melt kind"
+    );
+
+    // STEP 6: Query for incomplete swap sagas
+    let swap_sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .unwrap();
+
+    // STEP 7: Verify only swap saga is returned
+    assert_eq!(swap_sagas.len(), 1, "Should return exactly one swap saga");
+
+    assert_eq!(
+        swap_sagas[0].operation_kind,
+        OperationKind::Swap,
+        "Returned saga should have Swap kind"
+    );
+
+    // SUCCESS: Query correctly filters by operation kind!
+}
+
+/// Test: get_incomplete_sagas returns empty when none exist
+#[tokio::test]
+async fn test_get_incomplete_sagas_empty() {
+    let mint = create_test_mint().await.unwrap();
+
+    let sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+
+    assert!(sagas.is_empty(), "Should have no incomplete melt sagas");
+}
+
+// ============================================================================
+// Concurrent Operation Tests
+// ============================================================================
+
+/// Test: Multiple concurrent melt operations don't interfere
+#[tokio::test]
+async fn test_concurrent_melt_operations() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create 5 sets of proofs and quotes concurrently
+    // Using same amount for each to avoid FakeWallet limit issues
+    let mut tasks = Vec::new();
+
+    for _ in 0..5 {
+        let mint_clone = mint.clone();
+        let task = tokio::spawn(async move {
+            let proofs = mint_test_proofs(&mint_clone, Amount::from(10_000))
+                .await
+                .unwrap();
+            let quote = create_test_melt_quote(&mint_clone, Amount::from(9_000)).await;
+            (proofs, quote)
+        });
+        tasks.push(task);
+    }
+
+    let proof_quote_pairs: Vec<_> = futures::future::join_all(tasks)
+        .await
+        .into_iter()
+        .map(|r| r.unwrap())
+        .collect();
+
+    // STEP 3: Setup all melt sagas concurrently
+    let mut setup_tasks = Vec::new();
+
+    for (proofs, quote) in proof_quote_pairs {
+        let mint_clone = mint.clone();
+        let task = tokio::spawn(async move {
+            let melt_request = create_test_melt_request(&proofs, &quote);
+            let verification = mint_clone
+                .verify_inputs(melt_request.inputs())
+                .await
+                .unwrap();
+            let saga = MeltSaga::new(
+                std::sync::Arc::new(mint_clone.clone()),
+                mint_clone.localstore(),
+                mint_clone.pubsub_manager(),
+            );
+            let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+            let operation_id = *setup_saga.operation.id();
+            // Drop setup_saga before returning to avoid lifetime issues
+            drop(setup_saga);
+            operation_id
+        });
+        setup_tasks.push(task);
+    }
+
+    let operation_ids: Vec<_> = futures::future::join_all(setup_tasks)
+        .await
+        .into_iter()
+        .map(|r| r.unwrap())
+        .collect();
+
+    // STEP 4: Verify all operation_ids are unique
+    let unique_ids: std::collections::HashSet<_> = operation_ids.iter().collect();
+    assert_eq!(
+        unique_ids.len(),
+        operation_ids.len(),
+        "All operation IDs should be unique"
+    );
+
+    // STEP 5: Verify all sagas exist in database
+    let sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+    assert!(sagas.len() >= 5, "Should have at least 5 incomplete sagas");
+
+    for operation_id in &operation_ids {
+        assert!(
+            sagas.iter().any(|s| s.operation_id == *operation_id),
+            "Saga {} should exist in database",
+            operation_id
+        );
+    }
+
+    // SUCCESS: Concurrent operations work without interference!
+}
+
+/// Test: Concurrent recovery and new operations work together
+#[tokio::test]
+async fn test_concurrent_recovery_and_operations() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create incomplete saga
+    let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request1 = create_test_melt_request(&proofs1, &quote1);
+
+    let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
+    let saga1 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga1 = saga1
+        .setup_melt(&melt_request1, verification1)
+        .await
+        .unwrap();
+    let incomplete_operation_id = *setup_saga1.operation.id();
+
+    // Drop saga to simulate crash
+    drop(setup_saga1);
+
+    // Verify saga exists
+    assert_saga_exists(&mint, &incomplete_operation_id).await;
+
+    // STEP 3: Create tasks for concurrent recovery and new operation
+    let mint_for_recovery = mint.clone();
+    let recovery_task = tokio::spawn(async move {
+        mint_for_recovery
+            .recover_from_incomplete_melt_sagas()
+            .await
+            .expect("Recovery should succeed")
+    });
+
+    let mint_for_new_op = mint.clone();
+    let new_operation_task = tokio::spawn(async move {
+        let proofs2 = mint_test_proofs(&mint_for_new_op, Amount::from(10_000))
+            .await
+            .unwrap();
+        let quote2 = create_test_melt_quote(&mint_for_new_op, Amount::from(9_000)).await;
+        let melt_request2 = create_test_melt_request(&proofs2, &quote2);
+
+        let verification2 = mint_for_new_op
+            .verify_inputs(melt_request2.inputs())
+            .await
+            .unwrap();
+        let saga2 = MeltSaga::new(
+            std::sync::Arc::new(mint_for_new_op.clone()),
+            mint_for_new_op.localstore(),
+            mint_for_new_op.pubsub_manager(),
+        );
+        let setup_saga2 = saga2
+            .setup_melt(&melt_request2, verification2)
+            .await
+            .unwrap();
+        *setup_saga2.operation.id()
+    });
+
+    // STEP 4: Wait for both tasks to complete
+    let (recovery_result, new_op_result) = tokio::join!(recovery_task, new_operation_task);
+
+    recovery_result.expect("Recovery task should complete");
+    let new_operation_id = new_op_result.expect("New operation task should complete");
+
+    // STEP 5: Verify recovery completed
+    assert_saga_not_exists(&mint, &incomplete_operation_id).await;
+
+    // STEP 6: Verify new operation succeeded
+    assert_saga_exists(&mint, &new_operation_id).await;
+
+    // SUCCESS: Concurrent recovery and operations work together!
+}
+
+// ============================================================================
+// Failure Scenario Tests
+// ============================================================================
+
+/// Test: Double-spend detection during setup
+#[tokio::test]
+async fn test_double_spend_detection() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+
+    // STEP 2: Setup first melt saga with proofs
+    let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request1 = create_test_melt_request(&proofs, &quote1);
+
+    let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
+    let saga1 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let _setup_saga1 = saga1
+        .setup_melt(&melt_request1, verification1)
+        .await
+        .unwrap();
+
+    // Proofs should now be in PENDING state
+    let input_ys = proofs.ys().unwrap();
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 3: Try to setup second saga with same proofs
+    let quote2 = create_test_melt_quote(&mint, Amount::from(8_000)).await;
+    let melt_request2 = create_test_melt_request(&proofs, &quote2);
+
+    // STEP 4: verify_inputs succeeds (only checks signatures)
+    // but setup_melt should fail (checks proof states)
+    let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
+    let saga2 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+
+    // STEP 5: Verify second setup fails with appropriate error
+    assert!(
+        setup_result2.is_err(),
+        "Second melt with same proofs should fail during setup"
+    );
+
+    if let Err(error) = setup_result2 {
+        let error_msg = error.to_string().to_lowercase();
+        assert!(
+            error_msg.contains("pending")
+                || error_msg.contains("spent")
+                || error_msg.contains("state"),
+            "Error should mention proof state issue, got: {}",
+            error
+        );
+    }
+
+    // STEP 6: Verify first saga is unaffected - proofs still pending
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // SUCCESS: Double-spend prevented!
+}
+
+/// Test: Transaction balance validation
+///
+/// Note: This test verifies that the mint properly validates transaction balance.
+/// In the current implementation, balance checking happens during melt request
+/// validation before saga setup.
+#[tokio::test]
+async fn test_insufficient_funds() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create proofs
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let input_ys = proofs.ys().unwrap();
+
+    // STEP 3: Create quote
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+
+    // STEP 4: Setup a normal melt (this should succeed with sufficient funds)
+    let melt_request = create_test_melt_request(&proofs, &quote);
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_result = saga.setup_melt(&melt_request, verification).await;
+
+    // With 10000 msats input and 9000 msats quote, this should succeed
+    assert!(
+        setup_result.is_ok(),
+        "Setup should succeed with sufficient funds"
+    );
+
+    // Clean up
+    drop(setup_result);
+
+    // Verify proofs are now marked pending (setup succeeded)
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // SUCCESS: Balance validation works correctly!
+    // Note: Testing actual insufficient funds would require creating a quote
+    // that costs more than the proofs, but that's prevented at quote creation time
+}
+
+/// Test: Invalid quote ID rejection
+#[tokio::test]
+async fn test_invalid_quote_id() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+
+    // STEP 2: Create a melt request with non-existent quote ID
+    use cdk_common::nuts::MeltRequest;
+    use cdk_common::QuoteId;
+
+    let fake_quote_id = QuoteId::new_uuid();
+    let melt_request = MeltRequest::new(fake_quote_id.clone(), proofs.clone(), None);
+
+    // STEP 3: Try to setup melt saga (should fail due to invalid quote)
+    let verification_result = mint.verify_inputs(melt_request.inputs()).await;
+
+    // Verification might succeed (just checks signatures) or fail (if database issues)
+    if let Ok(verification) = verification_result {
+        let saga = MeltSaga::new(
+            std::sync::Arc::new(mint.clone()),
+            mint.localstore(),
+            mint.pubsub_manager(),
+        );
+        let setup_result = saga.setup_melt(&melt_request, verification).await;
+
+        // STEP 4: Verify setup fails with unknown quote error
+        assert!(
+            setup_result.is_err(),
+            "Setup should fail with invalid quote ID"
+        );
+
+        if let Err(error) = setup_result {
+            let error_msg = error.to_string().to_lowercase();
+            assert!(
+                error_msg.contains("quote")
+                    || error_msg.contains("unknown")
+                    || error_msg.contains("not found"),
+                "Error should mention quote issue, got: {}",
+                error
+            );
+        }
+
+        // Note: We don't query database state after a failed setup because
+        // the database may be in a transaction rollback state which can cause timeouts
+    } else {
+        // If verification fails due to database issues, that's also acceptable
+        // for this test (we're mainly testing quote validation)
+        eprintln!("Note: Verification failed (expected in some environments)");
+    }
+
+    // SUCCESS: Invalid quote ID handling works correctly!
+}
+
+/// Test: Quote already paid rejection
+#[tokio::test]
+async fn test_quote_already_paid() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create and complete a full melt operation
+    let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request1 = create_test_melt_request(&proofs1, &quote);
+
+    // Complete the full melt flow
+    let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
+    let saga1 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga1 = saga1
+        .setup_melt(&melt_request1, verification1)
+        .await
+        .unwrap();
+
+    let (payment_saga, decision) = setup_saga1
+        .attempt_internal_settlement(&melt_request1)
+        .await
+        .unwrap();
+
+    let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
+    let _response = confirmed_saga.finalize().await.unwrap();
+
+    // Verify quote is now paid
+    let paid_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        paid_quote.state,
+        MeltQuoteState::Paid,
+        "Quote should be paid"
+    );
+
+    // STEP 3: Try to setup new melt saga with the already-paid quote
+    let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_request2 = create_test_melt_request(&proofs2, &paid_quote);
+
+    let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
+    let saga2 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+
+    // STEP 4: Verify setup fails
+    assert!(
+        setup_result2.is_err(),
+        "Setup should fail with already paid quote"
+    );
+
+    if let Err(error) = setup_result2 {
+        let error_msg = error.to_string().to_lowercase();
+        assert!(
+            error_msg.contains("paid")
+                || error_msg.contains("quote")
+                || error_msg.contains("state"),
+            "Error should mention paid quote, got: {}",
+            error
+        );
+    }
+
+    // SUCCESS: Already paid quote rejected!
+}
+
+/// Test: Quote already pending rejection
+#[tokio::test]
+async fn test_quote_already_pending() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Setup first melt saga (this puts quote in PENDING state)
+    let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request1 = create_test_melt_request(&proofs1, &quote);
+
+    let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap();
+    let saga1 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let _setup_saga1 = saga1
+        .setup_melt(&melt_request1, verification1)
+        .await
+        .unwrap();
+
+    // Verify quote is now pending
+    let pending_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        pending_quote.state,
+        MeltQuoteState::Pending,
+        "Quote should be pending"
+    );
+
+    // STEP 3: Try to setup second saga with same quote (different proofs)
+    let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let melt_request2 = create_test_melt_request(&proofs2, &pending_quote);
+
+    let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap();
+    let saga2 = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await;
+
+    // STEP 4: Verify second setup fails
+    assert!(
+        setup_result2.is_err(),
+        "Setup should fail with pending quote"
+    );
+
+    if let Err(error) = setup_result2 {
+        let error_msg = error.to_string().to_lowercase();
+        assert!(
+            error_msg.contains("pending")
+                || error_msg.contains("quote")
+                || error_msg.contains("state"),
+            "Error should mention pending quote, got: {}",
+            error
+        );
+    }
+
+    // SUCCESS: Concurrent quote use prevented!
+}
+
+// ============================================================================
+// Edge Cases
+// ============================================================================
+
+/// Test: Empty input proofs
+#[tokio::test]
+async fn test_empty_inputs() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create a melt request with empty proofs
+    use cdk_common::nuts::{MeltRequest, Proofs};
+
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let empty_proofs = Proofs::new();
+
+    let melt_request = MeltRequest::new(quote.id.clone(), empty_proofs, None);
+
+    // STEP 3: Try to verify inputs (should fail with empty proofs)
+    let verification_result = mint.verify_inputs(melt_request.inputs()).await;
+
+    // Verification should fail with empty inputs
+    assert!(
+        verification_result.is_err(),
+        "Verification should fail with empty proofs"
+    );
+
+    let error = verification_result.unwrap_err();
+    let error_msg = error.to_string().to_lowercase();
+    assert!(
+        error_msg.contains("empty") || error_msg.contains("no") || error_msg.contains("input"),
+        "Error should mention empty inputs, got: {}",
+        error
+    );
+
+    // STEP 4: Verify no saga persisted
+    let sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+    assert!(sagas.is_empty(), "No saga should be persisted");
+
+    // SUCCESS: Empty inputs rejected!
+}
+
+/// Test: Recovery with empty input_ys in saga
+#[tokio::test]
+async fn test_recovery_empty_input_ys() {
+    // TODO: Implement this test
+    // 1. Manually create saga with empty input_ys
+    // 2. Run recovery
+    // 3. Verify saga is skipped gracefully
+    // 4. Verify logged warning
+}
+
+/// Test: Saga with no change outputs (simple melt scenario)
+///
+/// This test verifies that recovery works correctly when there are no
+/// change outputs to clean up (e.g., when input amount exactly matches quote amount)
+#[tokio::test]
+async fn test_recovery_no_melt_request() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // Create proofs that exactly match the quote amount (no change needed)
+    let amount = Amount::from(10_000);
+    let proofs = mint_test_proofs(&mint, amount).await.unwrap();
+    let quote = create_test_melt_quote(&mint, amount).await;
+
+    // Create melt request without change outputs
+    let melt_request = create_test_melt_request(&proofs, &quote);
+    assert!(
+        melt_request.outputs().is_none(),
+        "Should have no change outputs"
+    );
+
+    // STEP 2: Create incomplete saga
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+    let input_ys = proofs.ys().unwrap();
+
+    // Drop saga (simulate crash)
+    drop(setup_saga);
+
+    // Verify saga exists
+    assert_saga_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 3: Run recovery
+    // Should handle gracefully even with no change outputs to clean up
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed without change outputs");
+
+    // STEP 4: Verify recovery completed successfully
+    assert_saga_not_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    // SUCCESS: Recovery works even without change outputs!
+}
+
+// ============================================================================
+// Integration with check_pending_melt_quotes
+// ============================================================================
+
+/// Test: Saga recovery runs before quote checking on startup
+///
+/// This test verifies that saga recovery executes before quote checking,
+/// preventing conflicts where both mechanisms might try to handle the same state.
+#[tokio::test]
+async fn test_recovery_order_on_startup() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create incomplete saga with a pending quote
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+    let input_ys = proofs.ys().unwrap();
+
+    // Drop saga (simulate crash) - this leaves quote in PENDING state
+    drop(setup_saga);
+
+    // Verify initial state: saga exists, quote is pending, proofs are pending
+    assert_saga_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    let pending_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        pending_quote.state,
+        MeltQuoteState::Pending,
+        "Quote should be pending"
+    );
+
+    // STEP 3: Manually trigger recovery (simulating startup)
+    // Note: In production, mint.start() calls this automatically
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 4: Verify saga recovery completed correctly
+    // - Saga should be deleted
+    // - Proofs should be removed (returned to client)
+    // - Quote should be reset to UNPAID
+    assert_saga_not_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    let recovered_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(
+        recovered_quote.state,
+        MeltQuoteState::Unpaid,
+        "Quote should be reset to unpaid"
+    );
+
+    // STEP 5: Verify no conflicts - system is in consistent state
+    // Quote can be used again with new proofs
+    let new_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let new_request = create_test_melt_request(&new_proofs, &recovered_quote);
+
+    let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap();
+    let new_saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let _new_setup = new_saga
+        .setup_melt(&new_request, new_verification)
+        .await
+        .unwrap();
+
+    // SUCCESS: Recovery order is correct, no conflicts!
+}
+
+/// Test: Saga recovery and quote checking don't duplicate work
+///
+/// This test verifies that compensation is idempotent - running recovery
+/// multiple times doesn't cause errors or duplicate work.
+#[tokio::test]
+async fn test_no_duplicate_recovery() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create incomplete saga with pending quote
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+    let operation_id = *setup_saga.operation.id();
+    let input_ys = proofs.ys().unwrap();
+
+    // Drop saga (simulate crash)
+    drop(setup_saga);
+
+    // Verify saga exists and proofs are pending
+    assert_saga_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await;
+
+    // STEP 3: Run recovery first time
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("First recovery should succeed");
+
+    // Verify saga deleted and proofs removed
+    assert_saga_not_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    let recovered_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(recovered_quote.state, MeltQuoteState::Unpaid);
+
+    // STEP 4: Run recovery again (simulating duplicate execution)
+    // Should be idempotent - no errors even though saga is already cleaned up
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Second recovery should succeed (idempotent)");
+
+    // STEP 5: Verify state unchanged - still consistent
+    assert_saga_not_exists(&mint, &operation_id).await;
+    assert_proofs_state(&mint, &input_ys, None).await;
+
+    let still_recovered_quote = mint
+        .localstore
+        .get_melt_quote(&quote.id)
+        .await
+        .unwrap()
+        .unwrap();
+    assert_eq!(still_recovered_quote.state, MeltQuoteState::Unpaid);
+
+    // SUCCESS: Recovery is idempotent, no duplicate work or errors!
+}
+
+// ============================================================================
+// Production Readiness Tests
+// ============================================================================
+
+/// Test: Operation ID uniqueness across multiple sagas
+#[tokio::test]
+async fn test_operation_id_uniqueness_and_tracking() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+
+    // STEP 2: Create 10 sagas and collect their operation IDs
+    // Using same amount for each to avoid FakeWallet limit issues
+    let mut operation_ids = Vec::new();
+
+    for _ in 0..10 {
+        let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+        let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+        let melt_request = create_test_melt_request(&proofs, &quote);
+
+        let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+        let saga = MeltSaga::new(
+            std::sync::Arc::new(mint.clone()),
+            mint.localstore(),
+            mint.pubsub_manager(),
+        );
+        let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+
+        let operation_id = *setup_saga.operation.id();
+        operation_ids.push(operation_id);
+
+        // Keep saga alive
+        drop(setup_saga);
+    }
+
+    // STEP 3: Verify all operation IDs are unique
+    let unique_ids: std::collections::HashSet<_> = operation_ids.iter().collect();
+    assert_eq!(
+        unique_ids.len(),
+        operation_ids.len(),
+        "All {} operation IDs should be unique",
+        operation_ids.len()
+    );
+
+    // STEP 4: Verify all sagas are trackable in database
+    let sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+
+    for operation_id in &operation_ids {
+        assert!(
+            sagas.iter().any(|s| s.operation_id == *operation_id),
+            "Saga {} should be trackable in database",
+            operation_id
+        );
+    }
+
+    // SUCCESS: All operation IDs are unique and trackable!
+}
+
+/// Test: Saga drop without finalize doesn't panic
+#[tokio::test]
+async fn test_saga_drop_without_finalize() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup saga
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let operation_id = *setup_saga.operation.id();
+
+    // STEP 3: Drop saga without finalizing (simulates crash)
+    drop(setup_saga);
+
+    // STEP 4: Verify no panic occurred and saga remains in database
+    let saga_in_db = assert_saga_exists(&mint, &operation_id).await;
+    assert_eq!(saga_in_db.operation_id, operation_id);
+
+    // SUCCESS: Drop without finalize doesn't panic!
+}
+
+/// Test: Saga drop after payment is recoverable
+#[tokio::test]
+async fn test_saga_drop_after_payment() {
+    // STEP 1: Setup test environment
+    let mint = create_test_mint().await.unwrap();
+    let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap();
+    let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await;
+    let melt_request = create_test_melt_request(&proofs, &quote);
+
+    // STEP 2: Setup saga and make payment
+    let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap();
+    let saga = MeltSaga::new(
+        std::sync::Arc::new(mint.clone()),
+        mint.localstore(),
+        mint.pubsub_manager(),
+    );
+    let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap();
+    let operation_id = *setup_saga.operation.id();
+
+    // Attempt internal settlement
+    let (payment_saga, decision) = setup_saga
+        .attempt_internal_settlement(&melt_request)
+        .await
+        .unwrap();
+
+    // Make payment
+    let confirmed_saga = payment_saga.make_payment(decision).await.unwrap();
+
+    // STEP 3: Drop before finalize (simulates crash after payment)
+    drop(confirmed_saga);
+
+    // STEP 4: Verify saga still exists (wasn't finalized)
+    let saga_in_db = assert_saga_exists(&mint, &operation_id).await;
+    assert_eq!(saga_in_db.operation_id, operation_id);
+
+    // STEP 5: Run recovery to complete the operation
+    mint.recover_from_incomplete_melt_sagas()
+        .await
+        .expect("Recovery should succeed");
+
+    // STEP 6: Verify saga was recovered and cleaned up
+    assert_saga_not_exists(&mint, &operation_id).await;
+
+    // SUCCESS: Drop after payment is recoverable!
+}
+
+// ============================================================================
+// Test Helpers
+// ============================================================================
+
+/// Helper: Create a test melt quote
+///
+/// # Arguments
+/// * `mint` - Test mint instance
+/// * `amount` - Amount in sats for the quote
+///
+/// # Returns
+/// A valid unpaid melt quote
+///
+/// # How it works
+/// Uses `create_fake_invoice()` from cdk-fake-wallet to generate a valid
+/// bolt11 invoice that FakeWallet will process. The FakeInvoiceDescription
+/// controls payment behavior (success/failure).
+async fn create_test_melt_quote(
+    mint: &crate::mint::Mint,
+    amount: Amount,
+) -> cdk_common::mint::MeltQuote {
+    use cdk_common::melt::MeltQuoteRequest;
+    use cdk_common::nuts::MeltQuoteBolt11Request;
+    use cdk_common::CurrencyUnit;
+    use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription};
+
+    // Create fake invoice description (controls payment behavior)
+    let fake_description = FakeInvoiceDescription {
+        pay_invoice_state: MeltQuoteState::Paid, // Payment will succeed
+        check_payment_state: MeltQuoteState::Paid, // Check will show paid
+        pay_err: false,                          // No payment error
+        check_err: false,                        // No check error
+    };
+
+    // Create valid bolt11 invoice (amount in millisats)
+    // Amount is already in millisats, just convert to u64
+    let amount_msats: u64 = amount.into();
+    let invoice = create_fake_invoice(
+        amount_msats,
+        serde_json::to_string(&fake_description).unwrap(),
+    );
+
+    // Create melt quote request
+    let bolt11_request = MeltQuoteBolt11Request {
+        request: invoice,
+        unit: CurrencyUnit::Sat,
+        options: None,
+    };
+
+    let request = MeltQuoteRequest::Bolt11(bolt11_request);
+
+    // Get quote from mint
+    let quote_response = mint.get_melt_quote(request).await.unwrap();
+
+    // Retrieve the full quote from database
+    let quote = mint
+        .localstore
+        .get_melt_quote(&quote_response.quote)
+        .await
+        .unwrap()
+        .expect("Quote should exist in database");
+
+    quote
+}
+
+/// Helper: Create a test melt request
+///
+/// # Arguments
+/// * `proofs` - Input proofs for the melt
+/// * `quote` - Melt quote to use
+///
+/// # Returns
+/// A MeltRequest ready to be used with setup_melt()
+fn create_test_melt_request(
+    proofs: &cdk_common::nuts::Proofs,
+    quote: &cdk_common::mint::MeltQuote,
+) -> cdk_common::nuts::MeltRequest<cdk_common::QuoteId> {
+    use cdk_common::nuts::MeltRequest;
+
+    MeltRequest::new(
+        quote.id.clone(),
+        proofs.clone(),
+        None, // No change outputs for simplicity in tests
+    )
+}
+
+/// Helper: Verify saga exists in database
+async fn assert_saga_exists(mint: &crate::mint::Mint, operation_id: &uuid::Uuid) -> Saga {
+    let sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+
+    sagas
+        .into_iter()
+        .find(|s| s.operation_id == *operation_id)
+        .expect("Saga should exist in database")
+}
+
+/// Helper: Verify saga does not exist in database
+async fn assert_saga_not_exists(mint: &crate::mint::Mint, operation_id: &uuid::Uuid) {
+    let sagas = mint
+        .localstore
+        .get_incomplete_sagas(OperationKind::Melt)
+        .await
+        .unwrap();
+
+    assert!(
+        !sagas.iter().any(|s| s.operation_id == *operation_id),
+        "Saga should not exist in database"
+    );
+}
+
+/// Helper: Verify proofs are in expected state
+async fn assert_proofs_state(
+    mint: &crate::mint::Mint,
+    ys: &[cdk_common::PublicKey],
+    expected_state: Option<State>,
+) {
+    let states = mint.localstore.get_proofs_states(ys).await.unwrap();
+
+    for state in states {
+        assert_eq!(state, expected_state, "Proof state mismatch");
+    }
+}

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

@@ -0,0 +1,554 @@
+use std::str::FromStr;
+
+use cdk_common::amount::amount_for_offer;
+use cdk_common::melt::MeltQuoteRequest;
+use cdk_common::mint::MeltPaymentRequest;
+use cdk_common::nut05::MeltMethodOptions;
+use cdk_common::payment::{
+    Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, OutgoingPaymentOptions,
+};
+use cdk_common::quote_id::QuoteId;
+use cdk_common::{MeltOptions, MeltQuoteBolt12Request, SpendingConditionVerification};
+#[cfg(feature = "prometheus")]
+use cdk_prometheus::METRICS;
+use lightning::offers::offer::Offer;
+use tracing::instrument;
+
+use super::{
+    CurrencyUnit, MeltQuote, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, Mint,
+    PaymentMethod,
+};
+use crate::amount::to_unit;
+use crate::nuts::MeltQuoteState;
+use crate::types::PaymentProcessorKey;
+use crate::util::unix_time;
+use crate::{ensure_cdk, Amount, Error};
+
+pub(crate) mod melt_saga;
+pub(crate) mod shared;
+
+#[cfg(test)]
+mod tests;
+
+use melt_saga::MeltSaga;
+
+impl Mint {
+    #[instrument(skip_all)]
+    async fn check_melt_request_acceptable(
+        &self,
+        amount: Amount,
+        unit: CurrencyUnit,
+        method: PaymentMethod,
+        request: String,
+        options: Option<MeltOptions>,
+    ) -> Result<(), Error> {
+        let mint_info = self.mint_info().await?;
+        let nut05 = mint_info.nuts.nut05;
+
+        ensure_cdk!(!nut05.disabled, Error::MeltingDisabled);
+
+        let settings = nut05
+            .get_settings(&unit, &method)
+            .ok_or(Error::UnsupportedUnit)?;
+
+        let amount = match options {
+            Some(MeltOptions::Mpp { mpp: _ }) => {
+                let nut15 = mint_info.nuts.nut15;
+                // Verify there is no corresponding mint quote.
+                // Otherwise a wallet is trying to pay someone internally, but
+                // with a multi-part quote. And that's just not possible.
+                if (self.localstore.get_mint_quote_by_request(&request).await?).is_some() {
+                    return Err(Error::InternalMultiPartMeltQuote);
+                }
+                // Verify MPP is enabled for unit and method
+                if !nut15
+                    .methods
+                    .into_iter()
+                    .any(|m| m.method == method && m.unit == unit)
+                {
+                    return Err(Error::MppUnitMethodNotSupported(unit, method));
+                }
+                // Assign `amount`
+                // because should have already been converted to the partial amount
+                amount
+            }
+            Some(MeltOptions::Amountless { amountless: _ }) => {
+                if method == PaymentMethod::Bolt11
+                    && !matches!(
+                        settings.options,
+                        Some(MeltMethodOptions::Bolt11 { amountless: true })
+                    )
+                {
+                    return Err(Error::AmountlessInvoiceNotSupported(unit, method));
+                }
+
+                amount
+            }
+            None => amount,
+        };
+
+        let is_above_max = matches!(settings.max_amount, Some(max) if amount > max);
+        let is_below_min = matches!(settings.min_amount, Some(min) if amount < min);
+        match is_above_max || is_below_min {
+            true => {
+                tracing::error!(
+                    "Melt amount out of range: {} is not within {} and {}",
+                    amount,
+                    settings.min_amount.unwrap_or_default(),
+                    settings.max_amount.unwrap_or_default(),
+                );
+                Err(Error::AmountOutofLimitRange(
+                    settings.min_amount.unwrap_or_default(),
+                    settings.max_amount.unwrap_or_default(),
+                    amount,
+                ))
+            }
+            false => Ok(()),
+        }
+    }
+
+    /// Get melt quote for either BOLT11 or BOLT12
+    ///
+    /// This function accepts a `MeltQuoteRequest` enum and delegates to the
+    /// appropriate handler based on the request type.
+    #[instrument(skip_all)]
+    pub async fn get_melt_quote(
+        &self,
+        melt_quote_request: MeltQuoteRequest,
+    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        match melt_quote_request {
+            MeltQuoteRequest::Bolt11(bolt11_request) => {
+                self.get_melt_bolt11_quote_impl(&bolt11_request).await
+            }
+            MeltQuoteRequest::Bolt12(bolt12_request) => {
+                self.get_melt_bolt12_quote_impl(&bolt12_request).await
+            }
+        }
+    }
+
+    /// Implementation of get_melt_bolt11_quote
+    #[instrument(skip_all)]
+    async fn get_melt_bolt11_quote_impl(
+        &self,
+        melt_request: &MeltQuoteBolt11Request,
+    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        #[cfg(feature = "prometheus")]
+        METRICS.inc_in_flight_requests("get_melt_bolt11_quote");
+        let MeltQuoteBolt11Request {
+            request,
+            unit,
+            options,
+            ..
+        } = melt_request;
+
+        let ln = self
+            .payment_processors
+            .get(&PaymentProcessorKey::new(
+                unit.clone(),
+                PaymentMethod::Bolt11,
+            ))
+            .ok_or_else(|| {
+                tracing::info!("Could not get ln backend for {}, bolt11 ", unit);
+
+                Error::UnsupportedUnit
+            })?;
+
+        let bolt11 = Bolt11OutgoingPaymentOptions {
+            bolt11: melt_request.request.clone(),
+            max_fee_amount: None,
+            timeout_secs: None,
+            melt_options: melt_request.options,
+        };
+
+        let payment_quote = ln
+            .get_payment_quote(
+                &melt_request.unit,
+                OutgoingPaymentOptions::Bolt11(Box::new(bolt11)),
+            )
+            .await
+            .map_err(|err| {
+                tracing::error!(
+                    "Could not get payment quote for mint quote, {} bolt11, {}",
+                    unit,
+                    err
+                );
+
+                #[cfg(feature = "prometheus")]
+                {
+                    METRICS.dec_in_flight_requests("get_melt_bolt11_quote");
+                    METRICS.record_mint_operation("get_melt_bolt11_quote", false);
+                    METRICS.record_error();
+                }
+                err
+            })?;
+
+        if &payment_quote.unit != unit {
+            return Err(Error::UnitMismatch);
+        }
+
+        // Validate using processor quote amount for currency conversion
+        self.check_melt_request_acceptable(
+            payment_quote.amount,
+            unit.clone(),
+            PaymentMethod::Bolt11,
+            request.to_string(),
+            *options,
+        )
+        .await?;
+
+        let melt_ttl = self.quote_ttl().await?.melt_ttl;
+
+        let quote = MeltQuote::new(
+            MeltPaymentRequest::Bolt11 {
+                bolt11: request.clone(),
+            },
+            unit.clone(),
+            payment_quote.amount,
+            payment_quote.fee,
+            unix_time() + melt_ttl,
+            payment_quote.request_lookup_id.clone(),
+            *options,
+            PaymentMethod::Bolt11,
+        );
+
+        tracing::debug!(
+            "New {} melt quote {} for {} {} with request id {:?}",
+            quote.payment_method,
+            quote.id,
+            payment_quote.amount,
+            unit,
+            payment_quote.request_lookup_id
+        );
+
+        let mut tx = self.localstore.begin_transaction().await?;
+        tx.add_melt_quote(quote.clone()).await?;
+        tx.commit().await?;
+
+        Ok(quote.into())
+    }
+
+    /// Implementation of get_melt_bolt12_quote
+    #[instrument(skip_all)]
+    async fn get_melt_bolt12_quote_impl(
+        &self,
+        melt_request: &MeltQuoteBolt12Request,
+    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        let MeltQuoteBolt12Request {
+            request,
+            unit,
+            options,
+        } = melt_request;
+
+        let offer = Offer::from_str(request).map_err(|_| Error::InvalidPaymentRequest)?;
+
+        let amount = match options {
+            Some(options) => match options {
+                MeltOptions::Amountless { amountless } => {
+                    to_unit(amountless.amount_msat, &CurrencyUnit::Msat, unit)?
+                }
+                _ => return Err(Error::UnsupportedUnit),
+            },
+            None => amount_for_offer(&offer, unit).map_err(|_| Error::UnsupportedUnit)?,
+        };
+
+        let ln = self
+            .payment_processors
+            .get(&PaymentProcessorKey::new(
+                unit.clone(),
+                PaymentMethod::Bolt12,
+            ))
+            .ok_or_else(|| {
+                tracing::info!("Could not get ln backend for {}, bolt12 ", unit);
+
+                Error::UnsupportedUnit
+            })?;
+
+        let offer = Offer::from_str(&melt_request.request).map_err(|_| Error::Bolt12parse)?;
+
+        let outgoing_payment_options = Bolt12OutgoingPaymentOptions {
+            offer: offer.clone(),
+            max_fee_amount: None,
+            timeout_secs: None,
+            melt_options: *options,
+        };
+
+        let payment_quote = ln
+            .get_payment_quote(
+                &melt_request.unit,
+                OutgoingPaymentOptions::Bolt12(Box::new(outgoing_payment_options)),
+            )
+            .await
+            .map_err(|err| {
+                tracing::error!(
+                    "Could not get payment quote for mint quote, {} bolt12, {}",
+                    unit,
+                    err
+                );
+
+                err
+            })?;
+
+        if &payment_quote.unit != unit {
+            return Err(Error::UnitMismatch);
+        }
+
+        // Validate using processor quote amount for currency conversion
+        self.check_melt_request_acceptable(
+            payment_quote.amount,
+            unit.clone(),
+            PaymentMethod::Bolt12,
+            request.clone(),
+            *options,
+        )
+        .await?;
+
+        let payment_request = MeltPaymentRequest::Bolt12 {
+            offer: Box::new(offer),
+        };
+
+        let quote = MeltQuote::new(
+            payment_request,
+            unit.clone(),
+            payment_quote.amount,
+            payment_quote.fee,
+            unix_time() + self.quote_ttl().await?.melt_ttl,
+            payment_quote.request_lookup_id.clone(),
+            *options,
+            PaymentMethod::Bolt12,
+        );
+
+        tracing::debug!(
+            "New {} melt quote {} for {} {} with request id {:?}",
+            quote.payment_method,
+            quote.id,
+            amount,
+            unit,
+            payment_quote.request_lookup_id
+        );
+
+        let mut tx = self.localstore.begin_transaction().await?;
+        tx.add_melt_quote(quote.clone()).await?;
+        tx.commit().await?;
+
+        #[cfg(feature = "prometheus")]
+        {
+            METRICS.dec_in_flight_requests("get_melt_bolt11_quote");
+            METRICS.record_mint_operation("get_melt_bolt11_quote", true);
+        }
+
+        Ok(quote.into())
+    }
+
+    /// Check melt quote status
+    #[instrument(skip(self))]
+    pub async fn check_melt_quote(
+        &self,
+        quote_id: &QuoteId,
+    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        #[cfg(feature = "prometheus")]
+        METRICS.inc_in_flight_requests("check_melt_quote");
+        let quote = match self.localstore.get_melt_quote(quote_id).await {
+            Ok(Some(quote)) => quote,
+            Ok(None) => {
+                #[cfg(feature = "prometheus")]
+                {
+                    METRICS.dec_in_flight_requests("check_melt_quote");
+                    METRICS.record_mint_operation("check_melt_quote", false);
+                    METRICS.record_error();
+                }
+                return Err(Error::UnknownQuote);
+            }
+            Err(err) => {
+                #[cfg(feature = "prometheus")]
+                {
+                    METRICS.dec_in_flight_requests("check_melt_quote");
+                    METRICS.record_mint_operation("check_melt_quote", false);
+                    METRICS.record_error();
+                }
+                return Err(err.into());
+            }
+        };
+
+        let blind_signatures = match self
+            .localstore
+            .get_blind_signatures_for_quote(quote_id)
+            .await
+        {
+            Ok(signatures) => signatures,
+            Err(err) => {
+                #[cfg(feature = "prometheus")]
+                {
+                    METRICS.dec_in_flight_requests("check_melt_quote");
+                    METRICS.record_mint_operation("check_melt_quote", false);
+                    METRICS.record_error();
+                }
+                return Err(err.into());
+            }
+        };
+
+        let change = (!blind_signatures.is_empty()).then_some(blind_signatures);
+
+        let response = MeltQuoteBolt11Response {
+            quote: quote.id,
+            paid: Some(quote.state == MeltQuoteState::Paid),
+            state: quote.state,
+            expiry: quote.expiry,
+            amount: quote.amount,
+            fee_reserve: quote.fee_reserve,
+            payment_preimage: quote.payment_preimage,
+            change,
+            request: Some(quote.request.to_string()),
+            unit: Some(quote.unit.clone()),
+        };
+
+        #[cfg(feature = "prometheus")]
+        {
+            METRICS.dec_in_flight_requests("check_melt_quote");
+            METRICS.record_mint_operation("check_melt_quote", true);
+        }
+
+        Ok(response)
+    }
+
+    /// Get melt quotes
+    #[instrument(skip_all)]
+    pub async fn melt_quotes(&self) -> Result<Vec<MeltQuote>, Error> {
+        let quotes = self.localstore.get_melt_quotes().await?;
+        Ok(quotes)
+    }
+
+    /// Melt
+    ///
+    /// Uses MeltSaga typestate pattern for atomic transaction handling with automatic rollback on failure.
+    #[instrument(skip_all)]
+    pub async fn melt(
+        &self,
+        melt_request: &MeltRequest<QuoteId>,
+    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        // Verify spending conditions (NUT-10/NUT-11/NUT-14), i.e. P2PK
+        // and HTLC (including SIGALL)
+        melt_request.verify_spending_conditions()?;
+
+        // We don't need to check P2PK or HTLC again. It has all been checked above
+        // and the code doesn't reach here unless such verifications were satisfactory
+
+        let verification = self.verify_inputs(melt_request.inputs()).await?;
+
+        let init_saga = MeltSaga::new(
+            std::sync::Arc::new(self.clone()),
+            self.localstore.clone(),
+            std::sync::Arc::clone(&self.pubsub_manager),
+        );
+
+        // Step 1: Setup (TX1 - reserves inputs and outputs)
+        let setup_saga = init_saga.setup_melt(melt_request, verification).await?;
+
+        // Step 2: Attempt internal settlement (returns saga + SettlementDecision)
+        // Note: Compensation is handled internally if this fails
+        let (setup_saga, settlement) = setup_saga.attempt_internal_settlement(melt_request).await?;
+
+        // Step 3: Make payment (internal or external)
+        let payment_saga = setup_saga.make_payment(settlement).await?;
+
+        // Step 4: Finalize (TX2 - marks spent, issues change)
+        payment_saga.finalize().await
+    }
+
+    /// Process melt asynchronously - returns immediately after setup with PENDING state
+    ///
+    /// This method is called when the client includes the `Prefer: respond-async` header.
+    /// It performs the setup phase (TX1) to validate and reserve proofs, then spawns a
+    /// background task to complete the payment and finalization phases.
+    pub async fn melt_async(
+        &self,
+        melt_request: &MeltRequest<QuoteId>,
+    ) -> Result<MeltQuoteBolt11Response<QuoteId>, Error> {
+        let verification = self.verify_inputs(melt_request.inputs()).await?;
+
+        let init_saga = MeltSaga::new(
+            std::sync::Arc::new(self.clone()),
+            self.localstore.clone(),
+            std::sync::Arc::clone(&self.pubsub_manager),
+        );
+
+        let setup_saga = init_saga.setup_melt(melt_request, verification).await?;
+
+        // Get the quote to return with PENDING state
+        let quote_id = melt_request.quote().clone();
+        let quote = self
+            .localstore
+            .get_melt_quote(&quote_id)
+            .await?
+            .ok_or(Error::UnknownQuote)?;
+
+        // Spawn background task to complete the melt operation
+        let melt_request_clone = melt_request.clone();
+        let quote_id_clone = quote_id.clone();
+        tokio::spawn(async move {
+            tracing::debug!(
+                "Starting background melt completion for quote: {}",
+                quote_id_clone
+            );
+
+            // Step 2: Attempt internal settlement
+            match setup_saga
+                .attempt_internal_settlement(&melt_request_clone)
+                .await
+            {
+                Ok((setup_saga, settlement)) => {
+                    // Step 3: Make payment
+                    match setup_saga.make_payment(settlement).await {
+                        Ok(payment_saga) => {
+                            // Step 4: Finalize
+                            match payment_saga.finalize().await {
+                                Ok(_) => {
+                                    tracing::info!(
+                                        "Background melt completed successfully for quote: {}",
+                                        quote_id_clone
+                                    );
+                                }
+                                Err(e) => {
+                                    tracing::error!(
+                                        "Failed to finalize melt for quote {}: {}",
+                                        quote_id_clone,
+                                        e
+                                    );
+                                }
+                            }
+                        }
+                        Err(e) => {
+                            tracing::error!(
+                                "Failed to make payment for quote {}: {}",
+                                quote_id_clone,
+                                e
+                            );
+                        }
+                    }
+                }
+                Err(e) => {
+                    tracing::error!(
+                        "Failed internal settlement for quote {}: {}",
+                        quote_id_clone,
+                        e
+                    );
+                }
+            }
+        });
+
+        debug_assert!(quote.state == MeltQuoteState::Pending);
+
+        // Return immediately with the quote in PENDING state
+        Ok(MeltQuoteBolt11Response {
+            quote: quote_id,
+            amount: quote.amount,
+            fee_reserve: quote.fee_reserve,
+            state: quote.state,
+            paid: Some(false),
+            expiry: quote.expiry,
+            payment_preimage: None,
+            change: None,
+            request: Some(quote.request.to_string()),
+            unit: Some(quote.unit),
+        })
+    }
+}

+ 443 - 0
crates/cdk/src/mint/melt/shared.rs

@@ -0,0 +1,443 @@
+//! Shared logic for melt operations across saga and startup check.
+//!
+//! This module contains common functions used by both:
+//! - `melt_saga`: Normal melt operation flow
+//! - `start_up_check`: Recovery of interrupted melts during startup
+//!
+//! The functions here ensure consistency between these two code paths.
+
+use cdk_common::database::{self, DynMintDatabase};
+use cdk_common::nuts::{BlindSignature, BlindedMessage, MeltQuoteState, State};
+use cdk_common::{Amount, Error, PublicKey, QuoteId};
+use cdk_signatory::signatory::SignatoryKeySet;
+
+use crate::mint::subscription::PubSubManager;
+use crate::mint::MeltQuote;
+
+/// Retrieves fee and amount configuration for the keyset matching the change outputs.
+///
+/// Searches active keysets for one matching the first output's keyset_id.
+/// Used during change calculation for melts.
+///
+/// # Arguments
+///
+/// * `keysets` - Arc reference to the loaded keysets
+/// * `outputs` - Change output blinded messages
+///
+/// # Returns
+///
+/// Fee per thousand and allowed amounts for the keyset, or default if not found
+pub fn get_keyset_fee_and_amounts(
+    keysets: &arc_swap::ArcSwap<Vec<SignatoryKeySet>>,
+    outputs: &[BlindedMessage],
+) -> cdk_common::amount::FeeAndAmounts {
+    keysets
+        .load()
+        .iter()
+        .filter_map(|keyset| {
+            if keyset.active && Some(keyset.id) == outputs.first().map(|x| x.keyset_id) {
+                Some((keyset.input_fee_ppk, keyset.amounts.clone()).into())
+            } else {
+                None
+            }
+        })
+        .next()
+        .unwrap_or_else(|| (0, (0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>()).into())
+}
+
+/// Rolls back a melt quote by removing all setup artifacts and resetting state.
+///
+/// This function is used by both:
+/// - `melt_saga::compensation::RemoveMeltSetup` when saga fails
+/// - `start_up_check::rollback_failed_melt_quote` when recovering failed payments
+///
+/// # What This Does
+///
+/// Within a single database transaction:
+/// 1. Removes input proofs from database
+/// 2. Removes change output blinded messages
+/// 3. Resets quote state from Pending to Unpaid
+/// 4. Deletes melt request tracking record
+///
+/// This restores the database to its pre-melt state, allowing retry.
+///
+/// # Arguments
+///
+/// * `db` - Database connection
+/// * `quote_id` - ID of the quote to rollback
+/// * `input_ys` - Y values (public keys) from input proofs
+/// * `blinded_secrets` - Blinded secrets from change outputs
+///
+/// # Errors
+///
+/// Returns database errors if transaction fails
+pub async fn rollback_melt_quote(
+    db: &DynMintDatabase,
+    quote_id: &QuoteId,
+    input_ys: &[PublicKey],
+    blinded_secrets: &[PublicKey],
+) -> Result<(), Error> {
+    if input_ys.is_empty() && blinded_secrets.is_empty() {
+        return Ok(());
+    }
+
+    tracing::info!(
+        "Rolling back melt quote {} ({} proofs, {} blinded messages)",
+        quote_id,
+        input_ys.len(),
+        blinded_secrets.len()
+    );
+
+    let mut tx = db.begin_transaction().await?;
+
+    // Remove input proofs
+    if !input_ys.is_empty() {
+        tx.remove_proofs(input_ys, Some(quote_id.clone())).await?;
+    }
+
+    // Remove blinded messages (change outputs)
+    if !blinded_secrets.is_empty() {
+        tx.delete_blinded_messages(blinded_secrets).await?;
+    }
+
+    // Reset quote state from Pending to Unpaid
+    let (previous_state, _quote) = tx
+        .update_melt_quote_state(quote_id, MeltQuoteState::Unpaid, None)
+        .await?;
+
+    if previous_state != MeltQuoteState::Pending {
+        tracing::warn!(
+            "Unexpected quote state during rollback: expected Pending, got {}",
+            previous_state
+        );
+    }
+
+    // Delete melt request tracking record
+    tx.delete_melt_request(quote_id).await?;
+
+    tx.commit().await?;
+
+    tracing::info!("Successfully rolled back melt quote {}", quote_id);
+
+    Ok(())
+}
+
+/// Processes change for a melt operation.
+///
+/// This function handles the complete change workflow:
+/// 1. Calculate change target amount
+/// 2. Split into denominations based on keyset configuration
+/// 3. Sign change outputs (external call to blind_sign)
+/// 4. Store signatures in database (new transaction)
+///
+/// # Transaction Management
+///
+/// This function expects that the caller has already committed or will rollback
+/// their current transaction before calling. It will:
+/// - Call blind_sign (external, no DB lock held)
+/// - Open a new transaction to store signatures
+/// - Return the new transaction for the caller to commit
+///
+/// # Arguments
+///
+/// * `mint` - Mint instance (for keysets and blind_sign)
+/// * `db` - Database connection
+/// * `quote_id` - Quote ID for associating signatures
+/// * `inputs_amount` - Total amount from input proofs
+/// * `total_spent` - Amount spent on payment
+/// * `inputs_fee` - Fee paid for inputs
+/// * `change_outputs` - Blinded messages for change
+///
+/// # Returns
+///
+/// Tuple of:
+/// - `Option<Vec<BlindSignature>>` - Signed change outputs (if any)
+/// - `Box<dyn MintTransaction>` - New transaction with signatures stored
+///
+/// # Errors
+///
+/// Returns error if:
+/// - Change calculation fails
+/// - Blind signing fails
+/// - Database operations fail
+pub async fn process_melt_change<'a>(
+    mint: &super::super::Mint,
+    db: &'a DynMintDatabase,
+    quote_id: &QuoteId,
+    inputs_amount: Amount,
+    total_spent: Amount,
+    inputs_fee: Amount,
+    change_outputs: Vec<BlindedMessage>,
+) -> Result<
+    (
+        Option<Vec<BlindSignature>>,
+        Box<dyn database::MintTransaction<'a, database::Error> + Send + Sync + 'a>,
+    ),
+    Error,
+> {
+    // Check if change is needed
+    let needs_change = inputs_amount > total_spent;
+
+    if !needs_change || change_outputs.is_empty() {
+        // No change needed - open transaction and return empty result
+        let tx = db.begin_transaction().await?;
+        return Ok((None, tx));
+    }
+
+    let change_target = inputs_amount - total_spent - inputs_fee;
+
+    // Get keyset configuration
+    let fee_and_amounts = get_keyset_fee_and_amounts(&mint.keysets, &change_outputs);
+
+    // Split change into denominations
+    let mut amounts = change_target.split(&fee_and_amounts);
+
+    if change_outputs.len() < amounts.len() {
+        tracing::debug!(
+            "Providing change requires {} blinded messages, but only {} provided",
+            amounts.len(),
+            change_outputs.len()
+        );
+        amounts.sort_by(|a, b| b.cmp(a));
+    }
+
+    // Prepare blinded messages with amounts
+    let mut blinded_messages_to_sign = vec![];
+    for (amount, mut blinded_message) in amounts.iter().zip(change_outputs.iter().cloned()) {
+        blinded_message.amount = *amount;
+        blinded_messages_to_sign.push(blinded_message);
+    }
+
+    // External call: sign change outputs (no DB transaction held)
+    let change_sigs = mint.blind_sign(blinded_messages_to_sign.clone()).await?;
+
+    // Open new transaction to store signatures
+    let mut tx = db.begin_transaction().await?;
+
+    let blinded_secrets: Vec<_> = blinded_messages_to_sign
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    tx.add_blind_signatures(&blinded_secrets, &change_sigs, Some(quote_id.clone()))
+        .await?;
+
+    Ok((Some(change_sigs), tx))
+}
+
+/// Finalizes a melt quote by updating proofs, quote state, and publishing changes.
+///
+/// This function performs the core finalization operations that are common to both
+/// the saga finalize step and startup check recovery:
+/// 1. Validates amounts (total_spent vs quote amount, inputs vs total_spent)
+/// 2. Marks input proofs as SPENT
+/// 3. Publishes proof state changes
+/// 4. Updates quote state to PAID
+/// 5. Updates payment lookup ID if changed
+/// 6. Deletes melt request tracking
+///
+/// # Transaction Management
+///
+/// This function expects an open transaction and will NOT commit it.
+/// The caller is responsible for committing the transaction.
+///
+/// # Arguments
+///
+/// * `tx` - Open database transaction
+/// * `pubsub` - Pubsub manager for state notifications
+/// * `quote` - Melt quote being finalized
+/// * `input_ys` - Y values of input proofs
+/// * `inputs_amount` - Total amount from inputs
+/// * `inputs_fee` - Fee for inputs
+/// * `total_spent` - Amount spent on payment
+/// * `payment_preimage` - Payment preimage (if any)
+/// * `payment_lookup_id` - Payment lookup identifier
+///
+/// # Returns
+///
+/// `Ok(())` if finalization succeeds
+///
+/// # Errors
+///
+/// Returns error if:
+/// - Amount validation fails
+/// - Proofs are already spent
+/// - Database operations fail
+#[allow(clippy::too_many_arguments)]
+pub async fn finalize_melt_core(
+    tx: &mut Box<dyn database::MintTransaction<'_, database::Error> + Send + Sync + '_>,
+    pubsub: &PubSubManager,
+    quote: &MeltQuote,
+    input_ys: &[PublicKey],
+    inputs_amount: Amount,
+    inputs_fee: Amount,
+    total_spent: Amount,
+    payment_preimage: Option<String>,
+    payment_lookup_id: &cdk_common::payment::PaymentIdentifier,
+) -> Result<(), Error> {
+    // Validate quote amount vs payment amount
+    if quote.amount > total_spent {
+        tracing::error!(
+            "Payment amount {} is less than quote amount {} for quote {}",
+            total_spent,
+            quote.amount,
+            quote.id
+        );
+        return Err(Error::IncorrectQuoteAmount);
+    }
+
+    // Validate inputs amount
+    if inputs_amount - inputs_fee < total_spent {
+        tracing::error!("Over paid melt quote {}", quote.id);
+        return Err(Error::IncorrectQuoteAmount);
+    }
+
+    // Update quote state to Paid
+    tx.update_melt_quote_state(&quote.id, MeltQuoteState::Paid, payment_preimage.clone())
+        .await?;
+
+    // Update payment lookup ID if changed
+    if quote.request_lookup_id.as_ref() != Some(payment_lookup_id) {
+        tracing::info!(
+            "Payment lookup id changed post payment from {:?} to {}",
+            &quote.request_lookup_id,
+            payment_lookup_id
+        );
+
+        tx.update_melt_quote_request_lookup_id(&quote.id, payment_lookup_id)
+            .await?;
+    }
+
+    // Mark input proofs as spent
+    match tx.update_proofs_states(input_ys, State::Spent).await {
+        Ok(_) => {}
+        Err(database::Error::AttemptUpdateSpentProof) => {
+            tracing::info!("Proofs for quote {} already marked as spent", quote.id);
+            return Ok(());
+        }
+        Err(err) => {
+            return Err(err.into());
+        }
+    }
+
+    // Publish proof state changes
+    for pk in input_ys.iter() {
+        pubsub.proof_state((*pk, State::Spent));
+    }
+
+    Ok(())
+}
+
+/// High-level melt finalization that handles the complete workflow.
+///
+/// This function orchestrates:
+/// 1. Getting melt request info
+/// 2. Getting input proof Y values
+/// 3. Processing change (if needed)
+/// 4. Core finalization operations
+/// 5. Transaction commit
+/// 6. Pubsub notification
+///
+/// # Arguments
+///
+/// * `mint` - Mint instance
+/// * `db` - Database connection
+/// * `pubsub` - Pubsub manager
+/// * `quote` - Melt quote to finalize
+/// * `total_spent` - Amount spent on payment
+/// * `payment_preimage` - Payment preimage (if any)
+/// * `payment_lookup_id` - Payment lookup identifier
+///
+/// # Returns
+///
+/// `Option<Vec<BlindSignature>>` - Change signatures (if any)
+pub async fn finalize_melt_quote(
+    mint: &super::super::Mint,
+    db: &DynMintDatabase,
+    pubsub: &PubSubManager,
+    quote: &MeltQuote,
+    total_spent: Amount,
+    payment_preimage: Option<String>,
+    payment_lookup_id: &cdk_common::payment::PaymentIdentifier,
+) -> Result<Option<Vec<BlindSignature>>, Error> {
+    use cdk_common::amount::to_unit;
+
+    tracing::info!("Finalizing melt quote {}", quote.id);
+
+    // Convert total_spent to quote unit
+    let total_spent = to_unit(total_spent, &quote.unit, &quote.unit).unwrap_or(total_spent);
+
+    let mut tx = db.begin_transaction().await?;
+
+    // Get melt request info
+    let melt_request_info = match tx.get_melt_request_and_blinded_messages(&quote.id).await? {
+        Some(info) => info,
+        None => {
+            tracing::warn!(
+                "No melt request found for quote {} - may have been completed already",
+                quote.id
+            );
+            tx.rollback().await?;
+            return Ok(None);
+        }
+    };
+
+    // Get input proof Y values
+    let input_ys = tx.get_proof_ys_by_quote_id(&quote.id).await?;
+
+    if input_ys.is_empty() {
+        tracing::warn!(
+            "No input proofs found for quote {} - may have been completed already",
+            quote.id
+        );
+        tx.rollback().await?;
+        return Ok(None);
+    }
+
+    // Core finalization (marks proofs spent, updates quote)
+    finalize_melt_core(
+        &mut tx,
+        pubsub,
+        quote,
+        &input_ys,
+        melt_request_info.inputs_amount,
+        melt_request_info.inputs_fee,
+        total_spent,
+        payment_preimage.clone(),
+        payment_lookup_id,
+    )
+    .await?;
+
+    // Close transaction before external call
+    tx.commit().await?;
+
+    // Process change (if needed) - opens new transaction
+    let (change_sigs, mut tx) = process_melt_change(
+        mint,
+        db,
+        &quote.id,
+        melt_request_info.inputs_amount,
+        total_spent,
+        melt_request_info.inputs_fee,
+        melt_request_info.change_outputs.clone(),
+    )
+    .await?;
+
+    // Delete melt request tracking
+    tx.delete_melt_request(&quote.id).await?;
+
+    // Commit transaction
+    tx.commit().await?;
+
+    // Publish quote status change
+    pubsub.melt_quote_status(
+        quote,
+        payment_preimage,
+        change_sigs.clone(),
+        MeltQuoteState::Paid,
+    );
+
+    tracing::info!("Successfully finalized melt quote {}", quote.id);
+
+    Ok(change_sigs)
+}

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

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

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

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

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

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

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

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

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

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

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

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

+ 36 - 148
crates/cdk/src/mint/mod.rs

@@ -9,11 +9,10 @@ use cdk_common::amount::to_unit;
 use cdk_common::common::{PaymentProcessorKey, QuoteTTL};
 #[cfg(feature = "auth")]
 use cdk_common::database::DynMintAuthDatabase;
-use cdk_common::database::{self, DynMintDatabase, MintTransaction};
-use cdk_common::nuts::{self, BlindSignature, BlindedMessage, CurrencyUnit, Id, Kind};
+use cdk_common::database::{self, DynMintDatabase};
+use cdk_common::nuts::{BlindSignature, BlindedMessage, CurrencyUnit, Id};
 use cdk_common::payment::{DynMintPayment, WaitPaymentResponse};
 pub use cdk_common::quote_id::QuoteId;
-use cdk_common::secret;
 #[cfg(feature = "prometheus")]
 use cdk_prometheus::global;
 use cdk_signatory::signatory::{Signatory, SignatoryKeySet};
@@ -28,9 +27,9 @@ use tracing::instrument;
 use crate::error::Error;
 use crate::fees::calculate_fee;
 use crate::nuts::*;
+use crate::Amount;
 #[cfg(feature = "auth")]
 use crate::OidcClient;
-use crate::{cdk_database, Amount};
 
 #[cfg(feature = "auth")]
 pub(crate) mod auth;
@@ -40,7 +39,6 @@ mod issue;
 mod keysets;
 mod ln;
 mod melt;
-mod proof_writer;
 mod start_up_check;
 mod subscription;
 mod swap;
@@ -69,7 +67,7 @@ pub struct Mint {
     #[cfg(feature = "auth")]
     auth_localstore: Option<DynMintAuthDatabase>,
     /// Payment processors for mint
-    payment_processors: HashMap<PaymentProcessorKey, DynMintPayment>,
+    payment_processors: Arc<HashMap<PaymentProcessorKey, DynMintPayment>>,
     /// Subscription manager
     pubsub_manager: Arc<PubSubManager>,
     #[cfg(feature = "auth")]
@@ -203,9 +201,11 @@ impl Mint {
             }
         }
 
+        let payment_processors = Arc::new(payment_processors);
+
         Ok(Self {
             signatory,
-            pubsub_manager: PubSubManager::new(localstore.clone()),
+            pubsub_manager: PubSubManager::new((localstore.clone(), payment_processors.clone())),
             localstore,
             #[cfg(feature = "auth")]
             oidc_client: computed_info.nuts.nut21.as_ref().map(|nut21| {
@@ -238,14 +238,21 @@ impl Mint {
     /// - Payment processor initialization and startup
     /// - Invoice payment monitoring across all configured payment processors
     pub async fn start(&self) -> Result<(), Error> {
-        // Checks the status of all pending melt quotes
-        // Pending melt quotes where the payment has gone through inputs are burnt
-        // Pending melt quotes where the payment has **failed** inputs are reset to unspent
-        self.check_pending_melt_quotes().await?;
-
         // Recover from incomplete swap sagas
         // This cleans up incomplete swap operations using persisted saga state
-        self.recover_from_incomplete_sagas().await?;
+        if let Err(e) = self.recover_from_incomplete_sagas().await {
+            tracing::error!("Failed to recover incomplete swap sagas: {}", e);
+            // Don't fail startup
+        }
+
+        // Recover from incomplete melt sagas
+        // This cleans up incomplete melt operations using persisted saga state
+        // Now includes checking payment status with LN backend to determine
+        // whether to finalize (if paid) or compensate (if failed/unpaid)
+        if let Err(e) = self.recover_from_incomplete_melt_sagas().await {
+            tracing::error!("Failed to recover incomplete melt sagas: {}", e);
+            // Don't fail startup
+        }
 
         let mut task_state = self.task_state.lock().await;
 
@@ -257,7 +264,7 @@ impl Mint {
         // Start all payment processors first
         tracing::info!("Starting payment processors...");
         let mut seen_processors = Vec::new();
-        for (key, processor) in &self.payment_processors {
+        for (key, processor) in self.payment_processors.iter() {
             // Skip if we've already spawned a task for this processor instance
             if seen_processors.iter().any(|p| Arc::ptr_eq(p, processor)) {
                 continue;
@@ -369,7 +376,7 @@ impl Mint {
         tracing::info!("Stopping payment processors...");
         let mut seen_processors = Vec::new();
 
-        for (key, processor) in &self.payment_processors {
+        for (key, processor) in self.payment_processors.iter() {
             // Skip if we've already spawned a task for this processor instance
             if seen_processors.iter().any(|p| Arc::ptr_eq(p, processor)) {
                 continue;
@@ -845,39 +852,12 @@ impl Mint {
     /// Verify [`Proof`] meets conditions and is signed
     #[tracing::instrument(skip_all)]
     pub async fn verify_proofs(&self, proofs: Proofs) -> Result<(), Error> {
+        // This ignore P2PK and HTLC, as all NUT-10 spending conditions are
+        // checked elsewhere.
         #[cfg(feature = "prometheus")]
         global::inc_in_flight_requests("verify_proofs");
 
-        let result = async {
-            proofs
-                .iter()
-                .map(|proof| {
-                    // Check if secret is a nut10 secret with conditions
-                    if let Ok(secret) =
-                        <&secret::Secret as TryInto<nuts::nut10::Secret>>::try_into(&proof.secret)
-                    {
-                        // Checks and verifies known secret kinds.
-                        // If it is an unknown secret kind it will be treated as a normal secret.
-                        // Spending conditions will **not** be check. It is up to the wallet to ensure
-                        // only supported secret kinds are used as there is no way for the mint to
-                        // enforce only signing supported secrets as they are blinded at
-                        // that point.
-                        match secret.kind() {
-                            Kind::P2PK => {
-                                proof.verify_p2pk()?;
-                            }
-                            Kind::HTLC => {
-                                proof.verify_htlc()?;
-                            }
-                        }
-                    }
-                    Ok(())
-                })
-                .collect::<Result<Vec<()>, Error>>()?;
-
-            self.signatory.verify_proofs(proofs).await
-        }
-        .await;
+        let result = self.signatory.verify_proofs(proofs).await;
 
         #[cfg(feature = "prometheus")]
         {
@@ -888,78 +868,6 @@ impl Mint {
         result
     }
 
-    /// Verify melt request is valid
-    /// Check to see if there is a corresponding mint quote for a melt.
-    /// In this case the mint can settle the payment internally and no ln payment is
-    /// needed
-    #[instrument(skip_all)]
-    pub async fn handle_internal_melt_mint(
-        &self,
-        tx: &mut Box<dyn MintTransaction<'_, cdk_database::Error> + Send + Sync + '_>,
-        melt_quote: &MeltQuote,
-        melt_request: &MeltRequest<QuoteId>,
-    ) -> Result<Option<Amount>, Error> {
-        let mint_quote = match tx
-            .get_mint_quote_by_request(&melt_quote.request.to_string())
-            .await
-        {
-            Ok(Some(mint_quote)) if mint_quote.unit == melt_quote.unit => mint_quote,
-            // Not an internal melt -> mint or unit mismatch
-            Ok(_) => return Ok(None),
-            Err(err) => {
-                tracing::debug!("Error attempting to get mint quote: {}", err);
-                return Err(Error::Internal);
-            }
-        };
-
-        // Mint quote has already been settled, proofs should not be burned or held.
-        if (mint_quote.state() == MintQuoteState::Issued
-            || mint_quote.state() == MintQuoteState::Paid)
-            && mint_quote.payment_method == PaymentMethod::Bolt11
-        {
-            return Err(Error::RequestAlreadyPaid);
-        }
-
-        let inputs_amount_quote_unit = melt_request.inputs_amount().map_err(|_| {
-            tracing::error!("Proof inputs in melt quote overflowed");
-            Error::AmountOverflow
-        })?;
-
-        if let Some(amount) = mint_quote.amount {
-            if amount > inputs_amount_quote_unit {
-                tracing::debug!(
-                    "Not enough inputs provided: {} needed {}",
-                    inputs_amount_quote_unit,
-                    amount
-                );
-                return Err(Error::InsufficientFunds);
-            }
-        }
-
-        let amount = melt_quote.amount;
-
-        tracing::info!(
-            "Mint quote {} paid {} from internal payment.",
-            mint_quote.id,
-            amount
-        );
-
-        let total_paid = tx
-            .increment_mint_quote_amount_paid(&mint_quote.id, amount, melt_quote.id.to_string())
-            .await?;
-
-        self.pubsub_manager
-            .mint_quote_payment(&mint_quote, total_paid);
-
-        tracing::info!(
-            "Melt quote {} paid Mint quote {}",
-            melt_quote.id,
-            mint_quote.id
-        );
-
-        Ok(Some(amount))
-    }
-
     /// Restore
     #[instrument(skip_all)]
     pub async fn restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error> {
@@ -1015,21 +923,10 @@ impl Mint {
         global::inc_in_flight_requests("total_issued");
 
         let result = async {
-            let keysets = self.keysets().keysets;
-
-            let mut total_issued = HashMap::new();
-
-            for keyset in keysets {
-                let blinded = self
-                    .localstore
-                    .get_blind_signatures_for_keyset(&keyset.id)
-                    .await?;
-
-                let total = Amount::try_sum(blinded.iter().map(|b| b.amount))?;
-
-                total_issued.insert(keyset.id, total);
+            let mut total_issued = self.localstore.get_total_issued().await?;
+            for keyset in self.keysets().keysets {
+                total_issued.entry(keyset.id).or_default();
             }
-
             Ok(total_issued)
         }
         .await;
@@ -1049,28 +946,19 @@ impl Mint {
         #[cfg(feature = "prometheus")]
         global::inc_in_flight_requests("total_redeemed");
 
-        let keysets = self.signatory.keysets().await?;
-
-        let mut total_redeemed = HashMap::new();
-
-        for keyset in keysets.keysets {
-            let (proofs, state) = self.localstore.get_proofs_by_keyset_id(&keyset.id).await?;
-
-            let total_spent =
-                Amount::try_sum(proofs.iter().zip(state).filter_map(|(p, s)| {
-                    match s == Some(State::Spent) {
-                        true => Some(p.amount),
-                        false => None,
-                    }
-                }))?;
-
-            total_redeemed.insert(keyset.id, total_spent);
+        let total_redeemed = async {
+            let mut total_redeemed = self.localstore.get_total_redeemed().await?;
+            for keyset in self.keysets().keysets {
+                total_redeemed.entry(keyset.id).or_default();
+            }
+            Ok(total_redeemed)
         }
+        .await;
 
         #[cfg(feature = "prometheus")]
         global::dec_in_flight_requests("total_redeemed");
 
-        Ok(total_redeemed)
+        total_redeemed
     }
 }
 

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini