소스 검색

Merge branch 'cashubtc:main' into go-ffi

asmo 4 달 전
부모
커밋
292abe07b6
100개의 변경된 파일8658개의 추가작업 그리고 1127개의 파일을 삭제
  1. 7 0
      .backportrc.json
  2. 122 0
      .github/scripts/generate-agenda.sh
  3. 8 0
      .github/templates/failed-backport-issue.md
  4. 68 0
      .github/workflows/backport.yml
  5. 94 14
      .github/workflows/ci.yml
  6. 25 1
      .github/workflows/nutshell_itest.yml
  7. 62 0
      .github/workflows/weekly-meeting-agenda.yml
  8. 79 0
      DEVELOPMENT.md
  9. 13 2
      crates/cashu/src/nuts/nut11/mod.rs
  10. 17 2
      crates/cashu/src/nuts/nut14/mod.rs
  11. 6 12
      crates/cdk-cli/src/sub_commands/cat_device_login.rs
  12. 7 14
      crates/cdk-cli/src/sub_commands/cat_login.rs
  13. 22 21
      crates/cdk-cli/src/sub_commands/mint_blind_auth.rs
  14. 1 1
      crates/cdk-cli/src/sub_commands/restore.rs
  15. 1 1
      crates/cdk-cli/src/utils.rs
  16. 8 2
      crates/cdk-common/src/common.rs
  17. 47 4
      crates/cdk-common/src/database/mint/mod.rs
  18. 6 6
      crates/cdk-common/src/database/mint/test/mint.rs
  19. 3 1
      crates/cdk-common/src/database/mint/test/mod.rs
  20. 25 8
      crates/cdk-common/src/database/mint/test/proofs.rs
  21. 4 1
      crates/cdk-common/src/error.rs
  22. 203 1
      crates/cdk-common/src/mint.rs
  23. 45 1
      crates/cdk-common/src/state.rs
  24. 21 15
      crates/cdk-fake-wallet/src/lib.rs
  25. 7 0
      crates/cdk-ffi/.cargo/config.toml
  26. 3 1
      crates/cdk-ffi/src/database.rs
  27. 77 11
      crates/cdk-ffi/src/multi_mint_wallet.rs
  28. 1 1
      crates/cdk-ffi/src/postgres.rs
  29. 1 1
      crates/cdk-ffi/src/sqlite.rs
  30. 1 4
      crates/cdk-ffi/src/token.rs
  31. 106 55
      crates/cdk-ffi/src/types/proof.rs
  32. 19 106
      crates/cdk-ffi/src/types/quote.rs
  33. 4 8
      crates/cdk-ffi/src/types/subscription.rs
  34. 18 1
      crates/cdk-ffi/src/types/transaction.rs
  35. 4 7
      crates/cdk-ffi/src/types/wallet.rs
  36. 22 29
      crates/cdk-ffi/src/wallet.rs
  37. 6 9
      crates/cdk-integration-tests/tests/ffi_minting_integration.rs
  38. 903 0
      crates/cdk-integration-tests/tests/test_swap_flow.rs
  39. 67 44
      crates/cdk-ldk-node/src/web/handlers/dashboard.rs
  40. 113 80
      crates/cdk-ldk-node/src/web/handlers/invoices.rs
  41. 69 86
      crates/cdk-ldk-node/src/web/handlers/lightning.rs
  42. 43 43
      crates/cdk-ldk-node/src/web/handlers/onchain.rs
  43. 100 38
      crates/cdk-ldk-node/src/web/handlers/payments.rs
  44. 58 7
      crates/cdk-ldk-node/src/web/templates/components.rs
  45. 635 52
      crates/cdk-ldk-node/src/web/templates/layout.rs
  46. 1 0
      crates/cdk-ldk-node/src/web/templates/payments.rs
  47. 1 1
      crates/cdk-lnbits/Cargo.toml
  48. 49 15
      crates/cdk-lnbits/src/lib.rs
  49. 0 5
      crates/cdk-mintd/src/lib.rs
  50. 1 0
      crates/cdk-sql-common/Cargo.toml
  51. 24 0
      crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql
  52. 24 0
      crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql
  53. 203 6
      crates/cdk-sql-common/src/mint/mod.rs
  54. 5 3
      crates/cdk-sqlite/src/mint/memory.rs
  55. 2 0
      crates/cdk/Cargo.toml
  56. 3 0
      crates/cdk/src/lib.rs
  57. 0 152
      crates/cdk/src/mint/blinded_message_writer.rs
  58. 5 1
      crates/cdk/src/mint/issue/mod.rs
  59. 10 2
      crates/cdk/src/mint/melt.rs
  60. 16 1
      crates/cdk/src/mint/mod.rs
  61. 7 1
      crates/cdk/src/mint/proof_writer.rs
  62. 66 0
      crates/cdk/src/mint/start_up_check.rs
  63. 0 173
      crates/cdk/src/mint/swap.rs
  64. 84 0
      crates/cdk/src/mint/swap/mod.rs
  65. 61 0
      crates/cdk/src/mint/swap/swap_saga/compensation.rs
  66. 514 0
      crates/cdk/src/mint/swap/swap_saga/mod.rs
  67. 26 0
      crates/cdk/src/mint/swap/swap_saga/state.rs
  68. 2993 0
      crates/cdk/src/mint/swap/swap_saga/tests.rs
  69. 218 0
      crates/cdk/src/test_helpers/mint.rs
  70. 10 0
      crates/cdk/src/test_helpers/mod.rs
  71. 13 0
      crates/cdk/src/wallet/auth/mod.rs
  72. 14 0
      crates/cdk/src/wallet/mod.rs
  73. 289 21
      crates/cdk/src/wallet/multi_mint_wallet.rs
  74. 20 1
      docker-compose.ldk-node.yaml
  75. 0 36
      docker-compose.yaml
  76. 18 18
      flake.lock
  77. 3 1
      flake.nix
  78. 3 0
      justfile
  79. 18 0
      meetings/2025-04-02-agenda.md
  80. 24 0
      meetings/2025-04-09-agenda.md
  81. 12 0
      meetings/2025-04-23-agenda.md
  82. 24 0
      meetings/2025-06-04-agenda.md
  83. 26 0
      meetings/2025-06-11-agenda.md
  84. 34 0
      meetings/2025-06-25-agenda.md
  85. 41 0
      meetings/2025-07-02-agenda.md
  86. 39 0
      meetings/2025-07-09-agenda.md
  87. 51 0
      meetings/2025-07-23-agenda.md
  88. 26 0
      meetings/2025-07-30-agenda.md
  89. 20 0
      meetings/2025-08-05-agenda.md
  90. 35 0
      meetings/2025-08-13-agenda.md
  91. 41 0
      meetings/2025-08-20-agenda.md
  92. 40 0
      meetings/2025-08-27-agenda.md
  93. 50 0
      meetings/2025-09-03-agenda.md
  94. 44 0
      meetings/2025-09-10-agenda.md
  95. 47 0
      meetings/2025-09-17-agenda.md
  96. 49 0
      meetings/2025-09-24-agenda.md
  97. 45 0
      meetings/2025-10-08-agenda.md
  98. 39 0
      meetings/2025-10-15-agenda.md
  99. 57 0
      meetings/2025-10-27-agenda.md
  100. 62 0
      meetings/2025-10-29-agenda.md

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

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

@@ -0,0 +1,122 @@
+#!/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 %H:%M 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 ongoing (open) PRs
+echo "Fetching ongoing PRs..."
+ONGOING_PRS=$(gh pr list \
+    --repo "$REPO" \
+    --state open \
+    --json number,title,url,createdAt \
+    --jq '.[] | select(.createdAt < "'$SINCE_DATE'") | [.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 "")
+
+# Generate markdown
+AGENDA=$(cat <<EOF
+# CDK Development Meeting
+
+$MEETING_DATE
+
+Meeting Link: $MEETING_LINK
+
+## Merged
+
+$(format_list "$MERGED_PRS")
+
+## Ongoing
+
+$(format_list "$ONGOING_PRS")
+
+## New
+
+### Issues
+
+$(format_list "$NEW_ISSUES")
+
+### PRs
+
+$(format_list "$NEW_PRS")
+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

+ 94 - 14
.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]
 
@@ -19,14 +21,20 @@ jobs:
     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: "nightly"
+          shared-key: "nightly-${{ steps.flake-hash.outputs.hash }}"
       - name: Cargo fmt
         run: |
           nix develop -i -L .#nightly --command bash -c '
@@ -56,14 +64,20 @@ jobs:
     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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Run example
         run: nix develop -i -L .#stable --command cargo r --example ${{ matrix.build-args }}
 
@@ -150,14 +164,20 @@ jobs:
     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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Clippy
         run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings
       - name: Test
@@ -183,6 +203,9 @@ jobs:
     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: Free Disk Space (Ubuntu)
         uses: jlumbroso/free-disk-space@main
         with:
@@ -197,10 +220,13 @@ jobs:
         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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Test
         run: nix develop -i -L .#stable --command just itest ${{ matrix.database }}
 
@@ -223,6 +249,9 @@ jobs:
     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: Free Disk Space (Ubuntu)
         uses: jlumbroso/free-disk-space@main
         with:
@@ -237,10 +266,13 @@ jobs:
         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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Clippy
         run: nix develop -i -L .#stable --command cargo clippy -- -D warnings
       - name: Test fake auth mint
@@ -263,6 +295,9 @@ jobs:
     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: Free Disk Space (Ubuntu)
         uses: jlumbroso/free-disk-space@main
         with:
@@ -277,10 +312,13 @@ jobs:
         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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Test fake mint
         run: nix develop -i -L .#stable --command just test-pure ${{ matrix.database }}
       - name: Install Postgres
@@ -306,6 +344,9 @@ jobs:
     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: Free Disk Space (Ubuntu)
         uses: jlumbroso/free-disk-space@main
         with:
@@ -320,10 +361,13 @@ jobs:
         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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Test
         run: nix develop -i -L .#stable --command just itest-payment-processor ${{matrix.ln}}
 
@@ -358,14 +402,20 @@ jobs:
     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"
+          shared-key: "msrv-${{ steps.flake-hash.outputs.hash }}"
       - name: Build
         run: nix develop -i -L .#msrv --command cargo build ${{ matrix.build-args }}
 
@@ -391,14 +441,20 @@ jobs:
     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"
+          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 }}
 
@@ -424,14 +480,20 @@ jobs:
     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"
+          shared-key: "msrv-${{ steps.flake-hash.outputs.hash }}"
       - name: Build cdk wasm
         run: nix develop -i -L ".#${{ matrix.rust }}" --command cargo build ${{ matrix.build-args }} --target ${{ matrix.target }}
 
@@ -450,6 +512,9 @@ jobs:
     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: Free Disk Space (Ubuntu)
         uses: jlumbroso/free-disk-space@main
         with:
@@ -464,10 +529,13 @@ jobs:
         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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Start Keycloak with Backup
         run: |
           docker compose -f misc/keycloak/docker-compose-recover.yml up -d
@@ -490,6 +558,9 @@ jobs:
     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: Free Disk Space (Ubuntu)
         uses: jlumbroso/free-disk-space@main
         with:
@@ -504,10 +575,13 @@ jobs:
         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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Run doc tests
         run: nix develop -i -L .#stable --command cargo test --doc
 
@@ -519,13 +593,19 @@ jobs:
     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"
+          shared-key: "stable-${{ steps.flake-hash.outputs.hash }}"
       - name: Check docs with strict warnings
         run: nix develop -i -L .#stable --command just docs-strict

+ 25 - 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:
@@ -10,6 +18,9 @@ jobs:
     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: Free Disk Space (Ubuntu)
         uses: jlumbroso/free-disk-space@main
         with:
@@ -24,8 +35,13 @@ jobs:
         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: "integration-${{ steps.flake-hash.outputs.hash }}"
       - name: Test Nutshell
         run: nix develop -i -L .#integration --command just test-nutshell
       - name: Show logs if tests fail
@@ -39,6 +55,9 @@ jobs:
     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: Free Disk Space (Ubuntu)
         uses: jlumbroso/free-disk-space@main
         with:
@@ -55,8 +74,13 @@ jobs:
         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: "integration-${{ steps.flake-hash.outputs.hash }}"
       - name: Test Nutshell Wallet
         run: |
           nix develop -i -L .#integration --command just nutshell-wallet-itest

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

+ 79 - 0
DEVELOPMENT.md

@@ -226,6 +226,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/)

+ 13 - 2
crates/cashu/src/nuts/nut11/mod.rs

@@ -58,6 +58,9 @@ pub enum Error {
     /// HTLC hash invalid
     #[error("Invalid hash")]
     InvalidHash,
+    /// HTLC preimage too large
+    #[error("Preimage exceeds maximum size of 32 bytes (64 hex characters)")]
+    PreimageTooLarge,
     /// Witness Signatures not provided
     #[error("Witness signatures not provided")]
     SignaturesNotProvided,
@@ -329,7 +332,15 @@ pub enum SpendingConditions {
 impl SpendingConditions {
     /// New HTLC [SpendingConditions]
     pub fn new_htlc(preimage: String, conditions: Option<Conditions>) -> Result<Self, Error> {
-        let htlc = Sha256Hash::hash(&hex::decode(preimage)?);
+        const MAX_PREIMAGE_BYTES: usize = 32;
+
+        let preimage_bytes = hex::decode(preimage)?;
+
+        if preimage_bytes.len() != MAX_PREIMAGE_BYTES {
+            return Err(Error::PreimageTooLarge);
+        }
+
+        let htlc = Sha256Hash::hash(&preimage_bytes);
 
         Ok(Self::HTLCConditions {
             data: htlc,
@@ -2071,4 +2082,4 @@ mod tests {
             "Both signatures should verify"
         );
     }
-} // End of tests module
+}

+ 17 - 2
crates/cashu/src/nuts/nut14/mod.rs

@@ -15,7 +15,7 @@ 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,6 +37,12 @@ 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,
@@ -76,6 +82,15 @@ impl Proof {
             _ => return Err(Error::IncorrectSecretKind),
         };
 
+        const REQUIRED_PREIMAGE_BYTES: usize = 32;
+
+        let preimage_bytes =
+            hex::decode(&htlc_witness.preimage).map_err(|_| Error::InvalidHexPreimage)?;
+
+        if preimage_bytes.len() != REQUIRED_PREIMAGE_BYTES {
+            return Err(Error::PreimageInvalidSize);
+        }
+
         if let Some(conditions) = conditions {
             // Check locktime
             if let Some(locktime) = conditions.locktime {
@@ -127,7 +142,7 @@ impl Proof {
         let hash_lock =
             Sha256Hash::from_str(secret.secret_data().data()).map_err(|_| Error::InvalidHash)?;
 
-        let preimage_hash = Sha256Hash::hash(htlc_witness.preimage.as_bytes());
+        let preimage_hash = Sha256Hash::hash(&preimage_bytes);
 
         if hash_lock.ne(&preimage_hash) {
             return Err(Error::Preimage);

+ 6 - 12
crates/cdk-cli/src/sub_commands/cat_device_login.rs

@@ -29,19 +29,13 @@ pub async fn cat_device_login(
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
 
-    let wallet = match multi_mint_wallet.get_wallet(&mint_url).await {
-        Some(wallet) => wallet.clone(),
-        None => {
-            multi_mint_wallet.add_mint(mint_url.clone(), None).await?;
-            multi_mint_wallet
-                .get_wallet(&mint_url)
-                .await
-                .expect("Wallet should exist after adding mint")
-        }
-    };
+    // Ensure the mint exists
+    if !multi_mint_wallet.has_mint(&mint_url).await {
+        multi_mint_wallet.add_mint(mint_url.clone()).await?;
+    }
 
-    let mint_info = wallet
-        .fetch_mint_info()
+    let mint_info = multi_mint_wallet
+        .fetch_mint_info(&mint_url)
         .await?
         .ok_or(anyhow!("Mint info not found"))?;
 

+ 7 - 14
crates/cdk-cli/src/sub_commands/cat_login.rs

@@ -31,20 +31,13 @@ pub async fn cat_login(
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
 
-    let wallet = match multi_mint_wallet.get_wallet(&mint_url).await {
-        Some(wallet) => wallet.clone(),
-        None => {
-            multi_mint_wallet.add_mint(mint_url.clone(), None).await?;
-            multi_mint_wallet
-                .get_wallet(&mint_url)
-                .await
-                .expect("Wallet should exist after adding mint")
-                .clone()
-        }
-    };
-
-    let mint_info = wallet
-        .fetch_mint_info()
+    // Ensure the mint exists
+    if !multi_mint_wallet.has_mint(&mint_url).await {
+        multi_mint_wallet.add_mint(mint_url.clone()).await?;
+    }
+
+    let mint_info = multi_mint_wallet
+        .fetch_mint_info(&mint_url)
         .await?
         .ok_or(anyhow!("Mint info not found"))?;
 

+ 22 - 21
crates/cdk-cli/src/sub_commands/mint_blind_auth.rs

@@ -28,19 +28,12 @@ pub async fn mint_blind_auth(
 ) -> Result<()> {
     let mint_url = sub_command_args.mint_url.clone();
 
-    let wallet = match multi_mint_wallet.get_wallet(&mint_url).await {
-        Some(wallet) => wallet.clone(),
-        None => {
-            multi_mint_wallet.add_mint(mint_url.clone(), None).await?;
-            multi_mint_wallet
-                .get_wallet(&mint_url)
-                .await
-                .expect("Wallet should exist after adding mint")
-                .clone()
-        }
-    };
+    // Ensure the mint exists
+    if !multi_mint_wallet.has_mint(&mint_url).await {
+        multi_mint_wallet.add_mint(mint_url.clone()).await?;
+    }
 
-    wallet.fetch_mint_info().await?;
+    multi_mint_wallet.fetch_mint_info(&mint_url).await?;
 
     // Try to get the token from the provided argument or from the stored file
     let cat = match &sub_command_args.cat {
@@ -68,7 +61,7 @@ pub async fn mint_blind_auth(
     };
 
     // Try to set the access token
-    if let Err(err) = wallet.set_cat(cat.clone()).await {
+    if let Err(err) = multi_mint_wallet.set_cat(&mint_url, cat.clone()).await {
         tracing::error!("Could not set cat: {}", err);
 
         // Try to refresh the token if we have a refresh token
@@ -76,7 +69,7 @@ pub async fn mint_blind_auth(
             println!("Attempting to refresh the access token...");
 
             // Get the mint info to access OIDC configuration
-            if let Some(mint_info) = wallet.fetch_mint_info().await? {
+            if let Some(mint_info) = multi_mint_wallet.fetch_mint_info(&mint_url).await? {
                 match refresh_access_token(&mint_info, &token_data.refresh_token).await {
                     Ok((new_access_token, new_refresh_token)) => {
                         println!("Successfully refreshed access token");
@@ -94,7 +87,9 @@ pub async fn mint_blind_auth(
                         }
 
                         // Try setting the new access token
-                        if let Err(err) = wallet.set_cat(new_access_token).await {
+                        if let Err(err) =
+                            multi_mint_wallet.set_cat(&mint_url, new_access_token).await
+                        {
                             tracing::error!("Could not set refreshed cat: {}", err);
                             return Err(anyhow::anyhow!(
                                 "Authentication failed even after token refresh"
@@ -102,7 +97,9 @@ pub async fn mint_blind_auth(
                         }
 
                         // Set the refresh token
-                        wallet.set_refresh_token(new_refresh_token).await?;
+                        multi_mint_wallet
+                            .set_refresh_token(&mint_url, new_refresh_token)
+                            .await?;
                     }
                     Err(e) => {
                         tracing::error!("Failed to refresh token: {}", e);
@@ -119,8 +116,10 @@ pub async fn mint_blind_auth(
         // If we have a refresh token, set it
         if let Ok(Some(token_data)) = token_storage::get_token_for_mint(work_dir, &mint_url).await {
             tracing::info!("Attempting to use refresh access token to refresh auth token");
-            wallet.set_refresh_token(token_data.refresh_token).await?;
-            wallet.refresh_access_token().await?;
+            multi_mint_wallet
+                .set_refresh_token(&mint_url, token_data.refresh_token)
+                .await?;
+            multi_mint_wallet.refresh_access_token(&mint_url).await?;
         }
     }
 
@@ -129,8 +128,8 @@ pub async fn mint_blind_auth(
     let amount = match sub_command_args.amount {
         Some(amount) => amount,
         None => {
-            let mint_info = wallet
-                .fetch_mint_info()
+            let mint_info = multi_mint_wallet
+                .fetch_mint_info(&mint_url)
                 .await?
                 .ok_or(anyhow!("Unknown mint info"))?;
             mint_info
@@ -139,7 +138,9 @@ pub async fn mint_blind_auth(
         }
     };
 
-    let proofs = wallet.mint_blind_auth(Amount::from(amount)).await?;
+    let proofs = multi_mint_wallet
+        .mint_blind_auth(&mint_url, Amount::from(amount))
+        .await?;
 
     println!("Received {} auth proofs for mint {mint_url}", proofs.len());
 

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

@@ -18,7 +18,7 @@ pub async fn restore(
     let wallet = match multi_mint_wallet.get_wallet(&mint_url).await {
         Some(wallet) => wallet.clone(),
         None => {
-            multi_mint_wallet.add_mint(mint_url.clone(), None).await?;
+            multi_mint_wallet.add_mint(mint_url.clone()).await?;
             multi_mint_wallet
                 .get_wallet(&mint_url)
                 .await

+ 1 - 1
crates/cdk-cli/src/utils.rs

@@ -34,7 +34,7 @@ pub async fn get_or_create_wallet(
         Some(wallet) => Ok(wallet.clone()),
         None => {
             tracing::debug!("Wallet does not exist creating..");
-            multi_mint_wallet.add_mint(mint_url.clone(), None).await?;
+            multi_mint_wallet.add_mint(mint_url.clone()).await?;
             Ok(multi_mint_wallet
                 .get_wallet(mint_url)
                 .await

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

@@ -41,10 +41,16 @@ impl Melted {
             None => Amount::ZERO,
         };
 
+        tracing::info!(
+            "Proofs amount: {} Amount: {} Change: {}",
+            proofs_amount,
+            amount,
+            change_amount
+        );
+
         let fee_paid = proofs_amount
             .checked_sub(amount + change_amount)
-            .ok_or(Error::AmountOverflow)
-            .unwrap();
+            .ok_or(Error::AmountOverflow)?;
 
         Ok(Self {
             state,

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

@@ -7,7 +7,7 @@ use cashu::quote_id::QuoteId;
 use cashu::Amount;
 
 use super::Error;
-use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote};
+use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote, Operation};
 use crate::nuts::{
     BlindSignature, BlindedMessage, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey,
     State,
@@ -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>;
@@ -145,6 +145,7 @@ pub trait QuotesTransaction<'a> {
         &mut self,
         quote_id: Option<&QuoteId>,
         blinded_messages: &[BlindedMessage],
+        operation: &Operation,
     ) -> Result<(), Self::Err>;
 
     /// Delete blinded_messages by their blinded secrets
@@ -265,6 +266,7 @@ pub trait ProofsTransaction<'a> {
         &mut self,
         proof: Proofs,
         quote_id: Option<QuoteId>,
+        operation: &Operation,
     ) -> Result<(), Self::Err>;
     /// Updates the proofs to a given states and return the previous states
     async fn update_proofs_states(
@@ -354,6 +356,45 @@ pub trait SignaturesDatabase {
 }
 
 #[async_trait]
+/// Saga Transaction trait
+pub trait SagaTransaction<'a> {
+    /// Saga Database Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Get saga by operation_id
+    async fn get_saga(
+        &mut self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<Option<mint::Saga>, Self::Err>;
+
+    /// Add saga
+    async fn add_saga(&mut self, saga: &mint::Saga) -> Result<(), Self::Err>;
+
+    /// Update saga state (only updates state and updated_at fields)
+    async fn update_saga(
+        &mut self,
+        operation_id: &uuid::Uuid,
+        new_state: mint::SagaStateEnum,
+    ) -> Result<(), Self::Err>;
+
+    /// Delete saga
+    async fn delete_saga(&mut self, operation_id: &uuid::Uuid) -> Result<(), Self::Err>;
+}
+
+#[async_trait]
+/// Saga Database trait
+pub trait SagaDatabase {
+    /// Saga Database Error
+    type Err: Into<Error> + From<Error>;
+
+    /// Get all incomplete sagas for a given operation kind
+    async fn get_incomplete_sagas(
+        &self,
+        operation_kind: mint::OperationKind,
+    ) -> Result<Vec<mint::Saga>, Self::Err>;
+}
+
+#[async_trait]
 /// Commit and Rollback
 pub trait DbTransactionFinalizer {
     /// Mint Signature Database Error
@@ -409,6 +450,7 @@ pub trait Transaction<'a, Error>:
     + SignaturesTransaction<'a, Err = Error>
     + ProofsTransaction<'a, Err = Error>
     + KVStoreTransaction<'a, Error>
+    + SagaTransaction<'a, Err = Error>
 {
 }
 
@@ -437,7 +479,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>;
@@ -453,8 +495,9 @@ pub trait Database<Error>:
     + QuotesDatabase<Err = Error>
     + ProofsDatabase<Err = 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>;

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

@@ -8,7 +8,7 @@ use cashu::{Amount, Id, SecretKey};
 use crate::database::mint::test::unique_string;
 use crate::database::mint::{Database, Error, KeysDatabase};
 use crate::database::MintSignaturesDatabase;
-use crate::mint::{MeltPaymentRequest, MeltQuote, MintQuote};
+use crate::mint::{MeltPaymentRequest, MeltQuote, MintQuote, Operation};
 use crate::payment::PaymentIdentifier;
 
 /// Add a mint quote
@@ -435,7 +435,7 @@ where
     tx.add_melt_request(&quote.id, inputs_amount, inputs_fee)
         .await
         .unwrap();
-    tx.add_blinded_messages(Some(&quote.id), &blinded_messages)
+    tx.add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
         .await
         .unwrap();
     tx.commit().await.unwrap();
@@ -497,7 +497,7 @@ where
         .await
         .unwrap();
     let result = tx
-        .add_blinded_messages(Some(&quote2.id), &blinded_messages)
+        .add_blinded_messages(Some(&quote2.id), &blinded_messages, &Operation::new_melt())
         .await;
     assert!(result.is_err() && matches!(result.unwrap_err(), Error::Duplicate));
     tx.rollback().await.unwrap(); // Rollback to avoid partial state
@@ -530,7 +530,7 @@ where
         .await
         .unwrap();
     assert!(tx
-        .add_blinded_messages(Some(&quote.id), &blinded_messages)
+        .add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
         .await
         .is_ok());
     tx.commit().await.unwrap();
@@ -543,7 +543,7 @@ where
         .await
         .unwrap();
     let result = tx
-        .add_blinded_messages(Some(&quote.id), &blinded_messages)
+        .add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
         .await;
     // Expect a database error due to unique violation
     assert!(result.is_err()); // Specific error might be DB-specific, e.g., SqliteError or PostgresError
@@ -576,7 +576,7 @@ where
     tx1.add_melt_request(&quote.id, inputs_amount, inputs_fee)
         .await
         .unwrap();
-    tx1.add_blinded_messages(Some(&quote.id), &blinded_messages)
+    tx1.add_blinded_messages(Some(&quote.id), &blinded_messages, &Operation::new_melt())
         .await
         .unwrap();
     tx1.commit().await.unwrap();

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

@@ -74,7 +74,9 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), None).await.unwrap();
+    tx.add_proofs(proofs.clone(), None, &Operation::new_swap())
+        .await
+        .unwrap();
 
     // Mark one proof as `pending`
     assert!(tx

+ 25 - 8
crates/cdk-common/src/database/mint/test/proofs.rs

@@ -7,6 +7,7 @@ use cashu::{Amount, Id, SecretKey};
 
 use crate::database::mint::test::setup_keyset;
 use crate::database::mint::{Database, Error, KeysDatabase, Proof, QuoteId};
+use crate::mint::Operation;
 
 /// Test get proofs by keyset id
 pub async fn get_proofs_by_keyset_id<DB>(db: DB)
@@ -36,7 +37,9 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs, Some(quote_id)).await.unwrap();
+    tx.add_proofs(proofs, Some(quote_id), &Operation::new_swap())
+        .await
+        .unwrap();
     assert!(tx.commit().await.is_ok());
 
     let (proofs, states) = db.get_proofs_by_keyset_id(&keyset_id).await.unwrap();
@@ -88,9 +91,13 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), Some(quote_id.clone()))
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        Some(quote_id.clone()),
+        &Operation::new_swap(),
+    )
+    .await
+    .unwrap();
     assert!(tx.commit().await.is_ok());
 
     let proofs_from_db = db.get_proofs_by_ys(&[proofs[0].c, proofs[1].c]).await;
@@ -132,13 +139,23 @@ where
 
     // Add proofs to database
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    tx.add_proofs(proofs.clone(), Some(quote_id.clone()))
-        .await
-        .unwrap();
+    tx.add_proofs(
+        proofs.clone(),
+        Some(quote_id.clone()),
+        &Operation::new_swap(),
+    )
+    .await
+    .unwrap();
     assert!(tx.commit().await.is_ok());
 
     let mut tx = Database::begin_transaction(&db).await.unwrap();
-    let result = tx.add_proofs(proofs.clone(), Some(quote_id.clone())).await;
+    let result = tx
+        .add_proofs(
+            proofs.clone(),
+            Some(quote_id.clone()),
+            &Operation::new_swap(),
+        )
+        .await;
 
     assert!(
         matches!(result.unwrap_err(), Error::Duplicate),

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

@@ -335,7 +335,10 @@ pub enum Error {
     /// Http transport error
     #[error("Http transport error {0:?}: {1}")]
     HttpError(Option<u16>, String),
-    #[cfg(feature = "wallet")]
+    /// Parse invoice error
+    #[cfg(feature = "mint")]
+    #[error(transparent)]
+    Uuid(#[from] uuid::Error),
     // Crate error conversions
     /// Cashu Url Error
     #[error(transparent)]

+ 203 - 1
crates/cdk-common/src/mint.rs

@@ -1,5 +1,8 @@
 //! Mint types
 
+use std::fmt;
+use std::str::FromStr;
+
 use bitcoin::bip32::DerivationPath;
 use cashu::quote_id::QuoteId;
 use cashu::util::unix_time;
@@ -14,7 +17,206 @@ use uuid::Uuid;
 
 use crate::nuts::{MeltQuoteState, MintQuoteState};
 use crate::payment::PaymentIdentifier;
-use crate::{Amount, CurrencyUnit, Id, KeySetInfo, PublicKey};
+use crate::{Amount, CurrencyUnit, Error, Id, KeySetInfo, PublicKey};
+
+/// Operation kind for saga persistence
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "lowercase")]
+pub enum OperationKind {
+    /// Swap operation
+    Swap,
+    /// Mint operation
+    Mint,
+    /// Melt operation
+    Melt,
+}
+
+impl fmt::Display for OperationKind {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            OperationKind::Swap => write!(f, "swap"),
+            OperationKind::Mint => write!(f, "mint"),
+            OperationKind::Melt => write!(f, "melt"),
+        }
+    }
+}
+
+impl FromStr for OperationKind {
+    type Err = Error;
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        let value = value.to_lowercase();
+        match value.as_str() {
+            "swap" => Ok(OperationKind::Swap),
+            "mint" => Ok(OperationKind::Mint),
+            "melt" => Ok(OperationKind::Melt),
+            _ => Err(Error::Custom(format!("Invalid operation kind: {}", value))),
+        }
+    }
+}
+
+/// States specific to swap saga
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum SwapSagaState {
+    /// Swap setup complete (proofs added, blinded messages added)
+    SetupComplete,
+    /// Outputs signed (signatures generated but not persisted)
+    Signed,
+}
+
+impl fmt::Display for SwapSagaState {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            SwapSagaState::SetupComplete => write!(f, "setup_complete"),
+            SwapSagaState::Signed => write!(f, "signed"),
+        }
+    }
+}
+
+impl FromStr for SwapSagaState {
+    type Err = Error;
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        let value = value.to_lowercase();
+        match value.as_str() {
+            "setup_complete" => Ok(SwapSagaState::SetupComplete),
+            "signed" => Ok(SwapSagaState::Signed),
+            _ => Err(Error::Custom(format!("Invalid swap saga state: {}", value))),
+        }
+    }
+}
+
+/// Saga state for different operation types
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum SagaStateEnum {
+    /// Swap saga states
+    Swap(SwapSagaState),
+    // Future: Mint saga states
+    // Mint(MintSagaState),
+    // Future: Melt saga states
+    // Melt(MeltSagaState),
+}
+
+impl SagaStateEnum {
+    /// Create from string given operation kind
+    pub fn new(operation_kind: OperationKind, s: &str) -> Result<Self, Error> {
+        match operation_kind {
+            OperationKind::Swap => Ok(SagaStateEnum::Swap(SwapSagaState::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())),
+        }
+    }
+
+    /// Get string representation of the state
+    pub fn state(&self) -> &str {
+        match self {
+            SagaStateEnum::Swap(state) => match state {
+                SwapSagaState::SetupComplete => "setup_complete",
+                SwapSagaState::Signed => "signed",
+            },
+        }
+    }
+}
+
+/// Persisted saga for recovery
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Saga {
+    /// Operation ID (correlation key)
+    pub operation_id: Uuid,
+    /// Operation kind (swap, mint, melt)
+    pub operation_kind: OperationKind,
+    /// Current saga state (operation-specific)
+    pub state: SagaStateEnum,
+    /// Blinded secrets (B values) from output blinded messages
+    pub blinded_secrets: Vec<PublicKey>,
+    /// Y values (public keys) from input proofs
+    pub input_ys: Vec<PublicKey>,
+    /// Unix timestamp when saga was created
+    pub created_at: u64,
+    /// Unix timestamp when saga was last updated
+    pub updated_at: u64,
+}
+
+impl Saga {
+    /// Create new swap saga
+    pub fn new_swap(
+        operation_id: Uuid,
+        state: SwapSagaState,
+        blinded_secrets: Vec<PublicKey>,
+        input_ys: Vec<PublicKey>,
+    ) -> Self {
+        let now = unix_time();
+        Self {
+            operation_id,
+            operation_kind: OperationKind::Swap,
+            state: SagaStateEnum::Swap(state),
+            blinded_secrets,
+            input_ys,
+            created_at: now,
+            updated_at: now,
+        }
+    }
+
+    /// Update swap saga state
+    pub fn update_swap_state(&mut self, new_state: SwapSagaState) {
+        self.state = SagaStateEnum::Swap(new_state);
+        self.updated_at = unix_time();
+    }
+}
+
+/// Operation
+pub enum Operation {
+    /// Mint
+    Mint(Uuid),
+    /// Melt
+    Melt(Uuid),
+    /// Swap
+    Swap(Uuid),
+}
+
+impl Operation {
+    /// Mint
+    pub fn new_mint() -> Self {
+        Self::Mint(Uuid::new_v4())
+    }
+    /// Melt
+    pub fn new_melt() -> Self {
+        Self::Melt(Uuid::new_v4())
+    }
+    /// Swap
+    pub fn new_swap() -> Self {
+        Self::Swap(Uuid::new_v4())
+    }
+
+    /// Operation id
+    pub fn id(&self) -> &Uuid {
+        match self {
+            Operation::Mint(id) => id,
+            Operation::Melt(id) => id,
+            Operation::Swap(id) => id,
+        }
+    }
+
+    /// Operation kind
+    pub fn kind(&self) -> &str {
+        match self {
+            Operation::Mint(_) => "mint",
+            Operation::Melt(_) => "melt",
+            Operation::Swap(_) => "swap",
+        }
+    }
+
+    /// From kind and i
+    pub fn from_kind_and_id(kind: &str, id: &str) -> Result<Self, Error> {
+        let uuid = Uuid::parse_str(id)?;
+        match kind {
+            "mint" => Ok(Self::Mint(uuid)),
+            "melt" => Ok(Self::Melt(uuid)),
+            "swap" => Ok(Self::Swap(uuid)),
+            _ => Err(Error::Custom(format!("Invalid operation kind: {}", kind))),
+        }
+    }
+}
 
 /// Mint Quote Info
 #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]

+ 45 - 1
crates/cdk-common/src/state.rs

@@ -1,6 +1,6 @@
 //! State transition rules
 
-use cashu::State;
+use cashu::{MeltQuoteState, State};
 
 /// State transition Error
 #[derive(thiserror::Error, Debug)]
@@ -14,6 +14,12 @@ pub enum Error {
     /// Invalid transition
     #[error("Invalid transition: From {0} to {1}")]
     InvalidTransition(State, State),
+    /// Already paid
+    #[error("Quote already paid")]
+    AlreadyPaid,
+    /// Invalid transition
+    #[error("Invalid melt quote state transition: From {0} to {1}")]
+    InvalidMeltQuoteTransition(MeltQuoteState, MeltQuoteState),
 }
 
 #[inline]
@@ -37,3 +43,41 @@ pub fn check_state_transition(current_state: State, new_state: State) -> Result<
         Ok(())
     }
 }
+
+#[inline]
+/// Check if the melt quote state transition is allowed
+///
+/// Valid transitions:
+/// - Unpaid -> Pending, Failed
+/// - Pending -> Unpaid, Paid, Failed
+/// - Paid -> (no transitions allowed)
+/// - Failed -> Pending
+pub fn check_melt_quote_state_transition(
+    current_state: MeltQuoteState,
+    new_state: MeltQuoteState,
+) -> Result<(), Error> {
+    let is_valid_transition = match current_state {
+        MeltQuoteState::Unpaid => {
+            matches!(new_state, MeltQuoteState::Pending | MeltQuoteState::Failed)
+        }
+        MeltQuoteState::Pending => matches!(
+            new_state,
+            MeltQuoteState::Unpaid | MeltQuoteState::Paid | MeltQuoteState::Failed
+        ),
+        MeltQuoteState::Failed => {
+            matches!(new_state, MeltQuoteState::Pending | MeltQuoteState::Unpaid)
+        }
+        MeltQuoteState::Paid => false,
+        MeltQuoteState::Unknown => true,
+    };
+
+    if !is_valid_transition {
+        Err(match current_state {
+            MeltQuoteState::Pending => Error::Pending,
+            MeltQuoteState::Paid => Error::AlreadyPaid,
+            _ => Error::InvalidMeltQuoteTransition(current_state, new_state),
+        })
+    } else {
+        Ok(())
+    }
+}

+ 21 - 15
crates/cdk-fake-wallet/src/lib.rs

@@ -327,7 +327,7 @@ pub struct FakeWallet {
     fee_reserve: FeeReserve,
     sender: tokio::sync::mpsc::Sender<WaitPaymentResponse>,
     receiver: Arc<Mutex<Option<tokio::sync::mpsc::Receiver<WaitPaymentResponse>>>>,
-    payment_states: Arc<Mutex<HashMap<String, MeltQuoteState>>>,
+    payment_states: Arc<Mutex<HashMap<String, (MeltQuoteState, Amount)>>>,
     failed_payment_check: Arc<Mutex<HashSet<String>>>,
     payment_delay: u64,
     wait_invoice_cancel_token: CancellationToken,
@@ -342,7 +342,7 @@ impl FakeWallet {
     /// Create new [`FakeWallet`]
     pub fn new(
         fee_reserve: FeeReserve,
-        payment_states: HashMap<String, MeltQuoteState>,
+        payment_states: HashMap<String, (MeltQuoteState, Amount)>,
         fail_payment_check: HashSet<String>,
         payment_delay: u64,
         unit: CurrencyUnit,
@@ -360,7 +360,7 @@ impl FakeWallet {
     /// Create new [`FakeWallet`] with custom secondary repayment queue size
     pub fn new_with_repay_queue_size(
         fee_reserve: FeeReserve,
-        payment_states: HashMap<String, MeltQuoteState>,
+        payment_states: HashMap<String, (MeltQuoteState, Amount)>,
         fail_payment_check: HashSet<String>,
         payment_delay: u64,
         unit: CurrencyUnit,
@@ -563,7 +563,22 @@ impl MintPayment for FakeWallet {
                     .map(|s| s.check_payment_state)
                     .unwrap_or(MeltQuoteState::Paid);
 
-                payment_states.insert(payment_hash.clone(), checkout_going_status);
+                let amount_msat: u64 = if let Some(melt_options) = bolt11_options.melt_options {
+                    melt_options.amount_msat().into()
+                } else {
+                    // Fall back to invoice amount
+                    bolt11
+                        .amount_milli_satoshis()
+                        .ok_or(Error::UnknownInvoiceAmount)?
+                };
+
+                let amount_spent = if checkout_going_status == MeltQuoteState::Paid {
+                    amount_msat.into()
+                } else {
+                    Amount::ZERO
+                };
+
+                payment_states.insert(payment_hash.clone(), (checkout_going_status, amount_spent));
 
                 if let Some(description) = status {
                     if description.check_err {
@@ -574,15 +589,6 @@ impl MintPayment for FakeWallet {
                     ensure_cdk!(!description.pay_err, Error::UnknownInvoice.into());
                 }
 
-                let amount_msat: u64 = if let Some(melt_options) = bolt11_options.melt_options {
-                    melt_options.amount_msat().into()
-                } else {
-                    // Fall back to invoice amount
-                    bolt11
-                        .amount_milli_satoshis()
-                        .ok_or(Error::UnknownInvoiceAmount)?
-                };
-
                 let total_spent = convert_currency_amount(
                     amount_msat,
                     &CurrencyUnit::Msat,
@@ -784,7 +790,7 @@ impl MintPayment for FakeWallet {
         let states = self.payment_states.lock().await;
         let status = states.get(&request_lookup_id.to_string()).cloned();
 
-        let status = status.unwrap_or(MeltQuoteState::Paid);
+        let (status, total_spent) = status.unwrap_or((MeltQuoteState::Unknown, Amount::default()));
 
         let fail_payments = self.failed_payment_check.lock().await;
 
@@ -796,7 +802,7 @@ impl MintPayment for FakeWallet {
             payment_proof: Some("".to_string()),
             payment_lookup_id: request_lookup_id.clone(),
             status,
-            total_spent: Amount::ZERO,
+            total_spent,
             unit: CurrencyUnit::Msat,
         })
     }

+ 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",
+]

+ 3 - 1
crates/cdk-ffi/src/database.rs

@@ -450,7 +450,9 @@ impl CdkWalletDatabase for WalletDatabaseBridge {
             .into_iter()
             .map(|info| {
                 Ok(cdk::types::ProofInfo {
-                    proof: info.proof.inner.clone(),
+                    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())
                     })?,

+ 77 - 11
crates/cdk-ffi/src/multi_mint_wallet.rs

@@ -118,9 +118,16 @@ impl MultiMintWallet {
         target_proof_count: Option<u32>,
     ) -> Result<(), FfiError> {
         let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
-        self.inner
-            .add_mint(cdk_mint_url, target_proof_count.map(|c| c as usize))
-            .await?;
+
+        if let Some(count) = target_proof_count {
+            let config = cdk::wallet::multi_mint_wallet::WalletConfig::new()
+                .with_target_proof_count(count as usize);
+            self.inner
+                .add_mint_with_config(cdk_mint_url, config)
+                .await?;
+        } else {
+            self.inner.add_mint(cdk_mint_url).await?;
+        }
         Ok(())
     }
 
@@ -168,10 +175,7 @@ impl MultiMintWallet {
         let proofs = self.inner.list_proofs().await?;
         let mut proofs_by_mint = HashMap::new();
         for (mint_url, mint_proofs) in proofs {
-            let ffi_proofs: Vec<Arc<Proof>> = mint_proofs
-                .into_iter()
-                .map(|p| Arc::new(p.into()))
-                .collect();
+            let ffi_proofs: Vec<Proof> = mint_proofs.into_iter().map(|p| p.into()).collect();
             proofs_by_mint.insert(mint_url.to_string(), ffi_proofs);
         }
         Ok(proofs_by_mint)
@@ -255,7 +259,7 @@ impl MultiMintWallet {
             .inner
             .mint(&cdk_mint_url, &quote_id, conditions)
             .await?;
-        Ok(proofs.into_iter().map(|p| Arc::new(p.into())).collect())
+        Ok(proofs.into_iter().map(|p| p.into()).collect())
     }
 
     /// Wait for a mint quote to be paid and automatically mint the proofs
@@ -281,7 +285,7 @@ impl MultiMintWallet {
                 timeout_secs,
             )
             .await?;
-        Ok(proofs.into_iter().map(|p| Arc::new(p.into())).collect())
+        Ok(proofs.into_iter().map(|p| p.into()).collect())
     }
 
     /// Get a melt quote from a specific mint
@@ -339,7 +343,7 @@ impl MultiMintWallet {
 
         let result = self.inner.swap(amount.map(Into::into), conditions).await?;
 
-        Ok(result.map(|proofs| proofs.into_iter().map(|p| Arc::new(p.into())).collect()))
+        Ok(result.map(|proofs| proofs.into_iter().map(|p| p.into()).collect()))
     }
 
     /// List transactions from all mints
@@ -380,6 +384,68 @@ impl MultiMintWallet {
         self.inner.verify_token_dleq(&cdk_token).await?;
         Ok(())
     }
+
+    /// Query mint for current mint information
+    pub async fn fetch_mint_info(&self, mint_url: MintUrl) -> Result<Option<MintInfo>, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let mint_info = self.inner.fetch_mint_info(&cdk_mint_url).await?;
+        Ok(mint_info.map(Into::into))
+    }
+}
+
+/// Auth methods for MultiMintWallet
+#[uniffi::export(async_runtime = "tokio")]
+impl MultiMintWallet {
+    /// Set Clear Auth Token (CAT) for a specific mint
+    pub async fn set_cat(&self, mint_url: MintUrl, cat: String) -> Result<(), FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        self.inner.set_cat(&cdk_mint_url, cat).await?;
+        Ok(())
+    }
+
+    /// Set refresh token for a specific mint
+    pub async fn set_refresh_token(
+        &self,
+        mint_url: MintUrl,
+        refresh_token: String,
+    ) -> Result<(), FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        self.inner
+            .set_refresh_token(&cdk_mint_url, refresh_token)
+            .await?;
+        Ok(())
+    }
+
+    /// Refresh access token for a specific mint using the stored refresh token
+    pub async fn refresh_access_token(&self, mint_url: MintUrl) -> Result<(), FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        self.inner.refresh_access_token(&cdk_mint_url).await?;
+        Ok(())
+    }
+
+    /// Mint blind auth tokens at a specific mint
+    pub async fn mint_blind_auth(
+        &self,
+        mint_url: MintUrl,
+        amount: Amount,
+    ) -> Result<Proofs, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let proofs = self
+            .inner
+            .mint_blind_auth(&cdk_mint_url, amount.into())
+            .await?;
+        Ok(proofs.into_iter().map(|p| p.into()).collect())
+    }
+
+    /// Get unspent auth proofs for a specific mint
+    pub async fn get_unspent_auth_proofs(
+        &self,
+        mint_url: MintUrl,
+    ) -> Result<Vec<AuthProof>, FfiError> {
+        let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?;
+        let auth_proofs = self.inner.get_unspent_auth_proofs(&cdk_mint_url).await?;
+        Ok(auth_proofs.into_iter().map(Into::into).collect())
+    }
 }
 
 /// Transfer mode for mint-to-mint transfers
@@ -487,4 +553,4 @@ impl From<MultiMintSendOptions> for CdkMultiMintSendOptions {
 pub type BalanceMap = HashMap<String, Amount>;
 
 /// Type alias for proofs by mint URL
-pub type ProofsByMint = HashMap<String, Vec<Arc<Proof>>>;
+pub type ProofsByMint = HashMap<String, Vec<Proof>>;

+ 1 - 1
crates/cdk-ffi/src/postgres.rs

@@ -237,7 +237,7 @@ impl WalletDatabase for WalletPostgresDatabase {
             .into_iter()
             .map(|info| {
                 Ok::<cdk::types::ProofInfo, FfiError>(cdk::types::ProofInfo {
-                    proof: info.proof.inner.clone(),
+                    proof: info.proof.try_into()?,
                     y: info.y.try_into()?,
                     mint_url: info.mint_url.try_into()?,
                     state: info.state.into(),

+ 1 - 1
crates/cdk-ffi/src/sqlite.rs

@@ -272,7 +272,7 @@ impl WalletDatabase for WalletSqliteDatabase {
             .into_iter()
             .map(|info| {
                 Ok::<cdk::types::ProofInfo, FfiError>(cdk::types::ProofInfo {
-                    proof: info.proof.inner.clone(),
+                    proof: info.proof.try_into()?,
                     y: info.y.try_into()?,
                     mint_url: info.mint_url.try_into()?,
                     state: info.state.into(),

+ 1 - 4
crates/cdk-ffi/src/token.rs

@@ -75,10 +75,7 @@ impl Token {
         // For now, return empty keysets to get all proofs
         let empty_keysets = vec![];
         let proofs = self.inner.proofs(&empty_keysets)?;
-        Ok(proofs
-            .into_iter()
-            .map(|p| std::sync::Arc::new(p.into()))
-            .collect())
+        Ok(proofs.into_iter().map(|p| p.into()).collect())
     }
 
     /// Convert token to raw bytes

+ 106 - 55
crates/cdk-ffi/src/types/proof.rs

@@ -44,78 +44,126 @@ impl From<ProofState> for CdkState {
 }
 
 /// FFI-compatible Proof
-#[derive(Debug, uniffi::Object)]
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
 pub struct Proof {
-    pub(crate) inner: cdk::nuts::Proof,
+    /// Proof amount
+    pub amount: Amount,
+    /// Secret (as string)
+    pub secret: String,
+    /// Unblinded signature C (as hex string)
+    pub c: String,
+    /// Keyset ID (as hex string)
+    pub keyset_id: String,
+    /// Optional witness
+    pub witness: Option<Witness>,
+    /// Optional DLEQ proof
+    pub dleq: Option<ProofDleq>,
 }
 
 impl From<cdk::nuts::Proof> for Proof {
     fn from(proof: cdk::nuts::Proof) -> Self {
-        Self { inner: proof }
+        Self {
+            amount: proof.amount.into(),
+            secret: proof.secret.to_string(),
+            c: proof.c.to_string(),
+            keyset_id: proof.keyset_id.to_string(),
+            witness: proof.witness.map(|w| w.into()),
+            dleq: proof.dleq.map(|d| d.into()),
+        }
     }
 }
 
-impl From<Proof> for cdk::nuts::Proof {
-    fn from(proof: Proof) -> Self {
-        proof.inner
-    }
-}
+impl TryFrom<Proof> for cdk::nuts::Proof {
+    type Error = FfiError;
 
-#[uniffi::export]
-impl Proof {
-    /// Get the amount
-    pub fn amount(&self) -> Amount {
-        self.inner.amount.into()
-    }
+    fn try_from(proof: Proof) -> Result<Self, Self::Error> {
+        use std::str::FromStr;
 
-    /// Get the secret as string
-    pub fn secret(&self) -> String {
-        self.inner.secret.to_string()
-    }
+        use cdk::nuts::Id;
 
-    /// Get the unblinded signature (C) as string
-    pub fn c(&self) -> String {
-        self.inner.c.to_string()
+        Ok(Self {
+            amount: proof.amount.into(),
+            secret: cdk::secret::Secret::from_str(&proof.secret)
+                .map_err(|e| FfiError::Serialization { msg: e.to_string() })?,
+            c: cdk::nuts::PublicKey::from_str(&proof.c)
+                .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?,
+            keyset_id: Id::from_str(&proof.keyset_id)
+                .map_err(|e| FfiError::Serialization { msg: e.to_string() })?,
+            witness: proof.witness.map(|w| w.into()),
+            dleq: proof.dleq.map(|d| d.into()),
+        })
     }
+}
 
-    /// Get the keyset ID as string
-    pub fn keyset_id(&self) -> String {
-        self.inner.keyset_id.to_string()
-    }
+/// Get the Y value (hash_to_curve of secret) for a proof
+#[uniffi::export]
+pub fn proof_y(proof: &Proof) -> Result<String, FfiError> {
+    // Convert to CDK proof to calculate Y
+    let cdk_proof: cdk::nuts::Proof = proof.clone().try_into()?;
+    Ok(cdk_proof.y()?.to_string())
+}
 
-    /// Get the witness
-    pub fn witness(&self) -> Option<Witness> {
-        self.inner.witness.as_ref().map(|w| w.clone().into())
+/// Check if proof is active with given keyset IDs
+#[uniffi::export]
+pub fn proof_is_active(proof: &Proof, active_keyset_ids: Vec<String>) -> bool {
+    use cdk::nuts::Id;
+    let ids: Vec<Id> = active_keyset_ids
+        .into_iter()
+        .filter_map(|id| Id::from_str(&id).ok())
+        .collect();
+
+    // A proof is active if its keyset_id is in the active list
+    if let Ok(keyset_id) = Id::from_str(&proof.keyset_id) {
+        ids.contains(&keyset_id)
+    } else {
+        false
     }
+}
 
-    /// Check if proof is active with given keyset IDs
-    pub fn is_active(&self, active_keyset_ids: Vec<String>) -> bool {
-        use cdk::nuts::Id;
-        let ids: Vec<Id> = active_keyset_ids
-            .into_iter()
-            .filter_map(|id| Id::from_str(&id).ok())
-            .collect();
-        self.inner.is_active(&ids)
-    }
+/// Check if proof has DLEQ proof
+#[uniffi::export]
+pub fn proof_has_dleq(proof: &Proof) -> bool {
+    proof.dleq.is_some()
+}
 
-    /// Get the Y value (hash_to_curve of secret)
-    pub fn y(&self) -> Result<String, FfiError> {
-        Ok(self.inner.y()?.to_string())
-    }
+/// Verify HTLC witness on a proof
+#[uniffi::export]
+pub fn proof_verify_htlc(proof: &Proof) -> Result<(), FfiError> {
+    let cdk_proof: cdk::nuts::Proof = proof.clone().try_into()?;
+    cdk_proof
+        .verify_htlc()
+        .map_err(|e| FfiError::Generic { msg: e.to_string() })
+}
 
-    /// Get the DLEQ proof if present
-    pub fn dleq(&self) -> Option<ProofDleq> {
-        self.inner.dleq.as_ref().map(|d| d.clone().into())
-    }
+/// Verify DLEQ proof on a proof
+#[uniffi::export]
+pub fn proof_verify_dleq(
+    proof: &Proof,
+    mint_pubkey: super::keys::PublicKey,
+) -> Result<(), FfiError> {
+    let cdk_proof: cdk::nuts::Proof = proof.clone().try_into()?;
+    let cdk_pubkey: cdk::nuts::PublicKey = mint_pubkey.try_into()?;
+    cdk_proof
+        .verify_dleq(cdk_pubkey)
+        .map_err(|e| FfiError::Generic { msg: e.to_string() })
+}
+
+/// Sign a P2PK proof with a secret key, returning a new signed proof
+#[uniffi::export]
+pub fn proof_sign_p2pk(proof: Proof, secret_key_hex: String) -> Result<Proof, FfiError> {
+    let mut cdk_proof: cdk::nuts::Proof = proof.try_into()?;
+    let secret_key = cdk::nuts::SecretKey::from_hex(&secret_key_hex)
+        .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?;
 
-    /// Check if proof has DLEQ proof
-    pub fn has_dleq(&self) -> bool {
-        self.inner.dleq.is_some()
-    }
+    cdk_proof
+        .sign_p2pk(secret_key)
+        .map_err(|e| FfiError::Generic { msg: e.to_string() })?;
+
+    Ok(cdk_proof.into())
 }
 
 /// FFI-compatible Proofs (vector of Proof)
-pub type Proofs = Vec<std::sync::Arc<Proof>>;
+pub type Proofs = Vec<Proof>;
 
 /// FFI-compatible DLEQ proof for proofs
 #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
@@ -175,9 +223,12 @@ impl From<BlindSignatureDleq> for cdk::nuts::BlindSignatureDleq {
     }
 }
 
-/// Helper functions for Proofs
+/// Helper function to calculate total amount of proofs
+#[uniffi::export]
 pub fn proofs_total_amount(proofs: &Proofs) -> Result<Amount, FfiError> {
-    let cdk_proofs: Vec<cdk::nuts::Proof> = proofs.iter().map(|p| p.inner.clone()).collect();
+    let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
+        proofs.iter().map(|p| p.clone().try_into()).collect();
+    let cdk_proofs = cdk_proofs?;
     use cdk::nuts::ProofsMethods;
     Ok(cdk_proofs.total_amount()?.into())
 }
@@ -420,7 +471,7 @@ impl TryFrom<SpendingConditions> for cdk::nuts::SpendingConditions {
 #[derive(Debug, Clone, uniffi::Record)]
 pub struct ProofInfo {
     /// Proof
-    pub proof: std::sync::Arc<Proof>,
+    pub proof: Proof,
     /// Y value (hash_to_curve of secret)
     pub y: super::keys::PublicKey,
     /// Mint URL
@@ -436,7 +487,7 @@ pub struct ProofInfo {
 impl From<cdk::types::ProofInfo> for ProofInfo {
     fn from(info: cdk::types::ProofInfo) -> Self {
         Self {
-            proof: std::sync::Arc::new(info.proof.into()),
+            proof: info.proof.into(),
             y: info.y.into(),
             mint_url: info.mint_url.into(),
             state: info.state.into(),
@@ -458,7 +509,7 @@ pub fn decode_proof_info(json: String) -> Result<ProofInfo, FfiError> {
 pub fn encode_proof_info(info: ProofInfo) -> Result<String, FfiError> {
     // Convert to cdk::types::ProofInfo for serialization
     let cdk_info = cdk::types::ProofInfo {
-        proof: info.proof.inner.clone(),
+        proof: info.proof.try_into()?,
         y: info.y.try_into()?,
         mint_url: info.mint_url.try_into()?,
         state: info.state.into(),

+ 19 - 106
crates/cdk-ffi/src/types/quote.rs

@@ -77,30 +77,25 @@ impl TryFrom<MintQuote> for cdk::wallet::MintQuote {
     }
 }
 
-impl MintQuote {
-    /// Get total amount (amount + fees)
-    pub fn total_amount(&self) -> Amount {
-        if let Some(amount) = self.amount {
-            Amount::new(amount.value + self.amount_paid.value - self.amount_issued.value)
-        } else {
-            Amount::zero()
-        }
-    }
-
-    /// Check if quote is expired
-    pub fn is_expired(&self, current_time: u64) -> bool {
-        current_time > self.expiry
-    }
+/// Get total amount for a mint quote (amount paid)
+#[uniffi::export]
+pub fn mint_quote_total_amount(quote: &MintQuote) -> Result<Amount, FfiError> {
+    let cdk_quote: cdk::wallet::MintQuote = quote.clone().try_into()?;
+    Ok(cdk_quote.total_amount().into())
+}
 
-    /// Get amount that can be minted
-    pub fn amount_mintable(&self) -> Amount {
-        Amount::new(self.amount_paid.value - self.amount_issued.value)
-    }
+/// Check if mint quote is expired
+#[uniffi::export]
+pub fn mint_quote_is_expired(quote: &MintQuote, current_time: u64) -> Result<bool, FfiError> {
+    let cdk_quote: cdk::wallet::MintQuote = quote.clone().try_into()?;
+    Ok(cdk_quote.is_expired(current_time))
+}
 
-    /// Convert MintQuote to JSON string
-    pub fn to_json(&self) -> Result<String, FfiError> {
-        Ok(serde_json::to_string(self)?)
-    }
+/// Get amount that can be minted from a mint quote
+#[uniffi::export]
+pub fn mint_quote_amount_mintable(quote: &MintQuote) -> Result<Amount, FfiError> {
+    let cdk_quote: cdk::wallet::MintQuote = quote.clone().try_into()?;
+    Ok(cdk_quote.amount_mintable().into())
 }
 
 /// Decode MintQuote from JSON string
@@ -117,7 +112,7 @@ pub fn encode_mint_quote(quote: MintQuote) -> Result<String, FfiError> {
 }
 
 /// FFI-compatible MintQuoteBolt11Response
-#[derive(Debug, uniffi::Object)]
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
 pub struct MintQuoteBolt11Response {
     /// Quote ID
     pub quote: String,
@@ -149,46 +144,8 @@ impl From<cdk::nuts::MintQuoteBolt11Response<String>> for MintQuoteBolt11Respons
     }
 }
 
-#[uniffi::export]
-impl MintQuoteBolt11Response {
-    /// Get quote ID
-    pub fn quote(&self) -> String {
-        self.quote.clone()
-    }
-
-    /// Get request string
-    pub fn request(&self) -> String {
-        self.request.clone()
-    }
-
-    /// Get state
-    pub fn state(&self) -> QuoteState {
-        self.state.clone()
-    }
-
-    /// Get expiry
-    pub fn expiry(&self) -> Option<u64> {
-        self.expiry
-    }
-
-    /// Get amount
-    pub fn amount(&self) -> Option<Amount> {
-        self.amount
-    }
-
-    /// Get unit
-    pub fn unit(&self) -> Option<CurrencyUnit> {
-        self.unit.clone()
-    }
-
-    /// Get pubkey
-    pub fn pubkey(&self) -> Option<String> {
-        self.pubkey.clone()
-    }
-}
-
 /// FFI-compatible MeltQuoteBolt11Response
-#[derive(Debug, uniffi::Object)]
+#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
 pub struct MeltQuoteBolt11Response {
     /// Quote ID
     pub quote: String,
@@ -222,50 +179,6 @@ impl From<cdk::nuts::MeltQuoteBolt11Response<String>> for MeltQuoteBolt11Respons
         }
     }
 }
-
-#[uniffi::export]
-impl MeltQuoteBolt11Response {
-    /// Get quote ID
-    pub fn quote(&self) -> String {
-        self.quote.clone()
-    }
-
-    /// Get amount
-    pub fn amount(&self) -> Amount {
-        self.amount
-    }
-
-    /// Get fee reserve
-    pub fn fee_reserve(&self) -> Amount {
-        self.fee_reserve
-    }
-
-    /// Get state
-    pub fn state(&self) -> QuoteState {
-        self.state.clone()
-    }
-
-    /// Get expiry
-    pub fn expiry(&self) -> u64 {
-        self.expiry
-    }
-
-    /// Get payment preimage
-    pub fn payment_preimage(&self) -> Option<String> {
-        self.payment_preimage.clone()
-    }
-
-    /// Get request
-    pub fn request(&self) -> Option<String> {
-        self.request.clone()
-    }
-
-    /// Get unit
-    pub fn unit(&self) -> Option<CurrencyUnit> {
-        self.unit.clone()
-    }
-}
-
 /// FFI-compatible PaymentMethod
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
 pub enum PaymentMethod {

+ 4 - 8
crates/cdk-ffi/src/types/subscription.rs

@@ -139,13 +139,9 @@ pub enum NotificationPayload {
     /// Proof state update
     ProofState { proof_states: Vec<ProofStateUpdate> },
     /// Mint quote update
-    MintQuoteUpdate {
-        quote: std::sync::Arc<MintQuoteBolt11Response>,
-    },
+    MintQuoteUpdate { quote: MintQuoteBolt11Response },
     /// Melt quote update
-    MeltQuoteUpdate {
-        quote: std::sync::Arc<MeltQuoteBolt11Response>,
-    },
+    MeltQuoteUpdate { quote: MeltQuoteBolt11Response },
 }
 
 impl From<MintEvent<String>> for NotificationPayload {
@@ -156,12 +152,12 @@ impl From<MintEvent<String>> for NotificationPayload {
             },
             cdk::nuts::NotificationPayload::MintQuoteBolt11Response(quote_resp) => {
                 NotificationPayload::MintQuoteUpdate {
-                    quote: std::sync::Arc::new(quote_resp.into()),
+                    quote: quote_resp.into(),
                 }
             }
             cdk::nuts::NotificationPayload::MeltQuoteBolt11Response(quote_resp) => {
                 NotificationPayload::MeltQuoteUpdate {
-                    quote: std::sync::Arc::new(quote_resp.into()),
+                    quote: quote_resp.into(),
                 }
             }
             _ => {

+ 18 - 1
crates/cdk-ffi/src/types/transaction.rs

@@ -106,6 +106,21 @@ pub fn encode_transaction(transaction: Transaction) -> Result<String, FfiError>
     Ok(serde_json::to_string(&transaction)?)
 }
 
+/// Check if a transaction matches the given filter conditions
+#[uniffi::export]
+pub fn transaction_matches_conditions(
+    transaction: &Transaction,
+    mint_url: Option<MintUrl>,
+    direction: Option<TransactionDirection>,
+    unit: Option<CurrencyUnit>,
+) -> Result<bool, FfiError> {
+    let cdk_transaction: cdk::wallet::types::Transaction = transaction.clone().try_into()?;
+    let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?;
+    let cdk_direction = direction.map(Into::into);
+    let cdk_unit = unit.map(Into::into);
+    Ok(cdk_transaction.matches_conditions(&cdk_mint_url, &cdk_direction, &cdk_unit))
+}
+
 /// FFI-compatible TransactionDirection
 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
 pub enum TransactionDirection {
@@ -163,7 +178,9 @@ impl TransactionId {
 
     /// Create from proofs
     pub fn from_proofs(proofs: &Proofs) -> Result<Self, FfiError> {
-        let cdk_proofs: Vec<cdk::nuts::Proof> = proofs.iter().map(|p| p.inner.clone()).collect();
+        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
+            proofs.iter().map(|p| p.clone().try_into()).collect();
+        let cdk_proofs = cdk_proofs?;
         let id = cdk::wallet::types::TransactionId::from_proofs(cdk_proofs)?;
         Ok(Self {
             hex: id.to_string(),

+ 4 - 7
crates/cdk-ffi/src/types/wallet.rs

@@ -314,7 +314,7 @@ impl From<cdk::wallet::PreparedSend> for PreparedSend {
             .proofs()
             .iter()
             .cloned()
-            .map(|p| std::sync::Arc::new(p.into()))
+            .map(|p| p.into())
             .collect();
         Self {
             inner: Mutex::new(Some(prepared)),
@@ -421,12 +421,9 @@ impl From<cdk::types::Melted> for Melted {
         Self {
             state: melted.state.into(),
             preimage: melted.preimage,
-            change: melted.change.map(|proofs| {
-                proofs
-                    .into_iter()
-                    .map(|p| std::sync::Arc::new(p.into()))
-                    .collect()
-            }),
+            change: melted
+                .change
+                .map(|proofs| proofs.into_iter().map(|p| p.into()).collect()),
             amount: melted.amount.into(),
             fee_paid: melted.fee_paid.into(),
         }

+ 22 - 29
crates/cdk-ffi/src/wallet.rs

@@ -119,8 +119,9 @@ impl Wallet {
         options: ReceiveOptions,
         memo: Option<String>,
     ) -> Result<Amount, FfiError> {
-        let cdk_proofs: Vec<cdk::nuts::Proof> =
-            proofs.into_iter().map(|p| p.inner.clone()).collect();
+        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
+            proofs.into_iter().map(|p| p.try_into()).collect();
+        let cdk_proofs = cdk_proofs?;
 
         let amount = self
             .inner
@@ -166,10 +167,7 @@ impl Wallet {
             .inner
             .mint(&quote_id, amount_split_target.into(), conditions)
             .await?;
-        Ok(proofs
-            .into_iter()
-            .map(|p| std::sync::Arc::new(p.into()))
-            .collect())
+        Ok(proofs.into_iter().map(|p| p.into()).collect())
     }
 
     /// Get a melt quote
@@ -222,10 +220,7 @@ impl Wallet {
             )
             .await?;
 
-        Ok(proofs
-            .into_iter()
-            .map(|p| std::sync::Arc::new(p.into()))
-            .collect())
+        Ok(proofs.into_iter().map(|p| p.into()).collect())
     }
 
     /// Get a quote for a bolt12 melt
@@ -248,8 +243,9 @@ impl Wallet {
         spending_conditions: Option<SpendingConditions>,
         include_fees: bool,
     ) -> Result<Option<Proofs>, FfiError> {
-        let cdk_proofs: Vec<cdk::nuts::Proof> =
-            input_proofs.into_iter().map(|p| p.inner.clone()).collect();
+        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
+            input_proofs.into_iter().map(|p| p.try_into()).collect();
+        let cdk_proofs = cdk_proofs?;
 
         // Convert spending conditions if provided
         let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?;
@@ -265,12 +261,7 @@ impl Wallet {
             )
             .await?;
 
-        Ok(result.map(|proofs| {
-            proofs
-                .into_iter()
-                .map(|p| std::sync::Arc::new(p.into()))
-                .collect()
-        }))
+        Ok(result.map(|proofs| proofs.into_iter().map(|p| p.into()).collect()))
     }
 
     /// Get proofs by states
@@ -291,7 +282,7 @@ impl Wallet {
             };
 
             for proof in proofs {
-                all_proofs.push(std::sync::Arc::new(proof.into()));
+                all_proofs.push(proof.into());
             }
         }
 
@@ -300,8 +291,9 @@ impl Wallet {
 
     /// Check if proofs are spent
     pub async fn check_proofs_spent(&self, proofs: Proofs) -> Result<Vec<bool>, FfiError> {
-        let cdk_proofs: Vec<cdk::nuts::Proof> =
-            proofs.into_iter().map(|p| p.inner.clone()).collect();
+        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
+            proofs.into_iter().map(|p| p.try_into()).collect();
+        let cdk_proofs = cdk_proofs?;
 
         let proof_states = self.inner.check_proofs_spent(cdk_proofs).await?;
         // Convert ProofState to bool (spent = true, unspent = false)
@@ -382,7 +374,9 @@ impl Wallet {
 
     /// Reclaim unspent proofs (mark them as unspent in the database)
     pub async fn reclaim_unspent(&self, proofs: Proofs) -> Result<(), FfiError> {
-        let cdk_proofs: Vec<cdk::nuts::Proof> = proofs.iter().map(|p| p.inner.clone()).collect();
+        let cdk_proofs: Result<Vec<cdk::nuts::Proof>, _> =
+            proofs.iter().map(|p| p.clone().try_into()).collect();
+        let cdk_proofs = cdk_proofs?;
         self.inner.reclaim_unspent(cdk_proofs).await?;
         Ok(())
     }
@@ -401,9 +395,11 @@ impl Wallet {
     ) -> Result<Amount, FfiError> {
         let id = cdk::nuts::Id::from_str(&keyset_id)
             .map_err(|e| FfiError::Generic { msg: e.to_string() })?;
-        let fee_and_amounts = self.inner.get_keyset_fees_and_amounts_by_id(id).await?;
-        let total_fee = (proof_count as u64 * fee_and_amounts.fee()) / 1000; // fee is per thousand
-        Ok(Amount::new(total_fee))
+        let fee = self
+            .inner
+            .get_keyset_count_fee(&id, proof_count as u64)
+            .await?;
+        Ok(fee.into())
     }
 }
 
@@ -453,10 +449,7 @@ impl Wallet {
     /// Mint blind auth tokens
     pub async fn mint_blind_auth(&self, amount: Amount) -> Result<Proofs, FfiError> {
         let proofs = self.inner.mint_blind_auth(amount.into()).await?;
-        Ok(proofs
-            .into_iter()
-            .map(|p| std::sync::Arc::new(p.into()))
-            .collect())
+        Ok(proofs.into_iter().map(|p| p.into()).collect())
     }
 
     /// Get unspent auth proofs

+ 6 - 9
crates/cdk-integration-tests/tests/ffi_minting_integration.rs

@@ -18,7 +18,7 @@ use std::time::Duration;
 
 use bip39::Mnemonic;
 use cdk_ffi::sqlite::WalletSqliteDatabase;
-use cdk_ffi::types::{Amount, CurrencyUnit, QuoteState, SplitTarget};
+use cdk_ffi::types::{encode_mint_quote, Amount, CurrencyUnit, QuoteState, SplitTarget};
 use cdk_ffi::wallet::Wallet as FfiWallet;
 use cdk_ffi::WalletConfig;
 use cdk_integration_tests::{get_mint_url_from_env, pay_if_regtest};
@@ -157,7 +157,7 @@ async fn test_ffi_full_minting_flow() {
     );
 
     // Calculate total amount of minted proofs
-    let total_minted: u64 = mint_result.iter().map(|proof| proof.amount().value).sum();
+    let total_minted: u64 = mint_result.iter().map(|proof| proof.amount.value).sum();
     assert_eq!(
         total_minted, mint_amount.value,
         "Total minted amount should equal requested amount"
@@ -166,14 +166,11 @@ async fn test_ffi_full_minting_flow() {
     // Verify each proof has valid properties
     for proof in &mint_result {
         assert!(
-            proof.amount().value > 0,
+            proof.amount.value > 0,
             "Each proof should have positive amount"
         );
-        assert!(
-            !proof.secret().is_empty(),
-            "Each proof should have a secret"
-        );
-        assert!(!proof.c().is_empty(), "Each proof should have a C value");
+        assert!(!proof.secret.is_empty(), "Each proof should have a secret");
+        assert!(!proof.c.is_empty(), "Each proof should have a C value");
     }
 
     // Step 4: Verify wallet balance after minting
@@ -235,7 +232,7 @@ async fn test_ffi_mint_quote_creation() {
         );
 
         // Test quote JSON serialization (useful for bindings that need JSON)
-        let quote_json = quote.to_json().expect("Quote should serialize to JSON");
+        let quote_json = encode_mint_quote(quote.clone()).expect("Quote should serialize to JSON");
         assert!(!quote_json.is_empty(), "Quote JSON should not be empty");
 
         println!(

+ 903 - 0
crates/cdk-integration-tests/tests/test_swap_flow.rs

@@ -0,0 +1,903 @@
+//! Comprehensive tests for the current swap flow
+//!
+//! These tests validate the swap operation's behavior including:
+//! - Happy path: successful token swaps
+//! - Error handling: validation failures, rollback scenarios
+//! - Edge cases: concurrent operations, double-spending
+//! - State management: proof states, blinded message tracking
+//!
+//! The tests focus on the current implementation using ProofWriter and BlindedMessageWriter
+//! patterns to ensure proper cleanup and rollback behavior.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use cashu::amount::SplitTarget;
+use cashu::dhke::construct_proofs;
+use cashu::{CurrencyUnit, Id, PreMintSecrets, SecretKey, SpendingConditions, State, SwapRequest};
+use cdk::mint::Mint;
+use cdk::nuts::nut00::ProofsMethods;
+use cdk::Amount;
+use cdk_integration_tests::init_pure_tests::*;
+
+/// Helper to get the active keyset ID from a mint
+async fn get_keyset_id(mint: &Mint) -> Id {
+    let keys = mint.pubkeys().keysets.first().unwrap().clone();
+    keys.verify_id()
+        .expect("Keyset ID generation is successful");
+    keys.id
+}
+
+/// Tests the complete happy path of a swap operation:
+/// 1. Wallet is funded with tokens
+/// 2. Blinded messages are added to database
+/// 3. Outputs are signed by mint
+/// 4. Input proofs are verified
+/// 5. Transaction is balanced
+/// 6. Proofs are added and marked as spent
+/// 7. Blind signatures are saved
+/// All steps should succeed and database should be in consistent state.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_happy_path() {
+    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 100 sats
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Create swap request for same amount (100 sats)
+    let preswap = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
+
+    // Execute swap
+    let swap_response = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Swap should succeed");
+
+    // Verify response contains correct number of signatures
+    assert_eq!(
+        swap_response.signatures.len(),
+        preswap.blinded_messages().len(),
+        "Should receive signature for each blinded message"
+    );
+
+    // Verify input proofs are marked as spent
+    let states = mint
+        .localstore()
+        .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::<Vec<_>>())
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert_eq!(
+            State::Spent,
+            state.expect("State should be known"),
+            "All input proofs should be marked as spent"
+        );
+    }
+
+    // Verify blind signatures were saved
+    let saved_signatures = mint
+        .localstore()
+        .get_blind_signatures(
+            &preswap
+                .blinded_messages()
+                .iter()
+                .map(|bm| bm.blinded_secret)
+                .collect::<Vec<_>>(),
+        )
+        .await
+        .expect("Failed to get blind signatures");
+
+    assert_eq!(
+        saved_signatures.len(),
+        swap_response.signatures.len(),
+        "All signatures should be saved"
+    );
+}
+
+/// Tests that duplicate blinded messages are rejected:
+/// 1. First swap with blinded messages succeeds
+/// 2. Second swap attempt with same blinded messages fails
+/// 3. BlindedMessageWriter should prevent reuse
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_duplicate_blinded_messages() {
+    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 200 sats (enough for two swaps)
+    fund_wallet(wallet.clone(), 200, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let all_proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    // Split proofs into two sets
+    let mid = all_proofs.len() / 2;
+    let proofs1: Vec<_> = all_proofs.iter().take(mid).cloned().collect();
+    let proofs2: Vec<_> = all_proofs.iter().skip(mid).cloned().collect();
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Create blinded messages for first swap
+    let preswap = PreMintSecrets::random(
+        keyset_id,
+        proofs1.total_amount().unwrap(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let blinded_messages = preswap.blinded_messages();
+
+    // First swap should succeed
+    let swap_request1 = SwapRequest::new(proofs1, blinded_messages.clone());
+    mint.process_swap_request(swap_request1)
+        .await
+        .expect("First swap should succeed");
+
+    // Second swap with SAME blinded messages should fail
+    let swap_request2 = SwapRequest::new(proofs2, blinded_messages.clone());
+    let result = mint.process_swap_request(swap_request2).await;
+
+    assert!(
+        result.is_err(),
+        "Second swap with duplicate blinded messages should fail"
+    );
+}
+
+/// Tests that swap correctly rejects double-spending attempts:
+/// 1. First swap with proofs succeeds
+/// 2. Second swap with same proofs fails with TokenAlreadySpent
+/// 3. ProofWriter should detect already-spent proofs
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_double_spend_detection() {
+    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 100 sats
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // First swap
+    let preswap1 = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request1 = SwapRequest::new(proofs.clone(), preswap1.blinded_messages());
+    mint.process_swap_request(swap_request1)
+        .await
+        .expect("First swap should succeed");
+
+    // Second swap with same proofs should fail
+    let preswap2 = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request2 = SwapRequest::new(proofs.clone(), preswap2.blinded_messages());
+    let result = mint.process_swap_request(swap_request2).await;
+
+    match result {
+        Err(cdk::Error::TokenAlreadySpent) => {
+            // Expected error
+        }
+        Err(err) => panic!("Wrong error type: {:?}", err),
+        Ok(_) => panic!("Double spend should not succeed"),
+    }
+}
+
+/// Tests that unbalanced swap requests are rejected:
+/// Case 1: Output amount < Input amount (trying to steal from mint)
+/// Case 2: Output amount > Input amount (trying to create tokens)
+/// Both should fail with TransactionUnbalanced error.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_unbalanced_transaction_detection() {
+    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 100 sats
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Case 1: Try to swap for LESS (95 < 100) - underpaying
+    let preswap_less = PreMintSecrets::random(
+        keyset_id,
+        95.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request_less = SwapRequest::new(proofs.clone(), preswap_less.blinded_messages());
+
+    match mint.process_swap_request(swap_request_less).await {
+        Err(cdk::Error::TransactionUnbalanced(_, _, _)) => {
+            // Expected error
+        }
+        Err(err) => panic!("Wrong error type for underpay: {:?}", err),
+        Ok(_) => panic!("Unbalanced swap (underpay) should not succeed"),
+    }
+
+    // Case 2: Try to swap for MORE (105 > 100) - overpaying/creating tokens
+    let preswap_more = PreMintSecrets::random(
+        keyset_id,
+        105.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request_more = SwapRequest::new(proofs.clone(), preswap_more.blinded_messages());
+
+    match mint.process_swap_request(swap_request_more).await {
+        Err(cdk::Error::TransactionUnbalanced(_, _, _)) => {
+            // Expected error
+        }
+        Err(err) => panic!("Wrong error type for overpay: {:?}", err),
+        Ok(_) => panic!("Unbalanced swap (overpay) should not succeed"),
+    }
+}
+
+/// Tests P2PK (Pay-to-Public-Key) spending conditions:
+/// 1. Create proofs locked to a public key
+/// 2. Attempt swap without signature - should fail
+/// 3. Attempt swap with valid signature - should succeed
+/// Validates NUT-11 signature enforcement.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_p2pk_signature_validation() {
+    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 100 sats
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let input_proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let secret_key = SecretKey::generate();
+
+    // Create P2PK locked outputs
+    let spending_conditions = SpendingConditions::new_p2pk(secret_key.public_key(), None);
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    let pre_swap = PreMintSecrets::with_conditions(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &spending_conditions,
+        &fee_and_amounts,
+    )
+    .expect("Failed to create P2PK preswap");
+
+    let swap_request = SwapRequest::new(input_proofs.clone(), pre_swap.blinded_messages());
+
+    // First swap to get P2PK locked proofs
+    let keys = mint.pubkeys().keysets.first().cloned().unwrap().keys;
+
+    let post_swap = mint
+        .process_swap_request(swap_request)
+        .await
+        .expect("Initial swap should succeed");
+
+    // Construct proofs from swap response
+    let mut p2pk_proofs = construct_proofs(
+        post_swap.signatures,
+        pre_swap.rs(),
+        pre_swap.secrets(),
+        &keys,
+    )
+    .expect("Failed to construct proofs");
+
+    // Try to spend P2PK proofs WITHOUT signature - should fail
+    let preswap_unsigned = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request_unsigned =
+        SwapRequest::new(p2pk_proofs.clone(), preswap_unsigned.blinded_messages());
+
+    match mint.process_swap_request(swap_request_unsigned).await {
+        Err(cdk::Error::NUT11(cdk::nuts::nut11::Error::SignaturesNotProvided)) => {
+            // Expected error
+        }
+        Err(err) => panic!("Wrong error type: {:?}", err),
+        Ok(_) => panic!("Unsigned P2PK spend should fail"),
+    }
+
+    // Sign the proofs with correct key
+    for proof in &mut p2pk_proofs {
+        proof
+            .sign_p2pk(secret_key.clone())
+            .expect("Failed to sign proof");
+    }
+
+    // Try again WITH signature - should succeed
+    let preswap_signed = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request_signed = SwapRequest::new(p2pk_proofs, preswap_signed.blinded_messages());
+
+    mint.process_swap_request(swap_request_signed)
+        .await
+        .expect("Signed P2PK spend should succeed");
+}
+
+/// Tests rollback behavior when duplicate blinded messages are used:
+/// This validates that the BlindedMessageWriter prevents reuse of blinded messages.
+/// 1. First swap with blinded messages succeeds
+/// 2. Second swap with same blinded messages fails
+/// 3. The failure should happen early (during blinded message addition)
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_rollback_on_duplicate_blinded_message() {
+    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 with enough for multiple swaps
+    fund_wallet(wallet.clone(), 200, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let all_proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let mid = all_proofs.len() / 2;
+    let proofs1: Vec<_> = all_proofs.iter().take(mid).cloned().collect();
+    let proofs2: Vec<_> = all_proofs.iter().skip(mid).cloned().collect();
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Create shared blinded messages
+    let preswap = PreMintSecrets::random(
+        keyset_id,
+        proofs1.total_amount().unwrap(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let blinded_messages = preswap.blinded_messages();
+
+    // Extract proof2 ys before moving proofs2
+    let proof2_ys: Vec<_> = proofs2.iter().map(|p| p.y().unwrap()).collect();
+
+    // First swap succeeds
+    let swap1 = SwapRequest::new(proofs1, blinded_messages.clone());
+    mint.process_swap_request(swap1)
+        .await
+        .expect("First swap should succeed");
+
+    // Second swap with duplicate blinded messages should fail early
+    // The BlindedMessageWriter should detect duplicate and prevent the swap
+    let swap2 = SwapRequest::new(proofs2, blinded_messages.clone());
+    let result = mint.process_swap_request(swap2).await;
+
+    assert!(
+        result.is_err(),
+        "Duplicate blinded messages should cause failure"
+    );
+
+    // Verify the second set of proofs are NOT marked as spent
+    // (since the swap failed before processing them)
+    let states = mint
+        .localstore()
+        .get_proofs_states(&proof2_ys)
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert!(
+            state.is_none(),
+            "Proofs from failed swap should not be marked as spent"
+        );
+    }
+}
+
+/// Tests concurrent swap attempts with same proofs:
+/// Spawns 3 concurrent tasks trying to swap the same proofs.
+/// Only one should succeed, others should fail with TokenAlreadySpent or TokenPending.
+/// Validates that concurrent access is properly handled.
+#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
+async fn test_swap_concurrent_double_spend_prevention() {
+    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
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Create 3 different swap requests with SAME proofs but different outputs
+    let preswap1 = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap 1");
+
+    let preswap2 = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap 2");
+
+    let preswap3 = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap 3");
+
+    let swap_request1 = SwapRequest::new(proofs.clone(), preswap1.blinded_messages());
+    let swap_request2 = SwapRequest::new(proofs.clone(), preswap2.blinded_messages());
+    let swap_request3 = SwapRequest::new(proofs.clone(), preswap3.blinded_messages());
+
+    // Spawn concurrent tasks
+    let mint1 = mint.clone();
+    let mint2 = mint.clone();
+    let mint3 = mint.clone();
+
+    let task1 = tokio::spawn(async move { mint1.process_swap_request(swap_request1).await });
+    let task2 = tokio::spawn(async move { mint2.process_swap_request(swap_request2).await });
+    let task3 = tokio::spawn(async move { mint3.process_swap_request(swap_request3).await });
+
+    // Wait for all tasks
+    let results = tokio::try_join!(task1, task2, task3).expect("Tasks should complete");
+
+    // Count successes and failures
+    let mut success_count = 0;
+    let mut failure_count = 0;
+
+    for result in [results.0, results.1, results.2] {
+        match result {
+            Ok(_) => success_count += 1,
+            Err(cdk::Error::TokenAlreadySpent) | Err(cdk::Error::TokenPending) => {
+                failure_count += 1
+            }
+            Err(err) => panic!("Unexpected error: {:?}", err),
+        }
+    }
+
+    assert_eq!(
+        success_count, 1,
+        "Exactly one swap should succeed in concurrent scenario"
+    );
+    assert_eq!(
+        failure_count, 2,
+        "Exactly two swaps should fail in concurrent scenario"
+    );
+
+    // Verify all proofs are marked as spent
+    let states = mint
+        .localstore()
+        .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::<Vec<_>>())
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert_eq!(
+            State::Spent,
+            state.expect("State should be known"),
+            "All proofs should be marked as spent after concurrent attempts"
+        );
+    }
+}
+
+/// Tests swap with fees enabled:
+/// 1. Create mint with keyset that has fees (1 sat per proof)
+/// 2. Fund wallet with many small proofs
+/// 3. Attempt swap without paying fee - should fail
+/// 4. Attempt swap with correct fee deduction - should succeed
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_with_fees() {
+    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");
+
+    // Rotate to keyset with 1 sat per proof fee
+    mint.rotate_keyset(CurrencyUnit::Sat, 32, 1)
+        .await
+        .expect("Failed to rotate keyset");
+
+    // Fund with 1000 sats as individual 1-sat proofs using the fee-based keyset
+    // Wait a bit for keyset to be available
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    fund_wallet(wallet.clone(), 1000, Some(SplitTarget::Value(Amount::ONE)))
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    // Take 100 proofs (100 sats total, will need to pay fee)
+    let hundred_proofs: Vec<_> = proofs.iter().take(100).cloned().collect();
+
+    // Get the keyset ID from the proofs (which will be the fee-based keyset)
+    let keyset_id = hundred_proofs[0].keyset_id;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Try to swap for 100 outputs (same as input) - should fail due to unpaid fee
+    let preswap_no_fee = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_no_fee = SwapRequest::new(hundred_proofs.clone(), preswap_no_fee.blinded_messages());
+
+    match mint.process_swap_request(swap_no_fee).await {
+        Err(cdk::Error::TransactionUnbalanced(_, _, _)) => {
+            // Expected - didn't pay the fee
+        }
+        Err(err) => panic!("Wrong error type: {:?}", err),
+        Ok(_) => panic!("Should fail when fee not paid"),
+    }
+
+    // Calculate correct fee (1 sat per input proof in this keyset)
+    let fee = hundred_proofs.len() as u64; // 1 sat per proof = 100 sats fee
+    let output_amount = 100 - fee;
+
+    // Swap with correct fee deduction - should succeed if output_amount > 0
+    if output_amount > 0 {
+        let preswap_with_fee = PreMintSecrets::random(
+            keyset_id,
+            output_amount.into(),
+            &SplitTarget::default(),
+            &fee_and_amounts,
+        )
+        .expect("Failed to create preswap with fee");
+
+        let swap_with_fee =
+            SwapRequest::new(hundred_proofs.clone(), preswap_with_fee.blinded_messages());
+
+        mint.process_swap_request(swap_with_fee)
+            .await
+            .expect("Swap with correct fee should succeed");
+    }
+}
+
+/// Tests that swap correctly handles amount overflow:
+/// Attempts to create outputs that would overflow u64 when summed.
+/// This should be rejected before any database operations occur.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_amount_overflow_protection() {
+    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
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Try to create outputs that would overflow
+    // 2^63 + 2^63 + small amount would overflow u64
+    let large_amount = 2_u64.pow(63);
+
+    let pre_mint1 = PreMintSecrets::random(
+        keyset_id,
+        large_amount.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create pre_mint1");
+
+    let pre_mint2 = PreMintSecrets::random(
+        keyset_id,
+        large_amount.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create pre_mint2");
+
+    let mut combined_pre_mint = PreMintSecrets::random(
+        keyset_id,
+        1.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create combined_pre_mint");
+
+    combined_pre_mint.combine(pre_mint1);
+    combined_pre_mint.combine(pre_mint2);
+
+    let swap_request = SwapRequest::new(proofs, combined_pre_mint.blinded_messages());
+
+    // Should fail with overflow/amount error
+    match mint.process_swap_request(swap_request).await {
+        Err(cdk::Error::NUT03(cdk::nuts::nut03::Error::Amount(_)))
+        | Err(cdk::Error::AmountOverflow)
+        | Err(cdk::Error::AmountError(_))
+        | Err(cdk::Error::TransactionUnbalanced(_, _, _)) => {
+            // Any of these errors are acceptable for overflow
+        }
+        Err(err) => panic!("Unexpected error type: {:?}", err),
+        Ok(_) => panic!("Overflow swap should not succeed"),
+    }
+}
+
+/// Tests swap state transitions through pubsub notifications:
+/// 1. Subscribe to proof state changes
+/// 2. Execute swap
+/// 3. Verify Pending then Spent state transitions are received
+/// Validates NUT-17 notification behavior.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_state_transition_notifications() {
+    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
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    let preswap = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
+
+    // Subscribe to proof state changes
+    let proof_ys: Vec<String> = proofs.iter().map(|p| p.y().unwrap().to_string()).collect();
+
+    let mut listener = mint
+        .pubsub_manager()
+        .subscribe(cdk::subscription::Params {
+            kind: cdk::nuts::nut17::Kind::ProofState,
+            filters: proof_ys.clone(),
+            id: Arc::new("test_swap_notifications".into()),
+        })
+        .expect("Should subscribe successfully");
+
+    // Execute swap
+    mint.process_swap_request(swap_request)
+        .await
+        .expect("Swap should succeed");
+
+    // Give pubsub time to deliver messages
+    tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+
+    // Collect all state transition notifications
+    let mut state_transitions: HashMap<String, Vec<State>> = HashMap::new();
+
+    while let Some(msg) = listener.try_recv() {
+        match msg.into_inner() {
+            cashu::NotificationPayload::ProofState(cashu::ProofState { y, state, .. }) => {
+                state_transitions
+                    .entry(y.to_string())
+                    .or_insert_with(Vec::new)
+                    .push(state);
+            }
+            _ => panic!("Unexpected notification type"),
+        }
+    }
+
+    // Verify each proof went through Pending -> Spent transition
+    for y in proof_ys {
+        let transitions = state_transitions
+            .get(&y)
+            .expect("Should have transitions for proof");
+
+        assert_eq!(
+            transitions,
+            &vec![State::Pending, State::Spent],
+            "Proof should transition from Pending to Spent"
+        );
+    }
+}
+
+/// Tests that swap fails gracefully when proof states cannot be updated:
+/// This would test the rollback path where proofs are added but state update fails.
+/// In the current implementation, this should trigger rollback of both proofs and blinded messages.
+#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
+async fn test_swap_proof_state_consistency() {
+    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
+    fund_wallet(wallet.clone(), 100, None)
+        .await
+        .expect("Failed to fund wallet");
+
+    let proofs = wallet
+        .get_unspent_proofs()
+        .await
+        .expect("Could not get proofs");
+
+    let keyset_id = get_keyset_id(&mint).await;
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    // Execute successful swap
+    let preswap = PreMintSecrets::random(
+        keyset_id,
+        100.into(),
+        &SplitTarget::default(),
+        &fee_and_amounts,
+    )
+    .expect("Failed to create preswap");
+
+    let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages());
+
+    mint.process_swap_request(swap_request)
+        .await
+        .expect("Swap should succeed");
+
+    // Verify all proofs have consistent state (Spent)
+    let proof_ys: Vec<_> = proofs.iter().map(|p| p.y().unwrap()).collect();
+
+    let states = mint
+        .localstore()
+        .get_proofs_states(&proof_ys)
+        .await
+        .expect("Failed to get proof states");
+
+    // All states should be Some(Spent) - none should be None or Pending
+    for (i, state) in states.iter().enumerate() {
+        match state {
+            Some(State::Spent) => {
+                // Expected state
+            }
+            Some(other_state) => {
+                panic!("Proof {} in unexpected state: {:?}", i, other_state)
+            }
+            None => {
+                panic!("Proof {} has no state (should be Spent)", i)
+            }
+        }
+    }
+}

+ 67 - 44
crates/cdk-ldk-node/src/web/handlers/dashboard.rs

@@ -140,8 +140,8 @@ pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, St
 
         // Balance Summary as metric cards
         div class="card" {
-            h2 { "Balance Summary" }
-            div class="metrics-container" {
+            h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Balance Summary" }
+            div class="metrics-container" style="margin-top: 1.5rem;" {
                 div class="metric-card" {
                     div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) }
                     div class="metric-label" { "Lightning Balance" }
@@ -209,8 +209,8 @@ pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, St
             // Right side - Connections metrics
             aside class="node-metrics" {
                 div class="card" {
-                    h3 { "Connections" }
-                    div class="metrics-container" {
+                    h3 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Connections" }
+                    div class="metrics-container" style="margin-top: 1.5rem;" {
                         div class="metric-card" {
                             div class="metric-value" { (format!("{}/{}", num_connected_peers, num_peers)) }
                             div class="metric-label" { "Connected Peers" }
@@ -224,51 +224,74 @@ pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, St
             }
         }
 
-        // Lightning Network Activity as metric cards
+        // Activity Sections - Side by Side Layout
         div class="card" {
-            h2 { "Lightning Network Activity" }
-            div class="metrics-container" {
-                div class="metric-card" {
-                    div class="metric-value" { (format_sats_as_btc(metrics.lightning_inflow_24h)) }
-                    div class="metric-label" { "24h LN Inflow" }
-                }
-                div class="metric-card" {
-                    div class="metric-value" { (format_sats_as_btc(metrics.lightning_outflow_24h)) }
-                    div class="metric-label" { "24h LN Outflow" }
-                }
-                div class="metric-card" {
-                    div class="metric-value" { (format_sats_as_btc(metrics.lightning_inflow_all_time)) }
-                    div class="metric-label" { "All-time LN Inflow" }
-                }
-                div class="metric-card" {
-                    div class="metric-value" { (format_sats_as_btc(metrics.lightning_outflow_all_time)) }
-                    div class="metric-label" { "All-time LN Outflow" }
-                }
-            }
-        }
+            h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; margin-bottom: 0;" { "Activity Overview" }
 
-        // On-chain Activity as metric cards
-        div class="card" {
-            h2 { "On-chain Activity" }
-            div class="metrics-container" {
-                div class="metric-card" {
-                    div class="metric-value" { (format_sats_as_btc(metrics.onchain_inflow_24h)) }
-                    div class="metric-label" { "24h On-chain Inflow" }
-                }
-                div class="metric-card" {
-                    div class="metric-value" { (format_sats_as_btc(metrics.onchain_outflow_24h)) }
-                    div class="metric-label" { "24h On-chain Outflow" }
-                }
-                div class="metric-card" {
-                    div class="metric-value" { (format_sats_as_btc(metrics.onchain_inflow_all_time)) }
-                    div class="metric-label" { "All-time On-chain Inflow" }
+            div class="activity-grid" {
+                // Lightning Network Activity
+                div class="activity-section" {
+                    div class="activity-header" {
+                        div class="activity-icon-box" {
+                            svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" {
+                                path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" {}
+                            }
+                        }
+                        h3 class="activity-title" { "Lightning Network Activity" }
+                    }
+
+                    div class="activity-metrics" {
+                        div class="activity-metric-card" {
+                            div class="activity-metric-label" { "24h Inflow" }
+                            div class="activity-metric-value" { (format_sats_as_btc(metrics.lightning_inflow_24h)) }
+                        }
+                        div class="activity-metric-card" {
+                            div class="activity-metric-label" { "24h Outflow" }
+                            div class="activity-metric-value" { (format_sats_as_btc(metrics.lightning_outflow_24h)) }
+                        }
+                        div class="activity-metric-card" {
+                            div class="activity-metric-label" { "All-time Inflow" }
+                            div class="activity-metric-value" { (format_sats_as_btc(metrics.lightning_inflow_all_time)) }
+                        }
+                        div class="activity-metric-card" {
+                            div class="activity-metric-label" { "All-time Outflow" }
+                            div class="activity-metric-value" { (format_sats_as_btc(metrics.lightning_outflow_all_time)) }
+                        }
+                    }
                 }
-                div class="metric-card" {
-                    div class="metric-value" { (format_sats_as_btc(metrics.onchain_outflow_all_time)) }
-                    div class="metric-label" { "All-time On-chain Outflow" }
+
+                // On-chain Activity
+                div class="activity-section" {
+                    div class="activity-header" {
+                        div class="activity-icon-box" {
+                            svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" {
+                                path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" {}
+                                path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" {}
+                            }
+                        }
+                        h3 class="activity-title" { "On-chain Activity" }
+                    }
+
+                    div class="activity-metrics" {
+                        div class="activity-metric-card" {
+                            div class="activity-metric-label" { "24h Inflow" }
+                            div class="activity-metric-value" { (format_sats_as_btc(metrics.onchain_inflow_24h)) }
+                        }
+                        div class="activity-metric-card" {
+                            div class="activity-metric-label" { "24h Outflow" }
+                            div class="activity-metric-value" { (format_sats_as_btc(metrics.onchain_outflow_24h)) }
+                        }
+                        div class="activity-metric-card" {
+                            div class="activity-metric-label" { "All-time Inflow" }
+                            div class="activity-metric-value" { (format_sats_as_btc(metrics.onchain_inflow_all_time)) }
+                        }
+                        div class="activity-metric-card" {
+                            div class="activity-metric-label" { "All-time Outflow" }
+                            div class="activity-metric-value" { (format_sats_as_btc(metrics.onchain_outflow_all_time)) }
+                        }
+                    }
                 }
             }
-
         }
     };
 

+ 113 - 80
crates/cdk-ldk-node/src/web/handlers/invoices.rs

@@ -10,7 +10,7 @@ use serde::Deserialize;
 use crate::web::handlers::utils::{deserialize_optional_f64, deserialize_optional_u32};
 use crate::web::handlers::AppState;
 use crate::web::templates::{
-    error_message, form_card, format_sats_as_btc, info_card, is_node_running, layout_with_status,
+    error_message, format_sats_as_btc, invoice_display_card, is_node_running, layout_with_status,
     success_message,
 };
 
@@ -34,54 +34,101 @@ pub struct CreateBolt12Form {
 pub async fn invoices_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
     let content = html! {
         h2 style="text-align: center; margin-bottom: 3rem;" { "Invoices" }
-        div class="grid" {
-            (form_card(
-                "Create BOLT11 Invoice",
-                html! {
-                    form method="post" action="/invoices/bolt11" {
-                        div class="form-group" {
-                            label for="amount_btc" { "Amount" }
-                            input type="number" id="amount_btc" name="amount_btc" required placeholder="₿0" step="0.00000001" {}
-                        }
-                        div class="form-group" {
-                            label for="description" { "Description (optional)" }
-                            input type="text" id="description" name="description" placeholder="Payment for..." {}
-                        }
-                        div class="form-group" {
-                            label for="expiry_seconds" { "Expiry (seconds, optional)" }
-                            input type="number" id="expiry_seconds" name="expiry_seconds" placeholder="3600" {}
-                        }
-                        button type="submit" { "Create BOLT11 Invoice" }
+
+        div class="card" {
+            // Tab navigation
+            div class="payment-tabs" style="display: flex; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid hsl(var(--border)); padding-bottom: 0;" {
+                button type="button" class="payment-tab active" onclick="switchInvoiceTab('bolt11')" data-tab="bolt11" {
+                    "BOLT11 Invoice"
+                }
+                button type="button" class="payment-tab" onclick="switchInvoiceTab('bolt12')" data-tab="bolt12" {
+                    "BOLT12 Offer"
+                }
+            }
+
+            // BOLT11 tab content
+            div id="bolt11-content" class="tab-content active" {
+                form method="post" action="/invoices/bolt11" {
+                    div class="form-group" {
+                        label for="amount_btc_bolt11" { "Amount" }
+                        input type="number" id="amount_btc_bolt11" name="amount_btc" required placeholder="₿0" step="0.00000001" {}
+                    }
+                    div class="form-group" {
+                        label for="description_bolt11" { "Description (optional)" }
+                        input type="text" id="description_bolt11" name="description" placeholder="Payment for..." {}
+                    }
+                    div class="form-group" {
+                        label for="expiry_seconds_bolt11" { "Expiry (seconds, optional)" }
+                        input type="number" id="expiry_seconds_bolt11" name="expiry_seconds" placeholder="3600" {}
+                    }
+                    div class="form-actions" {
+                        a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
+                        button type="submit" class="button-primary" { "Create BOLT11 Invoice" }
                     }
                 }
-            ))
+            }
 
-            (form_card(
-                "Create BOLT12 Offer",
-                html! {
-                    form method="post" action="/invoices/bolt12" {
-                        div class="form-group" {
-                            label for="amount_btc" { "Amount (optional for variable amount)" }
-                            input type="number" id="amount_btc" name="amount_btc" placeholder="₿0" step="0.00000001" {}
-                        }
-                        div class="form-group" {
-                            label for="description" { "Description (optional)" }
-                            input type="text" id="description" name="description" placeholder="Payment for..." {}
+            // BOLT12 tab content
+            div id="bolt12-content" class="tab-content" {
+                form method="post" action="/invoices/bolt12" {
+                    div class="form-group" {
+                        label for="amount_btc_bolt12" { "Amount (optional for variable amount)" }
+                        input type="number" id="amount_btc_bolt12" name="amount_btc" placeholder="₿0" step="0.00000001" {}
+                        p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
+                            "Leave empty for variable amount offers, specify amount for fixed offers"
                         }
-                        div class="form-group" {
-                            label for="expiry_seconds" { "Expiry (seconds, optional)" }
-                            input type="number" id="expiry_seconds" name="expiry_seconds" placeholder="3600" {}
-                        }
-                        button type="submit" { "Create BOLT12 Offer" }
+                    }
+                    div class="form-group" {
+                        label for="description_bolt12" { "Description (optional)" }
+                        input type="text" id="description_bolt12" name="description" placeholder="Payment for..." {}
+                    }
+                    div class="form-group" {
+                        label for="expiry_seconds_bolt12" { "Expiry (seconds, optional)" }
+                        input type="number" id="expiry_seconds_bolt12" name="expiry_seconds" placeholder="3600" {}
+                    }
+                    div class="form-actions" {
+                        a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
+                        button type="submit" class="button-primary" { "Create BOLT12 Offer" }
                     }
                 }
-            ))
+            }
+        }
+
+        // Tab switching script
+        script type="text/javascript" {
+            (maud::PreEscaped(r#"
+            function switchInvoiceTab(tabName) {
+                console.log('Switching to invoice tab:', tabName);
+
+                // Hide all tab contents
+                const contents = document.querySelectorAll('.tab-content');
+                contents.forEach(content => content.classList.remove('active'));
+
+                // Remove active class from all tabs
+                const tabs = document.querySelectorAll('.payment-tab');
+                tabs.forEach(tab => tab.classList.remove('active'));
+
+                // Show selected tab content
+                const tabContent = document.getElementById(tabName + '-content');
+                if (tabContent) {
+                    tabContent.classList.add('active');
+                    console.log('Activated invoice tab content:', tabName);
+                }
+
+                // Add active class to selected tab
+                const tabButton = document.querySelector('[data-tab="' + tabName + '"]');
+                if (tabButton) {
+                    tabButton.classList.add('active');
+                    console.log('Activated invoice tab button:', tabName);
+                }
+            }
+            "#))
         }
     };
 
     let is_running = is_node_running(&state.node.inner);
     Ok(Html(
-        layout_with_status("Create Invoices", content, is_running).into_string(),
+        layout_with_status("s", content, is_running).into_string(),
     ))
 }
 
@@ -126,7 +173,7 @@ pub async fn post_create_bolt11(
                     .status(StatusCode::BAD_REQUEST)
                     .header("content-type", "text/html")
                     .body(Body::from(
-                        layout_with_status("Create Invoice Error", content, true).into_string(),
+                        layout_with_status(" Error", content, true).into_string(),
                     ))
                     .unwrap());
             }
@@ -161,32 +208,25 @@ pub async fn post_create_bolt11(
                 description_text.clone()
             };
 
+            let invoice_details = vec![
+                ("Payment Hash", invoice.payment_hash().to_string()),
+                ("Amount", format_sats_as_btc(form.amount_btc)),
+                ("Description", description_display),
+                (
+                    "Expires At",
+                    format!("{}", current_time + expiry_seconds as u64),
+                ),
+            ];
+
             html! {
                 (success_message("BOLT11 Invoice created successfully!"))
-                (info_card(
-                    "Invoice Details",
-                    vec![
-                        ("Payment Hash", invoice.payment_hash().to_string()),
-                        ("Amount", format_sats_as_btc(form.amount_btc)),
-                        ("Description", description_display),
-                        ("Expires At", format!("{}", current_time + expiry_seconds as u64)),
-                    ]
-                ))
-                div class="card" {
-                    h3 { "Invoice (copy this to share)" }
-                    textarea readonly style="width: 100%; height: 150px; font-family: monospace; font-size: 0.8rem;" {
-                        (invoice.to_string())
-                    }
-                }
-                div class="card" {
-                    a href="/invoices" { button { "← Create Another Invoice" } }
-                }
+                (invoice_display_card(&invoice.to_string(), &format_sats_as_btc(form.amount_btc), invoice_details, "/invoices"))
             }
         }
         Err(e) => {
             tracing::error!("Web interface: Failed to create BOLT11 invoice: {}", e);
             html! {
-                (error_message(&format!("Failed to create invoice: {e}")))
+                (error_message(&format!("Failed to : {e}")))
                 div class="card" {
                     a href="/invoices" { button { "← Try Again" } }
                 }
@@ -210,15 +250,15 @@ pub async fn post_create_bolt12(
     let description_text = form.description.unwrap_or_else(|| "".to_string());
 
     tracing::info!(
-        "Web interface: Creating BOLT12 offer for amount={:?} btc, description={:?}, expiry={}s",
+        "Web interface: Creating BOLT12 offer for amount={:?} sats, description={:?}, expiry={}s",
         form.amount_btc,
         description_text,
         expiry_seconds
     );
 
     let offer_result = if let Some(amount_btc) = form.amount_btc {
-        // Convert Bitcoin to millisatoshis (1 BTC = 100,000,000,000 msats)
-        let amount_msats = (amount_btc * 100_000_000_000.0) as u64;
+        // Convert satoshis to millisatoshis (1 sat = 1,000 msats)
+        let amount_msats = (amount_btc * 1_000.0) as u64;
         state.node.inner.bolt12_payment().receive(
             amount_msats,
             &description_text,
@@ -246,7 +286,7 @@ pub async fn post_create_bolt12(
 
             let amount_display = form
                 .amount_btc
-                .map(|a| format_sats_as_btc((a * 100_000_000.0) as u64))
+                .map(|a| format_sats_as_btc(a as u64))
                 .unwrap_or_else(|| "Variable amount".to_string());
 
             let description_display = if description_text.is_empty() {
@@ -255,26 +295,19 @@ pub async fn post_create_bolt12(
                 description_text
             };
 
+            let offer_details = vec![
+                ("Offer ID", offer.id().to_string()),
+                ("Amount", amount_display.clone()),
+                ("Description", description_display),
+                (
+                    "Expires At",
+                    format!("{}", current_time + expiry_seconds as u64),
+                ),
+            ];
+
             html! {
                 (success_message("BOLT12 Offer created successfully!"))
-                (info_card(
-                    "Offer Details",
-                    vec![
-                        ("Offer ID", offer.id().to_string()),
-                        ("Amount", amount_display),
-                        ("Description", description_display),
-                        ("Expires At", format!("{}", current_time + expiry_seconds as u64)),
-                    ]
-                ))
-                div class="card" {
-                    h3 { "Offer (copy this to share)" }
-                    textarea readonly style="width: 100%; height: 150px; font-family: monospace; font-size: 0.8rem;" {
-                        (offer.to_string())
-                    }
-                }
-                div class="card" {
-                    a href="/invoices" { button { "← Create Another Offer" } }
-                }
+                (invoice_display_card(&offer.to_string(), &amount_display, offer_details, "/invoices"))
             }
         }
         Err(e) => {

+ 69 - 86
crates/cdk-ldk-node/src/web/handlers/lightning.rs

@@ -26,43 +26,33 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
         html! {
             h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" }
 
-            // Quick Actions section - individual cards
-            div class="card" style="margin-bottom: 2rem;" {
-                h2 { "Quick Actions" }
-                div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
-                    // Open Channel Card
-                    div class="quick-action-card" {
-                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Open Channel" }
-                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Create a new Lightning Network channel to connect with another node." }
-                        a href="/channels/open" style="text-decoration: none;" {
-                            button class="button-outline" { "Open Channel" }
-                        }
+            // Inactive channels warning (only show if > 0)
+            @if num_inactive_channels > 0 {
+                div class="card" style="background-color: #fef3c7; border: 1px solid #f59e0b; margin-bottom: 2rem;" {
+                    h3 style="color: #92400e; margin-bottom: 0.5rem;" { "⚠️ Inactive Channels Detected" }
+                    p style="color: #78350f; margin: 0;" {
+                        "You have " (num_inactive_channels) " inactive channel(s). This may indicate a connectivity issue that requires attention."
                     }
+                }
+            }
 
-                    // Create Invoice Card
-                    div class="quick-action-card" {
-                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Create Invoice" }
-                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a Lightning invoice to receive payments from other users or services." }
-                        a href="/invoices" style="text-decoration: none;" {
-                            button class="button-outline" { "Create Invoice" }
+            // Balance Information with action buttons in header
+            div class="card" {
+                div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
+                    h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "Balance Information" }
+                    div style="display: flex; gap: 0.5rem;" {
+                        a href="/payments/send" style="text-decoration: none;" {
+                            button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
                         }
-                    }
-
-                    // Make Payment Card
-                    div class="quick-action-card" {
-                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Make Lightning Payment" }
-                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Lightning payments to other users using invoices. BOLT 11 & 12 supported." }
                         a href="/invoices" style="text-decoration: none;" {
-                            button class="button-outline" { "Make Payment" }
+                            button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
+                        }
+                        a href="/channels/open" style="text-decoration: none;" {
+                            button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Open Channel" }
                         }
                     }
                 }
-            }
-
-            // Balance Information as metric cards
-            div class="card" {
-                h2 { "Balance Information" }
-                div class="metrics-container" {
+                div class="metrics-container" style="margin-top: 1.5rem;" {
                     div class="metric-card" {
                         div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) }
                         div class="metric-label" { "Lightning Balance" }
@@ -75,9 +65,11 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
                         div class="metric-value" { (format!("{}", num_active_channels)) }
                         div class="metric-label" { "Active Channels" }
                     }
-                    div class="metric-card" {
-                        div class="metric-value" { (format!("{}", num_inactive_channels)) }
-                        div class="metric-label" { "Inactive Channels" }
+                    @if num_inactive_channels > 0 {
+                        div class="metric-card" {
+                            div class="metric-value" style="color: #f59e0b;" { (format!("{}", num_inactive_channels)) }
+                            div class="metric-label" { "Inactive Channels" }
+                        }
                     }
                 }
             }
@@ -90,43 +82,33 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
         html! {
             h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" }
 
-            // Quick Actions section - individual cards
-            div class="card" style="margin-bottom: 2rem;" {
-                h2 { "Quick Actions" }
-                div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
-                    // Open Channel Card
-                    div class="quick-action-card" {
-                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Open Channel" }
-                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Create a new Lightning channel by connecting with another node." }
-                        a href="/channels/open" style="text-decoration: none;" {
-                            button class="button-outline" { "Open Channel" }
-                        }
+            // Inactive channels warning (only show if > 0)
+            @if num_inactive_channels > 0 {
+                div class="card" style="background-color: #fef3c7; border: 1px solid #f59e0b; margin-bottom: 2rem;" {
+                    h3 style="color: #92400e; margin-bottom: 0.5rem;" { "⚠️ Inactive Channels Detected" }
+                    p style="color: #78350f; margin: 0;" {
+                        "You have " (num_inactive_channels) " inactive channel(s). This may indicate a connectivity issue that requires attention."
                     }
+                }
+            }
 
-                    // Create Invoice Card
-                    div class="quick-action-card" {
-                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Create Invoice" }
-                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a Lightning invoice to receive payments." }
+            // Balance Information with action buttons in header
+            div class="card" {
+                div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
+                    h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "Balance Information" }
+                    div style="display: flex; gap: 0.5rem;" {
+                        a href="/payments/send" style="text-decoration: none;" {
+                            button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
+                        }
                         a href="/invoices" style="text-decoration: none;" {
-                            button class="button-outline" { "Create Invoice" }
+                            button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
                         }
-                    }
-
-                    // Make Payment Card
-                    div class="quick-action-card" {
-                        h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Make Lightning Payment" }
-                        p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Lightning payments to other users using invoices." }
-                        a href="/payments/send" style="text-decoration: none;" {
-                            button class="button-outline" { "Make Payment" }
+                        a href="/channels/open" style="text-decoration: none;" {
+                            button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Open Channel" }
                         }
                     }
                 }
-            }
-
-            // Balance Information as metric cards
-            div class="card" {
-                h2 { "Balance Information" }
-                div class="metrics-container" {
+                div class="metrics-container" style="margin-top: 1.5rem;" {
                     div class="metric-card" {
                         div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) }
                         div class="metric-label" { "Lightning Balance" }
@@ -139,47 +121,48 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
                         div class="metric-value" { (format!("{}", num_active_channels)) }
                         div class="metric-label" { "Active Channels" }
                     }
-                    div class="metric-card" {
-                        div class="metric-value" { (format!("{}", num_inactive_channels)) }
-                        div class="metric-label" { "Inactive Channels" }
+                    @if num_inactive_channels > 0 {
+                        div class="metric-card" {
+                            div class="metric-value" style="color: #f59e0b;" { (format!("{}", num_inactive_channels)) }
+                            div class="metric-label" { "Inactive Channels" }
+                        }
                     }
                 }
             }
 
             // Channel Details header (outside card)
-            h2 class="section-header" { "Channel Details" }
+            h2 class="section-header" style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5;" { "Channel Details" }
 
-            // Channels list
-            @for (index, channel) in channels.iter().enumerate() {
+                    // Channels list
+                    @for (index, channel) in channels.iter().enumerate() {
                 @let node_id = channel.counterparty_node_id.to_string();
                 @let channel_number = index + 1;
 
                 div class="channel-box" {
-                    // Channel number as prominent header
-                    div class="channel-alias" { (format!("Channel {}", channel_number)) }
+                    // Channel header with number on left and status badge on right
+                    div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 1.5rem;" {
+                        div class="channel-alias" style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { (format!("Channel {}", channel_number)) }
+                        @if channel.is_usable {
+                            span class="status-badge status-active" { "Active" }
+                        } @else {
+                            span class="status-badge status-inactive" { "Inactive" }
+                        }
+                    }
 
-                    // Channel details in left-aligned format
+                    // Channel details - ordered by label length
                     div class="channel-details" {
                         div class="detail-row" {
+                            span class="detail-label" { "Node ID" }
+                            span class="detail-value" { (node_id) }
+                        }
+                        div class="detail-row" {
                             span class="detail-label" { "Channel ID" }
-                            span class="detail-value-amount" { (channel.channel_id.to_string()) }
+                            span class="detail-value" { (channel.channel_id.to_string()) }
                         }
                         @if let Some(short_channel_id) = channel.short_channel_id {
                             div class="detail-row" {
                                 span class="detail-label" { "Short Channel ID" }
-                                span class="detail-value-amount" { (short_channel_id.to_string()) }
-                            }
-                        }
-                        div class="detail-row" {
-                            span class="detail-label" { "Node ID" }
-                            span class="detail-value-amount" { (node_id) }
-                        }
-                        div class="detail-row" {
-                            span class="detail-label" { "Status" }
-                            @if channel.is_usable {
-                                span class="status-badge status-active" { "Active" }
-                            } @else {
-                                span class="status-badge status-inactive" { "Inactive" }
+                                span class="detail-value" { (short_channel_id.to_string()) }
                             }
                         }
                     }

+ 43 - 43
crates/cdk-ldk-node/src/web/handlers/onchain.rs

@@ -33,33 +33,6 @@ pub struct ConfirmOnchainForm {
     confirmed: Option<String>,
 }
 
-fn quick_actions_section() -> maud::Markup {
-    html! {
-        div class="card" style="margin-bottom: 2rem;" {
-            h2 { "Quick Actions" }
-            div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
-                // Receive Bitcoin Card
-                div class="quick-action-card" {
-                    h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
-                    p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
-                    a href="/onchain?action=receive" style="text-decoration: none;" {
-                        button class="button-outline" { "Receive Bitcoin" }
-                    }
-                }
-
-                // Send Bitcoin Card
-                div class="quick-action-card" {
-                    h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
-                    p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
-                    a href="/onchain?action=send" style="text-decoration: none;" {
-                        button class="button-outline" { "Send Bitcoin" }
-                    }
-                }
-            }
-        }
-    }
-}
-
 pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
     let address_result = state.node.inner.onchain_payment().new_address();
 
@@ -67,8 +40,8 @@ pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<Strin
         Ok(address) => {
             html! {
                 div class="card" {
-                    h2 { "Bitcoin Address" }
-                    div class="address-display" {
+                    h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Bitcoin Address" }
+                    div class="address-display" style="margin-top: 1.5rem;" {
                         div class="address-container" {
                             span class="address-text" { (address.to_string()) }
                         }
@@ -113,13 +86,20 @@ pub async fn onchain_page(
     let mut content = html! {
         h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
 
-        // Quick Actions section - only show on overview
-        (quick_actions_section())
-
-        // On-chain Balance as metric cards
+        // On-chain Balance with action buttons in header
         div class="card" {
-            h2 { "On-chain Balance" }
-            div class="metrics-container" {
+            div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
+                h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "On-chain Balance" }
+                div style="display: flex; gap: 0.5rem;" {
+                    a href="/onchain?action=send" style="text-decoration: none;" {
+                        button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
+                    }
+                    a href="/onchain?action=receive" style="text-decoration: none;" {
+                        button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
+                    }
+                }
+            }
+            div class="metrics-container" style="margin-top: 1.5rem;" {
                 div class="metric-card" {
                     div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
                     div class="metric-label" { "Total Balance" }
@@ -162,10 +142,20 @@ pub async fn onchain_page(
                     }
                 ))
 
-                // On-chain Balance as metric cards
+                // On-chain Balance with action buttons in header
                 div class="card" {
-                    h2 { "On-chain Balance" }
-                    div class="metrics-container" {
+                    div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
+                        h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "On-chain Balance" }
+                        div style="display: flex; gap: 0.5rem;" {
+                            a href="/onchain?action=send" style="text-decoration: none;" {
+                                button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
+                            }
+                            a href="/onchain?action=receive" style="text-decoration: none;" {
+                                button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
+                            }
+                        }
+                    }
+                    div class="metrics-container" style="margin-top: 1.5rem;" {
                         div class="metric-card" {
                             div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
                             div class="metric-label" { "Total Balance" }
@@ -196,10 +186,20 @@ pub async fn onchain_page(
                     }
                 ))
 
-                // On-chain Balance as metric cards
+                // On-chain Balance with action buttons in header
                 div class="card" {
-                    h2 { "On-chain Balance" }
-                    div class="metrics-container" {
+                    div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
+                        h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "On-chain Balance" }
+                        div style="display: flex; gap: 0.5rem;" {
+                            a href="/onchain?action=send" style="text-decoration: none;" {
+                                button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
+                            }
+                            a href="/onchain?action=receive" style="text-decoration: none;" {
+                                button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
+                            }
+                        }
+                    }
+                    div class="metrics-container" style="margin-top: 1.5rem;" {
                         div class="metric-card" {
                             div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
                             div class="metric-label" { "Total Balance" }
@@ -324,8 +324,8 @@ pub async fn onchain_confirm_page(
 
         // Transaction Details Card
         div class="card" {
-            h2 { "Transaction Details" }
-            div class="transaction-details" {
+            h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Transaction Details" }
+            div class="transaction-details" style="margin-top: 1.5rem;" {
                 div class="detail-row" {
                     span class="detail-label" { "Recipient Address:" }
                     span class="detail-value" { (form.address.clone()) }

+ 100 - 38
crates/cdk-ldk-node/src/web/handlers/payments.rs

@@ -15,7 +15,7 @@ use serde::Deserialize;
 use crate::web::handlers::utils::{deserialize_optional_u64, get_paginated_payments_streaming};
 use crate::web::handlers::AppState;
 use crate::web::templates::{
-    error_message, form_card, format_msats_as_btc, format_sats_as_btc, info_card, is_node_running,
+    error_message, format_msats_as_btc, format_sats_as_btc, info_card, is_node_running,
     layout_with_status, payment_list_item, success_message,
 };
 
@@ -86,7 +86,7 @@ pub async fn payments_page(
         div class="card" {
             div class="payment-list-header" {
                 div {
-                    h2 { "Payment History" }
+                    h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Payment History" }
                     @if total_count > 0 {
                         p style="margin: 0.25rem 0 0 0; color: #666; font-size: 0.9rem;" {
                             "Showing " (start_index + 1) " to " (end_index) " of " (total_count) " payments"
@@ -116,14 +116,6 @@ pub async fn payments_page(
                         PaymentDirection::Outbound => "Outbound",
                     };
 
-                    @let status_str = match payment.status {
-                        PaymentStatus::Pending => "Pending",
-                        PaymentStatus::Succeeded => "Succeeded",
-                        PaymentStatus::Failed => "Failed",
-                    };
-
-                    @let amount_str = payment.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string());
-
                     @let (payment_hash, description, payment_type, preimage) = match &payment.kind {
                         PaymentKind::Bolt11 { hash, preimage, .. } => {
                             (Some(hash.to_string()), None::<String>, "BOLT11", preimage.map(|p| p.to_string()))
@@ -147,6 +139,27 @@ pub async fn payments_page(
                         },
                     };
 
+                    @let status_str = {
+                        // Helper function to determine invoice status
+                        fn get_invoice_status(status: PaymentStatus, direction: PaymentDirection, payment_type: &str) -> &'static str {
+                            match status {
+                                PaymentStatus::Succeeded => "Succeeded",
+                                PaymentStatus::Failed => "Failed",
+                                PaymentStatus::Pending => {
+                                    // For inbound BOLT11 payments, show "Unpaid" instead of "Pending"
+                                    if direction == PaymentDirection::Inbound && payment_type == "BOLT11" {
+                                        "Unpaid"
+                                    } else {
+                                        "Pending"
+                                    }
+                                }
+                            }
+                        }
+                        get_invoice_status(payment.status, payment.direction, payment_type)
+                    };
+
+                    @let amount_str = payment.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string());
+
                     (payment_list_item(
                         &payment.id.to_string(),
                         direction_str,
@@ -245,42 +258,91 @@ pub async fn payments_page(
 pub async fn send_payments_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
     let content = html! {
         h2 style="text-align: center; margin-bottom: 3rem;" { "Send Payment" }
-        div class="grid" {
-            (form_card(
-                "Pay BOLT11 Invoice",
-                html! {
-                    form method="post" action="/payments/bolt11" {
-                        div class="form-group" {
-                            label for="invoice" { "BOLT11 Invoice" }
-                            textarea id="invoice" name="invoice" required placeholder="lnbc..." style="height: 120px;" {}
-                        }
-                        div class="form-group" {
-                            label for="amount_btc" { "Amount Override (optional)" }
-                            input type="number" id="amount_btc" name="amount_btc" placeholder="Leave empty to use invoice amount" step="1" {}
+
+        div class="card" {
+            // Tab navigation
+            div class="payment-tabs" style="display: flex; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid hsl(var(--border)); padding-bottom: 0;" {
+                button type="button" class="payment-tab active" onclick="switchTab('bolt11')" data-tab="bolt11" {
+                    "BOLT11 Invoice"
+                }
+                button type="button" class="payment-tab" onclick="switchTab('bolt12')" data-tab="bolt12" {
+                    "BOLT12 Offer"
+                }
+            }
+
+            // BOLT11 tab content
+            div id="bolt11-content" class="tab-content active" {
+                form method="post" action="/payments/bolt11" {
+                    div class="form-group" {
+                        label for="invoice" { "BOLT11 Invoice" }
+                        textarea id="invoice" name="invoice" required placeholder="lnbc..." rows="4" {}
+                    }
+                    div class="form-group" {
+                        label for="amount_btc_bolt11" { "Amount Override (optional)" }
+                        input type="number" id="amount_btc_bolt11" name="amount_btc" placeholder="Leave empty to use invoice amount" step="1" {}
+                        p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
+                            "Only specify an amount if you want to override the invoice amount"
                         }
-                        button type="submit" { "Pay BOLT11 Invoice" }
+                    }
+                    div class="form-actions" {
+                        a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
+                        button type="submit" class="button-primary" { "Pay Invoice" }
                     }
                 }
-            ))
-
-            (form_card(
-                "Pay BOLT12 Offer",
-                html! {
-                    form method="post" action="/payments/bolt12" {
-                        div class="form-group" {
-                            label for="offer" { "BOLT12 Offer" }
-                            textarea id="offer" name="offer" required placeholder="lno..." style="height: 120px;" {}
-                        }
-                        div class="form-group" {
-                            label for="amount_btc" { "Amount (required for variable amount offers)" }
-                            input type="number" id="amount_btc" name="amount_btc" placeholder="Required for variable amount offers, ignored for fixed amount offers" step="1" {}
+            }
+
+            // BOLT12 tab content
+            div id="bolt12-content" class="tab-content" {
+                form method="post" action="/payments/bolt12" {
+                    div class="form-group" {
+                        label for="offer" { "BOLT12 Offer" }
+                        textarea id="offer" name="offer" required placeholder="lno..." rows="4" {}
+                    }
+                    div class="form-group" {
+                        label for="amount_btc_bolt12" { "Amount" }
+                        input type="number" id="amount_btc_bolt12" name="amount_btc" placeholder="Amount in satoshis" step="1" {}
+                        p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
+                            "Required for variable amount offers, ignored for fixed amount offers"
                         }
-                        button type="submit" { "Pay BOLT12 Offer" }
+                    }
+                    div class="form-actions" {
+                        a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
+                        button type="submit" class="button-primary" { "Pay Offer" }
                     }
                 }
-            ))
+            }
         }
 
+        // Tab switching script
+        script type="text/javascript" {
+            (maud::PreEscaped(r#"
+            function switchTab(tabName) {
+                console.log('Switching to tab:', tabName);
+
+                // Hide all tab contents
+                const contents = document.querySelectorAll('.tab-content');
+                contents.forEach(content => content.classList.remove('active'));
+
+                // Remove active class from all tabs
+                const tabs = document.querySelectorAll('.payment-tab');
+                tabs.forEach(tab => tab.classList.remove('active'));
+
+                // Show selected tab content
+                const tabContent = document.getElementById(tabName + '-content');
+                if (tabContent) {
+                    tabContent.classList.add('active');
+                    console.log('Activated tab content:', tabName);
+                }
+
+                // Add active class to selected tab
+                const tabButton = document.querySelector('[data-tab="' + tabName + '"]');
+                if (tabButton) {
+                    tabButton.classList.add('active');
+                    console.log('Activated tab button:', tabName);
+                }
+            }
+            "#))
+        }
     };
 
     let is_running = is_node_running(&state.node.inner);

+ 58 - 7
crates/cdk-ldk-node/src/web/templates/components.rs

@@ -3,11 +3,13 @@ use maud::{html, Markup};
 pub fn info_card(title: &str, items: Vec<(&str, String)>) -> Markup {
     html! {
         div class="card" {
-            h2 { (title) }
-            @for (label, value) in items {
-                div class="info-item" {
-                    span class="info-label" { (label) ":" }
-                    span class="info-value" { (value) }
+            h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { (title) }
+            div style="margin-top: 1.5rem;" {
+                @for (label, value) in items {
+                    div class="info-item" {
+                        span class="info-label" { (label) ":" }
+                        span class="info-value" { (value) }
+                    }
                 }
             }
         }
@@ -17,8 +19,10 @@ pub fn info_card(title: &str, items: Vec<(&str, String)>) -> Markup {
 pub fn form_card(title: &str, form_content: Markup) -> Markup {
     html! {
         div class="card" {
-            h2 { (title) }
-            (form_content)
+            h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { (title) }
+            div style="margin-top: 1.5rem;" {
+                (form_content)
+            }
         }
     }
 }
@@ -34,3 +38,50 @@ pub fn error_message(message: &str) -> Markup {
         div class="error" { (message) }
     }
 }
+
+pub fn invoice_display_card(
+    invoice_text: &str,
+    amount: &str,
+    details: Vec<(&str, String)>,
+    back_url: &str,
+) -> Markup {
+    html! {
+        div class="card" {
+            div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 1.5rem;" {
+                h3 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "Invoice Details" }
+            }
+
+            // Amount highlight section at the top
+            div class="invoice-amount-section" {
+                div class="invoice-amount-label" { "Amount" }
+                div class="invoice-amount-value" { (amount) }
+            }
+
+            // Invoice display section - under the amount
+            div class="invoice-display-section" {
+                div class="invoice-label" { "Invoice" }
+                div class="invoice-display-container" {
+                    textarea readonly class="invoice-textarea" { (invoice_text) }
+                }
+            }
+
+            // Invoice details section - after the invoice with increased spacing
+            div class="invoice-details-section" style="margin-top: 2.5rem;" {
+                h4 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0 0 1rem 0;" { "Details" }
+                @for (label, value) in details {
+                    div class="info-item" {
+                        span class="info-label" { (label) ":" }
+                        span class="info-value" { (value) }
+                    }
+                }
+            }
+
+            // Back button at bottom left - no border lines
+            div style="margin-top: 2rem;" {
+                a href=(back_url) style="text-decoration: none;" {
+                    button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Back" }
+                }
+            }
+        }
+    }
+}

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 635 - 52
crates/cdk-ldk-node/src/web/templates/layout.rs


+ 1 - 0
crates/cdk-ldk-node/src/web/templates/payments.rs

@@ -18,6 +18,7 @@ pub fn payment_list_item(
         "Succeeded" => "status-active",
         "Failed" => "status-inactive",
         "Pending" => "status-pending",
+        "Unpaid" => "status-pending", // Use pending styling for unpaid
         _ => "status-badge",
     };
 

+ 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

+ 49 - 15
crates/cdk-lnbits/src/lib.rs

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

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

@@ -1183,11 +1183,6 @@ pub async fn run_mintd_with_shutdown(
 
     let mint = Arc::new(mint);
 
-    // 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
-    mint.check_pending_melt_quotes().await?;
-
     start_services_with_shutdown(
         mint.clone(),
         settings,

+ 1 - 0
crates/cdk-sql-common/Cargo.toml

@@ -29,3 +29,4 @@ serde.workspace = true
 serde_json.workspace = true
 lightning-invoice.workspace = true
 once_cell.workspace = true
+uuid.workspace = true

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

@@ -0,0 +1,24 @@
+-- Add operation and operation_id columns to proof table
+ALTER TABLE proof ADD COLUMN operation_kind TEXT;
+ALTER TABLE proof ADD COLUMN operation_id TEXT;
+
+-- Add operation and operation_id columns to blind_signature table
+ALTER TABLE blind_signature ADD COLUMN operation_kind TEXT;
+ALTER TABLE blind_signature ADD COLUMN operation_id TEXT;
+
+CREATE INDEX idx_proof_state_operation ON proof(state, operation_kind);
+CREATE INDEX idx_proof_operation_id ON proof(operation_kind, operation_id);
+CREATE INDEX idx_blind_sig_operation_id ON blind_signature(operation_kind, operation_id);
+
+-- Add saga_state table for persisting saga state
+CREATE TABLE IF NOT EXISTS saga_state (
+    operation_id TEXT PRIMARY KEY,
+    operation_kind TEXT NOT NULL,
+    state TEXT NOT NULL,
+    blinded_secrets TEXT NOT NULL,
+    input_ys TEXT NOT NULL,
+    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);

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

@@ -0,0 +1,24 @@
+-- Add operation and operation_id columns to proof table
+ALTER TABLE proof ADD COLUMN operation_kind TEXT;
+ALTER TABLE proof ADD COLUMN operation_id TEXT;
+
+-- Add operation and operation_id columns to blind_signature table
+ALTER TABLE blind_signature ADD COLUMN operation_kind TEXT;
+ALTER TABLE blind_signature ADD COLUMN operation_id TEXT;
+
+CREATE INDEX idx_proof_state_operation ON proof(state, operation_kind);
+CREATE INDEX idx_proof_operation_id ON proof(operation_kind, operation_id);
+CREATE INDEX idx_blind_sig_operation_id ON blind_signature(operation_kind, operation_id);
+
+-- Add saga_state table for persisting saga state
+CREATE TABLE IF NOT EXISTS saga_state (
+    operation_id TEXT PRIMARY KEY,
+    operation_kind TEXT NOT NULL,
+    state TEXT NOT NULL,
+    blinded_secrets TEXT NOT NULL,
+    input_ys TEXT NOT NULL,
+    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);

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

@@ -15,7 +15,7 @@ use std::sync::Arc;
 
 use async_trait::async_trait;
 use bitcoin::bip32::DerivationPath;
-use cdk_common::database::mint::validate_kvstore_params;
+use cdk_common::database::mint::{validate_kvstore_params, SagaDatabase, SagaTransaction};
 use cdk_common::database::{
     self, ConversionError, Error, MintDatabase, MintDbWriterFinalizer, MintKeyDatabaseTransaction,
     MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, MintQuotesTransaction,
@@ -23,12 +23,13 @@ use cdk_common::database::{
 };
 use cdk_common::mint::{
     self, IncomingPayment, Issuance, MeltPaymentRequest, MeltQuote, MintKeySetInfo, MintQuote,
+    Operation,
 };
 use cdk_common::nut00::ProofsMethods;
 use cdk_common::payment::PaymentIdentifier;
 use cdk_common::quote_id::QuoteId;
 use cdk_common::secret::Secret;
-use cdk_common::state::check_state_transition;
+use cdk_common::state::{check_melt_quote_state_transition, check_state_transition};
 use cdk_common::util::unix_time;
 use cdk_common::{
     Amount, BlindSignature, BlindSignatureDleq, BlindedMessage, CurrencyUnit, Id, MeltQuoteState,
@@ -138,6 +139,7 @@ where
         &mut self,
         proofs: Proofs,
         quote_id: Option<QuoteId>,
+        operation: &Operation,
     ) -> Result<(), Self::Err> {
         let current_time = unix_time();
 
@@ -165,9 +167,9 @@ where
             query(
                 r#"
                   INSERT INTO proof
-                  (y, amount, keyset_id, secret, c, witness, state, quote_id, created_time)
+                  (y, amount, keyset_id, secret, c, witness, state, quote_id, created_time, operation_kind, operation_id)
                   VALUES
-                  (:y, :amount, :keyset_id, :secret, :c, :witness, :state, :quote_id, :created_time)
+                  (:y, :amount, :keyset_id, :secret, :c, :witness, :state, :quote_id, :created_time, :operation_kind, :operation_id)
                   "#,
             )?
             .bind("y", proof.y()?.to_bytes().to_vec())
@@ -182,6 +184,8 @@ where
             .bind("state", "UNSPENT".to_string())
             .bind("quote_id", quote_id.clone().map(|q| q.to_string()))
             .bind("created_time", current_time as i64)
+            .bind("operation_kind", operation.kind())
+            .bind("operation_id", operation.id().to_string())
             .execute(&self.inner)
             .await?;
         }
@@ -574,6 +578,7 @@ where
         &mut self,
         quote_id: Option<&QuoteId>,
         blinded_messages: &[BlindedMessage],
+        operation: &Operation,
     ) -> Result<(), Self::Err> {
         let current_time = unix_time();
 
@@ -583,9 +588,9 @@ where
             match query(
                 r#"
                 INSERT INTO blind_signature
-                (blinded_message, amount, keyset_id, c, quote_id, created_time)
+                (blinded_message, amount, keyset_id, c, quote_id, created_time, operation_kind, operation_id)
                 VALUES
-                (:blinded_message, :amount, :keyset_id, NULL, :quote_id, :created_time)
+                (:blinded_message, :amount, :keyset_id, NULL, :quote_id, :created_time, :operation_kind, :operation_id)
                 "#,
             )?
             .bind(
@@ -596,6 +601,8 @@ where
             .bind("keyset_id", message.keyset_id.to_string())
             .bind("quote_id", quote_id.map(|q| q.to_string()))
             .bind("created_time", current_time as i64)
+            .bind("operation_kind", operation.kind())
+            .bind("operation_id", operation.id().to_string())
             .execute(&self.inner)
             .await
             {
@@ -1043,6 +1050,8 @@ VALUES (:quote_id, :amount, :timestamp);
         .transpose()?
         .ok_or(Error::QuoteNotFound)?;
 
+        check_melt_quote_state_transition(quote.state, state)?;
+
         let rec = if state == MeltQuoteState::Paid {
             let current_time = unix_time();
             query(r#"UPDATE melt_quote SET state = :state, paid_time = :paid_time, payment_preimage = :payment_preimage WHERE id = :id"#)?
@@ -2119,6 +2128,147 @@ where
 }
 
 #[async_trait]
+impl<RM> SagaTransaction<'_> for SQLTransaction<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    type Err = Error;
+
+    async fn get_saga(
+        &mut self,
+        operation_id: &uuid::Uuid,
+    ) -> Result<Option<mint::Saga>, Self::Err> {
+        Ok(query(
+            r#"
+            SELECT
+                operation_id,
+                operation_kind,
+                state,
+                blinded_secrets,
+                input_ys,
+                created_at,
+                updated_at
+            FROM
+                saga_state
+            WHERE
+                operation_id = :operation_id
+            FOR UPDATE
+            "#,
+        )?
+        .bind("operation_id", operation_id.to_string())
+        .fetch_one(&self.inner)
+        .await?
+        .map(sql_row_to_saga)
+        .transpose()?)
+    }
+
+    async fn add_saga(&mut self, saga: &mint::Saga) -> Result<(), Self::Err> {
+        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)))?;
+
+        let input_ys_json = serde_json::to_string(&saga.input_ys)
+            .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)
+            VALUES
+            (:operation_id, :operation_kind, :state, :blinded_secrets, :input_ys, :created_at, :updated_at)
+            "#,
+        )?
+        .bind("operation_id", saga.operation_id.to_string())
+        .bind("operation_kind", saga.operation_kind.to_string())
+        .bind("state", saga.state.state())
+        .bind("blinded_secrets", blinded_secrets_json)
+        .bind("input_ys", input_ys_json)
+        .bind("created_at", saga.created_at as i64)
+        .bind("updated_at", current_time as i64)
+        .execute(&self.inner)
+        .await?;
+
+        Ok(())
+    }
+
+    async fn update_saga(
+        &mut self,
+        operation_id: &uuid::Uuid,
+        new_state: mint::SagaStateEnum,
+    ) -> Result<(), Self::Err> {
+        let current_time = unix_time();
+
+        query(
+            r#"
+            UPDATE saga_state
+            SET state = :state, updated_at = :updated_at
+            WHERE operation_id = :operation_id
+            "#,
+        )?
+        .bind("state", new_state.state())
+        .bind("updated_at", current_time as i64)
+        .bind("operation_id", operation_id.to_string())
+        .execute(&self.inner)
+        .await?;
+
+        Ok(())
+    }
+
+    async fn delete_saga(&mut self, operation_id: &uuid::Uuid) -> Result<(), Self::Err> {
+        query(
+            r#"
+            DELETE FROM saga_state
+            WHERE operation_id = :operation_id
+            "#,
+        )?
+        .bind("operation_id", operation_id.to_string())
+        .execute(&self.inner)
+        .await?;
+
+        Ok(())
+    }
+}
+
+#[async_trait]
+impl<RM> SagaDatabase for SQLMintDatabase<RM>
+where
+    RM: DatabasePool + 'static,
+{
+    type Err = Error;
+
+    async fn get_incomplete_sagas(
+        &self,
+        operation_kind: mint::OperationKind,
+    ) -> Result<Vec<mint::Saga>, Self::Err> {
+        let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?;
+        Ok(query(
+            r#"
+            SELECT
+                operation_id,
+                operation_kind,
+                state,
+                blinded_secrets,
+                input_ys,
+                created_at,
+                updated_at
+            FROM
+                saga_state
+            WHERE
+                operation_kind = :operation_kind
+            ORDER BY created_at ASC
+            "#,
+        )?
+        .bind("operation_kind", operation_kind.to_string())
+        .fetch_all(&*conn)
+        .await?
+        .into_iter()
+        .map(sql_row_to_saga)
+        .collect::<Result<Vec<_>, _>>()?)
+    }
+}
+
+#[async_trait]
 impl<RM> MintDatabase<Error> for SQLMintDatabase<RM>
 where
     RM: DatabasePool + 'static,
@@ -2381,6 +2531,53 @@ fn sql_row_to_blind_signature(row: Vec<Column>) -> Result<BlindSignature, Error>
     })
 }
 
+fn sql_row_to_saga(row: Vec<Column>) -> Result<mint::Saga, Error> {
+    unpack_into!(
+        let (
+            operation_id,
+            operation_kind,
+            state,
+            blinded_secrets,
+            input_ys,
+            created_at,
+            updated_at
+        ) = row
+    );
+
+    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)))?;
+
+    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)))?;
+
+    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)))?;
+
+    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)))?;
+
+    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)))?;
+
+    let created_at: u64 = column_as_number!(created_at);
+    let updated_at: u64 = column_as_number!(updated_at);
+
+    Ok(mint::Saga {
+        operation_id,
+        operation_kind,
+        state,
+        blinded_secrets,
+        input_ys,
+        created_at,
+        updated_at,
+    })
+}
+
 #[cfg(test)]
 mod test {
     use super::*;

+ 5 - 3
crates/cdk-sqlite/src/mint/memory.rs

@@ -2,7 +2,7 @@
 use std::collections::HashMap;
 
 use cdk_common::database::{self, MintDatabase, MintKeysDatabase};
-use cdk_common::mint::{self, MintKeySetInfo, MintQuote};
+use cdk_common::mint::{self, MintKeySetInfo, MintQuote, Operation};
 use cdk_common::nuts::{CurrencyUnit, Id, Proofs};
 use cdk_common::MintInfo;
 
@@ -56,8 +56,10 @@ pub async fn new_with_state(
         tx.add_melt_quote(quote).await?;
     }
 
-    tx.add_proofs(pending_proofs, None).await?;
-    tx.add_proofs(spent_proofs, None).await?;
+    tx.add_proofs(pending_proofs, None, &Operation::new_swap())
+        .await?;
+    tx.add_proofs(spent_proofs, None, &Operation::new_swap())
+        .await?;
     let mint_info_bytes = serde_json::to_vec(&mint_info)?;
     tx.kv_write(
         CDK_MINT_PRIMARY_NAMESPACE,

+ 2 - 0
crates/cdk/Cargo.toml

@@ -141,12 +141,14 @@ required-features = ["wallet"]
 [dev-dependencies]
 rand.workspace = true
 cdk-sqlite.workspace = true
+cdk-fake-wallet.workspace = true
 bip39.workspace = true
 tracing-subscriber.workspace = true
 criterion.workspace = true
 reqwest = { workspace = true }
 anyhow.workspace = true
 ureq = { version = "3.1.0", features = ["json"] }
+tokio = { workspace = true, features = ["full"] }
 
 
 [[bench]]

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

@@ -49,6 +49,9 @@ pub use oidc_client::OidcClient;
 pub mod event;
 pub mod fees;
 
+#[cfg(test)]
+pub mod test_helpers;
+
 #[doc(hidden)]
 pub use bitcoin::secp256k1;
 #[cfg(feature = "mint")]

+ 0 - 152
crates/cdk/src/mint/blinded_message_writer.rs

@@ -1,152 +0,0 @@
-//! Blinded message writer
-use std::collections::HashSet;
-
-use cdk_common::database::{self, DynMintDatabase, MintTransaction};
-use cdk_common::nuts::BlindedMessage;
-use cdk_common::{Error, PublicKey, QuoteId};
-
-type Tx<'a, 'b> = Box<dyn MintTransaction<'a, database::Error> + Send + Sync + 'b>;
-
-/// Blinded message writer
-///
-/// This is a blinded message writer that emulates a database transaction but without holding the
-/// transaction alive while waiting for external events to be fully committed to the database;
-/// instead, it maintains a `pending` state.
-///
-/// This struct allows for premature exit on error, enabling it to remove blinded messages that
-/// were added during the operation.
-///
-/// This struct is not fully ACID. If the process exits due to a panic, and the `Drop` function
-/// cannot be run, the cleanup process should reset the state.
-pub struct BlindedMessageWriter {
-    db: Option<DynMintDatabase>,
-    added_blinded_secrets: Option<HashSet<PublicKey>>,
-}
-
-impl BlindedMessageWriter {
-    /// Creates a new BlindedMessageWriter on top of the database
-    pub fn new(db: DynMintDatabase) -> Self {
-        Self {
-            db: Some(db),
-            added_blinded_secrets: Some(Default::default()),
-        }
-    }
-
-    /// The changes are permanent, consume the struct removing the database, so the Drop does
-    /// nothing
-    pub fn commit(mut self) {
-        self.db.take();
-        self.added_blinded_secrets.take();
-    }
-
-    /// Add blinded messages
-    pub async fn add_blinded_messages(
-        &mut self,
-        tx: &mut Tx<'_, '_>,
-        quote_id: Option<QuoteId>,
-        blinded_messages: &[BlindedMessage],
-    ) -> Result<Vec<PublicKey>, Error> {
-        let added_secrets = if let Some(secrets) = self.added_blinded_secrets.as_mut() {
-            secrets
-        } else {
-            return Err(Error::Internal);
-        };
-
-        if let Some(err) = tx
-            .add_blinded_messages(quote_id.as_ref(), blinded_messages)
-            .await
-            .err()
-        {
-            return match err {
-                cdk_common::database::Error::Duplicate => Err(Error::DuplicateOutputs),
-                err => Err(Error::Database(err)),
-            };
-        }
-
-        let blinded_secrets: Vec<PublicKey> = blinded_messages
-            .iter()
-            .map(|bm| bm.blinded_secret)
-            .collect();
-
-        for blinded_secret in &blinded_secrets {
-            added_secrets.insert(*blinded_secret);
-        }
-
-        Ok(blinded_secrets)
-    }
-
-    /// Rollback all changes in this BlindedMessageWriter consuming it.
-    pub async fn rollback(mut self) -> Result<(), Error> {
-        let db = if let Some(db) = self.db.take() {
-            db
-        } else {
-            return Ok(());
-        };
-        let mut tx = db.begin_transaction().await?;
-        let blinded_secrets: Vec<PublicKey> =
-            if let Some(secrets) = self.added_blinded_secrets.take() {
-                secrets.into_iter().collect()
-            } else {
-                return Ok(());
-            };
-
-        if !blinded_secrets.is_empty() {
-            tracing::info!("Rollback {} blinded messages", blinded_secrets.len(),);
-
-            remove_blinded_messages(&mut tx, &blinded_secrets).await?;
-        }
-
-        tx.commit().await?;
-
-        Ok(())
-    }
-}
-
-/// Removes blinded messages from the database
-#[inline(always)]
-async fn remove_blinded_messages(
-    tx: &mut Tx<'_, '_>,
-    blinded_secrets: &[PublicKey],
-) -> Result<(), Error> {
-    tx.delete_blinded_messages(blinded_secrets)
-        .await
-        .map_err(Error::Database)
-}
-
-#[inline(always)]
-async fn rollback_blinded_messages(
-    db: DynMintDatabase,
-    blinded_secrets: Vec<PublicKey>,
-) -> Result<(), Error> {
-    let mut tx = db.begin_transaction().await?;
-    remove_blinded_messages(&mut tx, &blinded_secrets).await?;
-    tx.commit().await?;
-
-    Ok(())
-}
-
-impl Drop for BlindedMessageWriter {
-    fn drop(&mut self) {
-        let db = if let Some(db) = self.db.take() {
-            db
-        } else {
-            tracing::debug!("Blinded message writer dropped after commit, no need to rollback.");
-            return;
-        };
-        let blinded_secrets: Vec<PublicKey> =
-            if let Some(secrets) = self.added_blinded_secrets.take() {
-                secrets.into_iter().collect()
-            } else {
-                return;
-            };
-
-        if !blinded_secrets.is_empty() {
-            tracing::debug!("Blinded message writer dropper with messages attempting to remove.");
-            tokio::spawn(async move {
-                if let Err(err) = rollback_blinded_messages(db, blinded_secrets).await {
-                    tracing::error!("Failed to rollback blinded messages in Drop: {}", err);
-                }
-            });
-        }
-    }
-}

+ 5 - 1
crates/cdk/src/mint/issue/mod.rs

@@ -1,4 +1,4 @@
-use cdk_common::mint::MintQuote;
+use cdk_common::mint::{MintQuote, Operation};
 use cdk_common::payment::{
     Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions,
     IncomingPaymentOptions, WaitPaymentResponse,
@@ -657,6 +657,10 @@ impl Mint {
         let unit = unit.ok_or(Error::UnsupportedUnit).unwrap();
         ensure_cdk!(unit == mint_quote.unit, Error::UnsupportedUnit);
 
+        let operation = Operation::new_mint();
+
+        tx.add_blinded_messages(Some(&mint_request.quote), &mint_request.outputs, &operation).await?;
+
         tx.add_blind_signatures(
             &mint_request
                 .outputs

+ 10 - 2
crates/cdk/src/mint/melt.rs

@@ -5,7 +5,7 @@ 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;
+use cdk_common::mint::{MeltPaymentRequest, Operation};
 use cdk_common::nut05::MeltMethodOptions;
 use cdk_common::payment::{
     Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, DynMintPayment,
@@ -506,6 +506,7 @@ impl Mint {
         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,
@@ -520,6 +521,7 @@ impl Mint {
                 tx,
                 melt_request.inputs(),
                 Some(melt_request.quote_id().to_owned()),
+                operation,
             )
             .await?;
 
@@ -613,10 +615,11 @@ impl Mint {
 
         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)
+            .verify_melt_request(&mut tx, verification, melt_request, &melt_operation)
             .await
         {
             Ok(result) => result,
@@ -646,6 +649,7 @@ impl Mint {
         tx.add_blinded_messages(
             Some(melt_request.quote_id()),
             melt_request.outputs().as_ref().unwrap_or(&Vec::new()),
+            &melt_operation,
         )
         .await?;
 
@@ -894,6 +898,10 @@ impl Mint {
             quote.id
         );
 
+        if total_spent < quote.amount {
+            return Err(Error::AmountUndefined);
+        }
+
         let update_proof_states_result = proof_writer
             .update_proofs_states(&mut tx, &input_ys, State::Spent)
             .await;

+ 16 - 1
crates/cdk/src/mint/mod.rs

@@ -34,7 +34,6 @@ use crate::{cdk_database, Amount};
 
 #[cfg(feature = "auth")]
 pub(crate) mod auth;
-mod blinded_message_writer;
 mod builder;
 mod check_spendable;
 mod issue;
@@ -239,6 +238,15 @@ 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?;
+
         let mut task_state = self.task_state.lock().await;
 
         // Prevent starting if already running
@@ -813,6 +821,13 @@ impl Mint {
         &self,
         blinded_message: Vec<BlindedMessage>,
     ) -> Result<Vec<BlindSignature>, Error> {
+        #[cfg(test)]
+        {
+            if crate::test_helpers::mint::should_fail_in_test() {
+                return Err(Error::SignatureMissingOrInvalid);
+            }
+        }
+
         #[cfg(feature = "prometheus")]
         global::inc_in_flight_requests("blind_sign");
 

+ 7 - 1
crates/cdk/src/mint/proof_writer.rs

@@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet};
 use std::sync::Arc;
 
 use cdk_common::database::{self, DynMintDatabase, MintTransaction};
+use cdk_common::mint::Operation;
 use cdk_common::{Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
 
 use super::subscription::PubSubManager;
@@ -49,6 +50,7 @@ impl ProofWriter {
         tx: &mut Tx<'_, '_>,
         proofs: &Proofs,
         quote_id: Option<QuoteId>,
+        operation_id: &Operation,
     ) -> Result<Vec<PublicKey>, Error> {
         let proof_states = if let Some(proofs) = self.proof_original_states.as_mut() {
             proofs
@@ -56,7 +58,11 @@ impl ProofWriter {
             return Err(Error::Internal);
         };
 
-        if let Some(err) = tx.add_proofs(proofs.clone(), quote_id).await.err() {
+        if let Some(err) = tx
+            .add_proofs(proofs.clone(), quote_id, operation_id)
+            .await
+            .err()
+        {
             return match err {
                 cdk_common::database::Error::Duplicate => Err(Error::TokenPending),
                 cdk_common::database::Error::AttemptUpdateSpentProof => {

+ 66 - 0
crates/cdk/src/mint/start_up_check.rs

@@ -3,7 +3,10 @@
 //! These checks are need in the case the mint was offline and the lightning node was node.
 //! These ensure that the status of the mint or melt quote matches in the mint db and on the node.
 
+use cdk_common::mint::OperationKind;
+
 use super::{Error, Mint};
+use crate::mint::swap::swap_saga::compensation::{CompensatingAction, RemoveSwapSetup};
 use crate::mint::{MeltQuote, MeltQuoteState, PaymentMethod};
 use crate::types::PaymentProcessorKey;
 
@@ -79,4 +82,67 @@ impl Mint {
 
         Ok(())
     }
+
+    /// Recover from incomplete swap sagas
+    ///
+    /// Checks all persisted sagas for swap operations and compensates
+    /// incomplete ones by removing both proofs and blinded messages.
+    pub async fn recover_from_incomplete_sagas(&self) -> Result<(), Error> {
+        let incomplete_sagas = self
+            .localstore
+            .get_incomplete_sagas(OperationKind::Swap)
+            .await?;
+
+        if incomplete_sagas.is_empty() {
+            tracing::info!("No incomplete swap sagas found to recover.");
+            return Ok(());
+        }
+
+        let total_sagas = incomplete_sagas.len();
+        tracing::info!("Found {} incomplete swap sagas to recover.", total_sagas);
+
+        for saga in incomplete_sagas {
+            tracing::info!(
+                "Recovering saga {} in state '{}' (created: {}, updated: {})",
+                saga.operation_id,
+                saga.state.state(),
+                saga.created_at,
+                saga.updated_at
+            );
+
+            // Use the same compensation logic as in-process failures
+            let compensation = RemoveSwapSetup {
+                blinded_secrets: saga.blinded_secrets.clone(),
+                input_ys: saga.input_ys.clone(),
+            };
+
+            // Execute compensation
+            if let Err(e) = compensation.execute(&self.localstore).await {
+                tracing::error!(
+                    "Failed to compensate saga {}: {}. Continuing...",
+                    saga.operation_id,
+                    e
+                );
+                continue;
+            }
+
+            // Delete saga after successful compensation
+            let mut tx = self.localstore.begin_transaction().await?;
+            if let Err(e) = tx.delete_saga(&saga.operation_id).await {
+                tracing::error!("Failed to delete saga for {}: {}", saga.operation_id, e);
+                tx.rollback().await?;
+                continue;
+            }
+            tx.commit().await?;
+
+            tracing::info!("Successfully recovered saga {}", saga.operation_id);
+        }
+
+        tracing::info!(
+            "Successfully recovered {} incomplete swap sagas.",
+            total_sagas
+        );
+
+        Ok(())
+    }
 }

+ 0 - 173
crates/cdk/src/mint/swap.rs

@@ -1,173 +0,0 @@
-#[cfg(feature = "prometheus")]
-use cdk_prometheus::METRICS;
-use tracing::instrument;
-
-use super::blinded_message_writer::BlindedMessageWriter;
-use super::nut11::{enforce_sig_flag, EnforceSigFlag};
-use super::proof_writer::ProofWriter;
-use super::{Mint, PublicKey, SigFlag, State, SwapRequest, SwapResponse};
-use crate::Error;
-
-impl Mint {
-    /// Process Swap
-    #[instrument(skip_all)]
-    pub async fn process_swap_request(
-        &self,
-        swap_request: SwapRequest,
-    ) -> Result<SwapResponse, Error> {
-        #[cfg(feature = "prometheus")]
-        METRICS.inc_in_flight_requests("process_swap_request");
-        // Do the external call before beginning the db transaction
-        // Check any overflow before talking to the signatory
-        swap_request.input_amount()?;
-        swap_request.output_amount()?;
-
-        // We add blinded messages to db before attempting to sign
-        // this ensures that they are unique and have not been used before
-        let mut blinded_message_writer = BlindedMessageWriter::new(self.localstore.clone());
-        let mut tx = self.localstore.begin_transaction().await?;
-
-        match blinded_message_writer
-            .add_blinded_messages(&mut tx, None, swap_request.outputs())
-            .await
-        {
-            Ok(_) => {
-                tx.commit().await?;
-            }
-            Err(err) => {
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("process_swap_request");
-                    METRICS.record_mint_operation("process_swap_request", false);
-                    METRICS.record_error();
-                }
-                return Err(err);
-            }
-        }
-
-        let promises = self.blind_sign(swap_request.outputs().to_owned()).await?;
-        let input_verification =
-            self.verify_inputs(swap_request.inputs())
-                .await
-                .map_err(|err| {
-                    #[cfg(feature = "prometheus")]
-                    {
-                        METRICS.dec_in_flight_requests("process_swap_request");
-                        METRICS.record_mint_operation("process_swap_request", false);
-                        METRICS.record_error();
-                    }
-
-                    tracing::debug!("Input verification failed: {:?}", err);
-                    err
-                })?;
-        let mut tx = self.localstore.begin_transaction().await?;
-
-        if let Err(err) = self
-            .verify_transaction_balanced(
-                &mut tx,
-                input_verification,
-                swap_request.inputs(),
-                swap_request.outputs(),
-            )
-            .await
-        {
-            tracing::debug!("Attempt to swap unbalanced transaction, aborting: {err}");
-
-            #[cfg(feature = "prometheus")]
-            {
-                METRICS.dec_in_flight_requests("process_swap_request");
-                METRICS.record_mint_operation("process_swap_request", false);
-                METRICS.record_error();
-            }
-
-            tx.rollback().await?;
-            blinded_message_writer.rollback().await?;
-
-            return Err(err);
-        };
-
-        let validate_sig_result = self.validate_sig_flag(&swap_request).await;
-
-        if let Err(err) = validate_sig_result {
-            tx.rollback().await?;
-            blinded_message_writer.rollback().await?;
-
-            #[cfg(feature = "prometheus")]
-            self.record_swap_failure("process_swap_request");
-            return Err(err);
-        }
-        let mut proof_writer =
-            ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone());
-        let input_ys = match proof_writer
-            .add_proofs(&mut tx, swap_request.inputs(), None)
-            .await
-        {
-            Ok(ys) => ys,
-            Err(err) => {
-                #[cfg(feature = "prometheus")]
-                {
-                    METRICS.dec_in_flight_requests("process_swap_request");
-                    METRICS.record_mint_operation("process_swap_request", false);
-                    METRICS.record_error();
-                }
-                tx.rollback().await?;
-                blinded_message_writer.rollback().await?;
-                return Err(err);
-            }
-        };
-
-        let update_proof_states_result = proof_writer
-            .update_proofs_states(&mut tx, &input_ys, State::Spent)
-            .await;
-
-        if let Err(err) = update_proof_states_result {
-            #[cfg(feature = "prometheus")]
-            self.record_swap_failure("process_swap_request");
-
-            tx.rollback().await?;
-            blinded_message_writer.rollback().await?;
-            return Err(err);
-        }
-
-        tx.add_blind_signatures(
-            &swap_request
-                .outputs()
-                .iter()
-                .map(|o| o.blinded_secret)
-                .collect::<Vec<PublicKey>>(),
-            &promises,
-            None,
-        )
-        .await?;
-
-        proof_writer.commit();
-        blinded_message_writer.commit();
-        tx.commit().await?;
-
-        let response = SwapResponse::new(promises);
-
-        #[cfg(feature = "prometheus")]
-        {
-            METRICS.dec_in_flight_requests("process_swap_request");
-            METRICS.record_mint_operation("process_swap_request", true);
-        }
-
-        Ok(response)
-    }
-
-    async fn validate_sig_flag(&self, swap_request: &SwapRequest) -> Result<(), Error> {
-        let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(swap_request.inputs().clone());
-
-        if sig_flag == SigFlag::SigAll {
-            swap_request.verify_sig_all()?;
-        }
-
-        Ok(())
-    }
-    #[cfg(feature = "prometheus")]
-    fn record_swap_failure(&self, operation: &str) {
-        METRICS.dec_in_flight_requests(operation);
-        METRICS.record_mint_operation(operation, false);
-        METRICS.record_error();
-    }
-}

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

@@ -0,0 +1,84 @@
+#[cfg(feature = "prometheus")]
+use cdk_prometheus::METRICS;
+use swap_saga::SwapSaga;
+use tracing::instrument;
+
+use super::nut11::{enforce_sig_flag, EnforceSigFlag};
+use super::{Mint, SigFlag, SwapRequest, SwapResponse};
+use crate::Error;
+
+pub mod swap_saga;
+
+impl Mint {
+    /// Process Swap
+    #[instrument(skip_all)]
+    pub async fn process_swap_request(
+        &self,
+        swap_request: SwapRequest,
+    ) -> Result<SwapResponse, Error> {
+        #[cfg(feature = "prometheus")]
+        METRICS.inc_in_flight_requests("process_swap_request");
+
+        swap_request.input_amount()?;
+        swap_request.output_amount()?;
+
+        // Verify inputs (cryptographic verification, no DB needed)
+        let input_verification =
+            self.verify_inputs(swap_request.inputs())
+                .await
+                .map_err(|err| {
+                    #[cfg(feature = "prometheus")]
+                    self.record_swap_failure("process_swap_request");
+
+                    tracing::debug!("Input verification failed: {:?}", err);
+                    err
+                })?;
+
+        // Verify signature flag (no DB needed)
+        self.validate_sig_flag(&swap_request).await?;
+
+        // Step 1: Initialize the swap saga
+        let init_saga = SwapSaga::new(self, self.localstore.clone(), self.pubsub_manager.clone());
+
+        // Step 2: TX1 - Setup swap (verify balance + add inputs as pending + add output blinded messages)
+        let setup_saga = init_saga
+            .setup_swap(
+                swap_request.inputs(),
+                swap_request.outputs(),
+                None,
+                input_verification,
+            )
+            .await?;
+
+        // Step 3: Blind sign outputs (no DB transaction)
+        let signed_saga = setup_saga.sign_outputs().await?;
+
+        // Step 4: TX2 - Finalize swap (add signatures + mark inputs spent)
+        let response = signed_saga.finalize().await?;
+
+        #[cfg(feature = "prometheus")]
+        {
+            METRICS.dec_in_flight_requests("process_swap_request");
+            METRICS.record_mint_operation("process_swap_request", true);
+        }
+
+        Ok(response)
+    }
+
+    async fn validate_sig_flag(&self, swap_request: &SwapRequest) -> Result<(), Error> {
+        let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(swap_request.inputs().clone());
+
+        if sig_flag == SigFlag::SigAll {
+            swap_request.verify_sig_all()?;
+        }
+
+        Ok(())
+    }
+
+    #[cfg(feature = "prometheus")]
+    fn record_swap_failure(&self, operation: &str) {
+        METRICS.dec_in_flight_requests(operation);
+        METRICS.record_mint_operation(operation, false);
+        METRICS.record_error();
+    }
+}

+ 61 - 0
crates/cdk/src/mint/swap/swap_saga/compensation.rs

@@ -0,0 +1,61 @@
+use async_trait::async_trait;
+use cdk_common::database::DynMintDatabase;
+use cdk_common::{Error, PublicKey};
+use tracing::instrument;
+
+#[async_trait]
+pub trait CompensatingAction: Send + Sync {
+    async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error>;
+    fn name(&self) -> &'static str;
+}
+
+/// Compensation action to remove swap setup (both proofs and blinded messages).
+///
+/// This compensation is used when blind signing fails or finalization fails after
+/// the setup transaction has committed. It removes:
+/// - Output blinded messages (identified by blinded_secrets)
+/// - Input proofs (identified by input_ys)
+///
+/// This restores the database to its pre-swap state.
+pub struct RemoveSwapSetup {
+    /// Blinded secrets (B values) from the output blinded messages
+    pub blinded_secrets: Vec<PublicKey>,
+    /// Y values (public keys) from the input proofs
+    pub input_ys: Vec<PublicKey>,
+}
+
+#[async_trait]
+impl CompensatingAction for RemoveSwapSetup {
+    #[instrument(skip_all)]
+    async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error> {
+        if self.blinded_secrets.is_empty() && self.input_ys.is_empty() {
+            return Ok(());
+        }
+
+        tracing::info!(
+            "Compensation: Removing swap setup ({} blinded messages, {} proofs)",
+            self.blinded_secrets.len(),
+            self.input_ys.len()
+        );
+
+        let mut tx = db.begin_transaction().await?;
+
+        // Remove blinded messages (outputs)
+        if !self.blinded_secrets.is_empty() {
+            tx.delete_blinded_messages(&self.blinded_secrets).await?;
+        }
+
+        // Remove proofs (inputs)
+        if !self.input_ys.is_empty() {
+            tx.remove_proofs(&self.input_ys, None).await?;
+        }
+
+        tx.commit().await?;
+
+        Ok(())
+    }
+
+    fn name(&self) -> &'static str {
+        "RemoveSwapSetup"
+    }
+}

+ 514 - 0
crates/cdk/src/mint/swap/swap_saga/mod.rs

@@ -0,0 +1,514 @@
+use std::collections::VecDeque;
+use std::sync::Arc;
+
+use cdk_common::database::DynMintDatabase;
+use cdk_common::mint::{Operation, Saga, SwapSagaState};
+use cdk_common::nuts::BlindedMessage;
+use cdk_common::{database, Error, Proofs, ProofsMethods, PublicKey, QuoteId, State};
+use tokio::sync::Mutex;
+use tracing::instrument;
+
+use self::compensation::{CompensatingAction, RemoveSwapSetup};
+use self::state::{Initial, SetupComplete, Signed};
+use crate::mint::subscription::PubSubManager;
+
+pub mod compensation;
+mod state;
+
+#[cfg(test)]
+mod tests;
+
+/// Saga pattern implementation for atomic swap operations.
+///
+/// # Why Use the Saga Pattern?
+///
+/// The swap operation consists of multiple steps that span database transactions
+/// and non-transactional operations (blind signing). We need to ensure atomicity
+/// across these heterogeneous steps while maintaining consistency in failure scenarios.
+///
+/// Traditional ACID transactions cannot span:
+/// 1. Multiple database transactions (TX1: setup, TX2: finalize)
+/// 2. Non-database operations (blind signing of outputs)
+///
+/// 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
+///
+/// # Transaction Boundaries
+///
+/// - **TX1 (setup_swap)**: Atomically verifies balance, adds input proofs (pending),
+///   adds output blinded messages, and persists saga state for crash recovery
+/// - **Signing (sign_outputs)**: Non-transactional cryptographic operation
+/// - **TX2 (finalize)**: Atomically adds signatures to outputs, marks inputs as spent,
+///   and deletes saga state (best-effort, will be cleaned up on recovery if this fails)
+///
+/// Saga state persistence is atomic with swap state changes, ensuring consistency
+/// for crash recovery scenarios.
+///
+/// # Expected Actions
+///
+/// 1. **setup_swap**: Verifies the swap is balanced, reserves inputs, prepares outputs
+///    - Compensation: Removes both inputs and outputs if later steps fail
+/// 2. **sign_outputs**: Performs blind signing (no DB changes)
+///    - Triggers compensation if signing fails
+/// 3. **finalize**: Commits signatures and marks inputs spent
+///    - Triggers compensation if finalization fails
+///    - Clears compensations on success (swap complete)
+///
+/// # Failure Handling
+///
+/// If any step fails after setup_swap, all compensating actions are executed in reverse
+/// order to restore the database to its pre-swap state. This ensures no partial swaps
+/// leave the system in an inconsistent state.
+///
+/// # Compensation Order (LIFO)
+///
+/// Compensations are stored in a VecDeque and executed in LIFO (Last-In-First-Out) order
+/// using `push_front` + iteration. This ensures that actions are undone in the reverse
+/// order they were performed, which is critical for maintaining data consistency.
+///
+/// Example: If we perform actions A → B → C in the forward path, compensations must
+/// execute as C' → B' → A' to properly reverse the operations without violating
+/// any invariants or constraints.
+///
+/// # Typestate Pattern
+///
+/// This saga uses the **typestate pattern** to enforce state transitions at compile-time.
+/// Each state (Initial, SetupComplete, Signed) is a distinct type, and operations are
+/// only available on the appropriate type:
+///
+/// ```text
+/// SwapSaga<Initial>
+///   └─> setup_swap() -> SwapSaga<SetupComplete>
+///         └─> sign_outputs() -> SwapSaga<Signed>
+///               └─> finalize() -> SwapResponse
+/// ```
+///
+/// **Benefits:**
+/// - Invalid state transitions (e.g., `finalize()` before `sign_outputs()`) won't compile
+/// - State-specific data (e.g., signatures) 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 SwapSaga<'a, S> {
+    mint: &'a super::Mint,
+    db: DynMintDatabase,
+    pubsub: Arc<PubSubManager>,
+    /// Compensating actions in LIFO order (most recent first)
+    compensations: Arc<Mutex<VecDeque<Box<dyn CompensatingAction>>>>,
+    operation: Operation,
+    state_data: S,
+}
+
+impl<'a> SwapSaga<'a, Initial> {
+    pub fn new(mint: &'a super::Mint, db: DynMintDatabase, pubsub: Arc<PubSubManager>) -> Self {
+        Self {
+            mint,
+            db,
+            pubsub,
+            compensations: Arc::new(Mutex::new(VecDeque::new())),
+            operation: Operation::new_swap(),
+            state_data: Initial,
+        }
+    }
+
+    /// Sets up the swap by atomically verifying balance and reserving inputs/outputs.
+    ///
+    /// This is the first transaction (TX1) in the saga and must complete before blind signing.
+    ///
+    /// # What This Does
+    ///
+    /// Within a single database transaction:
+    /// 1. Verifies the swap is balanced (input amount >= output amount + fees)
+    /// 2. Adds input proofs to the database
+    /// 3. Updates input proof states from Unspent to Pending
+    /// 4. Adds output blinded messages to the database
+    /// 5. Persists saga state for crash recovery (atomic with steps 1-4)
+    /// 6. Publishes proof state changes via pubsub
+    ///
+    /// # Compensation
+    ///
+    /// Registers a compensation action that will remove both the input proofs and output
+    /// blinded messages if any subsequent step (signing or finalization) fails.
+    ///
+    /// # Errors
+    ///
+    /// - `TokenPending`: Proofs are already pending or blinded messages are duplicates
+    /// - `TokenAlreadySpent`: Proofs have already been spent
+    /// - `DuplicateOutputs`: Output blinded messages already exist
+    #[instrument(skip_all)]
+    pub async fn setup_swap(
+        self,
+        input_proofs: &Proofs,
+        blinded_messages: &[BlindedMessage],
+        quote_id: Option<QuoteId>,
+        input_verification: crate::mint::Verification,
+    ) -> Result<SwapSaga<'a, SetupComplete>, Error> {
+        tracing::info!("TX1: Setting up swap (verify + inputs + outputs)");
+
+        let mut tx = self.db.begin_transaction().await?;
+
+        // Verify balance within the transaction
+        self.mint
+            .verify_transaction_balanced(
+                &mut tx,
+                input_verification,
+                input_proofs,
+                blinded_messages,
+            )
+            .await?;
+
+        // Add input proofs to DB
+        if let Err(err) = tx
+            .add_proofs(input_proofs.clone(), quote_id.clone(), &self.operation)
+            .await
+        {
+            tx.rollback().await?;
+            return Err(match err {
+                database::Error::Duplicate => Error::TokenPending,
+                database::Error::AttemptUpdateSpentProof => Error::TokenAlreadySpent,
+                _ => Error::Database(err),
+            });
+        }
+
+        let ys = match input_proofs.ys() {
+            Ok(ys) => ys,
+            Err(err) => return Err(Error::NUT00(err)),
+        };
+
+        // Update input proof states to Pending
+        let original_proof_states = match tx.update_proofs_states(&ys, State::Pending).await {
+            Ok(states) => states,
+            Err(database::Error::AttemptUpdateSpentProof)
+            | Err(database::Error::AttemptRemoveSpentProof) => {
+                tx.rollback().await?;
+                return Err(Error::TokenAlreadySpent);
+            }
+            Err(err) => {
+                tx.rollback().await?;
+                return Err(err.into());
+            }
+        };
+
+        // Verify proofs weren't already pending or spent
+        if ys.len() != original_proof_states.len() {
+            tracing::error!("Mismatched proof states");
+            tx.rollback().await?;
+            return Err(Error::Internal);
+        }
+
+        let forbidden_states = [State::Pending, State::Spent];
+        for original_state in original_proof_states.iter().flatten() {
+            if forbidden_states.contains(original_state) {
+                tx.rollback().await?;
+                return Err(if *original_state == State::Pending {
+                    Error::TokenPending
+                } else {
+                    Error::TokenAlreadySpent
+                });
+            }
+        }
+
+        // Add output blinded messages
+        if let Err(err) = tx
+            .add_blinded_messages(quote_id.as_ref(), blinded_messages, &self.operation)
+            .await
+        {
+            tx.rollback().await?;
+            return Err(match err {
+                database::Error::Duplicate => Error::DuplicateOutputs,
+                _ => Error::Database(err),
+            });
+        }
+
+        // Publish proof state changes
+        for pk in &ys {
+            self.pubsub.proof_state((*pk, State::Pending));
+        }
+
+        // Store data in saga struct (avoid duplication in state enum)
+        let blinded_messages_vec = blinded_messages.to_vec();
+        let blinded_secrets: Vec<PublicKey> = blinded_messages_vec
+            .iter()
+            .map(|bm| bm.blinded_secret)
+            .collect();
+
+        // Persist saga state for crash recovery (atomic with TX1)
+        let saga = Saga::new_swap(
+            *self.operation.id(),
+            SwapSagaState::SetupComplete,
+            blinded_secrets.clone(),
+            ys.clone(),
+        );
+
+        if let Err(err) = tx.add_saga(&saga).await {
+            tx.rollback().await?;
+            return Err(err.into());
+        }
+
+        tx.commit().await?;
+
+        // Register compensation (uses LIFO via push_front)
+        let compensations = Arc::clone(&self.compensations);
+        compensations
+            .lock()
+            .await
+            .push_front(Box::new(RemoveSwapSetup {
+                blinded_secrets: blinded_secrets.clone(),
+                input_ys: ys.clone(),
+            }));
+
+        // Transition to SetupComplete state
+        Ok(SwapSaga {
+            mint: self.mint,
+            db: self.db,
+            pubsub: self.pubsub,
+            compensations: self.compensations,
+            operation: self.operation,
+            state_data: SetupComplete {
+                blinded_messages: blinded_messages_vec,
+                ys,
+            },
+        })
+    }
+}
+
+impl<'a> SwapSaga<'a, SetupComplete> {
+    /// Performs blind signing of output blinded messages.
+    ///
+    /// This is a non-transactional cryptographic operation that happens after `setup_swap`
+    /// and before `finalize`. No database changes occur in this step.
+    ///
+    /// # What This Does
+    ///
+    /// 1. Retrieves blinded messages from the state data
+    /// 2. Calls the mint's blind signing function to generate signatures
+    /// 3. Stores signatures and transitions to the Signed state
+    ///
+    /// # Failure Handling
+    ///
+    /// If blind signing fails, all registered compensations are executed to roll back
+    /// the setup transaction, removing both input proofs and output blinded messages.
+    ///
+    /// # Errors
+    ///
+    /// - Propagates any errors from the blind signing operation
+    #[instrument(skip_all)]
+    pub async fn sign_outputs(self) -> Result<SwapSaga<'a, Signed>, Error> {
+        tracing::info!("Signing outputs (no DB)");
+
+        match self
+            .mint
+            .blind_sign(self.state_data.blinded_messages.clone())
+            .await
+        {
+            Ok(signatures) => {
+                // Transition to Signed state
+                // Note: We don't update saga state here because the "signed" state
+                // is not used by recovery logic - saga state remains "SetupComplete"
+                // until the swap is finalized or compensated
+                Ok(SwapSaga {
+                    mint: self.mint,
+                    db: self.db,
+                    pubsub: self.pubsub,
+                    compensations: self.compensations,
+                    operation: self.operation,
+                    state_data: Signed {
+                        blinded_messages: self.state_data.blinded_messages,
+                        ys: self.state_data.ys,
+                        signatures,
+                    },
+                })
+            }
+            Err(err) => {
+                self.compensate_all().await?;
+                Err(err)
+            }
+        }
+    }
+}
+
+impl SwapSaga<'_, Signed> {
+    /// Finalizes the swap by committing signatures and marking inputs as spent.
+    ///
+    /// This is the second and final transaction (TX2) in the saga and completes the swap.
+    ///
+    /// # What This Does
+    ///
+    /// Within a single database transaction:
+    /// 1. Adds the blind signatures to the output blinded messages
+    /// 2. Updates input proof states from Pending to Spent
+    /// 3. Deletes saga state (best-effort, won't fail swap if this fails)
+    /// 4. Publishes proof state changes via pubsub
+    /// 5. Clears all registered compensations (swap successfully completed)
+    ///
+    /// # Failure Handling
+    ///
+    /// If finalization fails, all registered compensations are executed to roll back
+    /// the setup transaction, removing both input proofs and output blinded messages.
+    /// The signatures are not persisted, so they are lost.
+    ///
+    /// # Success
+    ///
+    /// On success, compensations are cleared and the swap is complete. The client
+    /// can now use the returned signatures to construct valid proofs. If saga state
+    /// deletion fails, a warning is logged but the swap still succeeds (orphaned
+    /// saga state will be cleaned up on next recovery).
+    ///
+    /// # Errors
+    ///
+    /// - `TokenAlreadySpent`: Input proofs were already spent by another operation
+    /// - Propagates any database errors
+    #[instrument(skip_all)]
+    pub async fn finalize(self) -> Result<cdk_common::nuts::SwapResponse, Error> {
+        tracing::info!("TX2: Finalizing swap (signatures + mark spent)");
+
+        let blinded_secrets: Vec<PublicKey> = self
+            .state_data
+            .blinded_messages
+            .iter()
+            .map(|bm| bm.blinded_secret)
+            .collect();
+
+        let mut tx = self.db.begin_transaction().await?;
+
+        // Add blind signatures to outputs
+        // TODO: WE should move the should fail to the db so the there is not this extra rollback.
+        // This would allow the error to be from the same place in test and prod
+        #[cfg(test)]
+        {
+            if crate::test_helpers::mint::should_fail_for("ADD_SIGNATURES") {
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(Error::Database(database::Error::Database(
+                    "Test failure: ADD_SIGNATURES".into(),
+                )));
+            }
+        }
+
+        if let Err(err) = tx
+            .add_blind_signatures(&blinded_secrets, &self.state_data.signatures, None)
+            .await
+        {
+            tx.rollback().await?;
+            self.compensate_all().await?;
+            return Err(err.into());
+        }
+
+        // Mark input proofs as spent
+        // TODO: WE should move the should fail to the db so the there is not this extra rollback.
+        // This would allow the error to be from the same place in test and prod
+        #[cfg(test)]
+        {
+            if crate::test_helpers::mint::should_fail_for("UPDATE_PROOFS") {
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(Error::Database(database::Error::Database(
+                    "Test failure: UPDATE_PROOFS".into(),
+                )));
+            }
+        }
+
+        match tx
+            .update_proofs_states(&self.state_data.ys, State::Spent)
+            .await
+        {
+            Ok(_) => {}
+            Err(database::Error::AttemptUpdateSpentProof)
+            | Err(database::Error::AttemptRemoveSpentProof) => {
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(Error::TokenAlreadySpent);
+            }
+            Err(err) => {
+                tx.rollback().await?;
+                self.compensate_all().await?;
+                return Err(err.into());
+            }
+        }
+
+        // Publish proof state changes
+        for pk in &self.state_data.ys {
+            self.pubsub.proof_state((*pk, State::Spent));
+        }
+
+        // Delete saga - swap completed successfully (best-effort, atomic with TX2)
+        // Don't fail the swap if saga deletion fails - orphaned saga will be
+        // cleaned up on next recovery
+        if let Err(e) = tx.delete_saga(self.operation.id()).await {
+            tracing::warn!(
+                "Failed to delete saga in finalize (will be cleaned up on recovery): {}",
+                e
+            );
+            // Don't rollback - swap succeeded, orphaned saga is harmless
+        }
+
+        tx.commit().await?;
+
+        // Clear compensations - swap is complete
+        self.compensations.lock().await.clear();
+
+        Ok(cdk_common::nuts::SwapResponse::new(
+            self.state_data.signatures,
+        ))
+    }
+}
+
+impl<S> SwapSaga<'_, 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.
+    #[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")]
+        {
+            use cdk_prometheus::METRICS;
+
+            self.mint.record_swap_failure("process_swap_request");
+            METRICS.dec_in_flight_requests("process_swap_request");
+        }
+
+        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
+                );
+            }
+        }
+
+        // Delete saga - swap was compensated
+        // Use a separate transaction since compensations already ran
+        // Don't fail the compensation if saga cleanup fails (log only)
+        let mut tx = match self.db.begin_transaction().await {
+            Ok(tx) => tx,
+            Err(e) => {
+                tracing::error!(
+                    "Failed to begin tx for saga cleanup after compensation: {}",
+                    e
+                );
+                return Ok(()); // Compensations already ran, don't fail now
+            }
+        };
+
+        if let Err(e) = tx.delete_saga(self.operation.id()).await {
+            tracing::warn!("Failed to delete saga after compensation: {}", e);
+        } else if let Err(e) = tx.commit().await {
+            tracing::error!("Failed to commit saga cleanup after compensation: {}", e);
+        }
+        // Always succeed - compensations are done, saga cleanup is best-effort
+
+        Ok(())
+    }
+}

+ 26 - 0
crates/cdk/src/mint/swap/swap_saga/state.rs

@@ -0,0 +1,26 @@
+use cdk_common::nuts::{BlindSignature, BlindedMessage};
+use cdk_common::PublicKey;
+
+/// Initial state - no data yet.
+///
+/// The swap saga starts in this state. Only the `setup_swap` method is available.
+pub struct Initial;
+
+/// Setup complete - has blinded messages and input Y values.
+///
+/// After successful setup, the saga transitions to this state.
+/// Only the `sign_outputs` method is available.
+pub struct SetupComplete {
+    pub blinded_messages: Vec<BlindedMessage>,
+    pub ys: Vec<PublicKey>,
+}
+
+/// Signed state - has everything including signatures.
+///
+/// After successful signing, the saga transitions to this state.
+/// Only the `finalize` method is available.
+pub struct Signed {
+    pub blinded_messages: Vec<BlindedMessage>,
+    pub ys: Vec<PublicKey>,
+    pub signatures: Vec<BlindSignature>,
+}

+ 2993 - 0
crates/cdk/src/mint/swap/swap_saga/tests.rs

@@ -0,0 +1,2993 @@
+#![cfg(test)]
+//! Unit tests for the swap saga implementation
+//!
+//! These tests verify the swap saga pattern using in-memory mints and databases,
+//! without requiring external dependencies like Lightning nodes.
+
+use std::sync::Arc;
+
+use cdk_common::nuts::{Proofs, ProofsMethods};
+use cdk_common::{Amount, State};
+
+use super::SwapSaga;
+use crate::mint::swap::Mint;
+use crate::mint::Verification;
+use crate::test_helpers::mint::{create_test_blinded_messages, create_test_mint};
+
+/// Helper to create a verification result for testing
+fn create_verification(amount: Amount) -> Verification {
+    Verification {
+        amount,
+        unit: Some(cdk_common::nuts::CurrencyUnit::Sat),
+    }
+}
+
+/// Helper to create test proofs for swapping using the mint's process
+async fn create_swap_inputs(mint: &Mint, amount: Amount) -> (Proofs, Verification) {
+    let proofs = crate::test_helpers::mint::mint_test_proofs(mint, amount)
+        .await
+        .expect("Failed to create test proofs");
+
+    let verification = create_verification(amount);
+
+    (proofs, verification)
+}
+
+/// Tests that a SwapSaga can be created in the Initial state.
+///
+/// # What This Tests
+/// - SwapSaga::new() creates a saga in the Initial state
+/// - The typestate pattern ensures only Initial state is accessible after creation
+/// - No database operations occur during construction
+///
+/// # Success Criteria
+/// - Saga can be instantiated without errors
+/// - Saga is in Initial state (enforced by type system)
+#[tokio::test]
+async fn test_swap_saga_initial_state_creation() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let _saga = SwapSaga::new(&mint, db, pubsub);
+
+    // If we can create the saga, we're in the Initial state
+    // This is verified by the type system - only SwapSaga<Initial> can be created with new()
+}
+
+/// Tests the complete happy path flow through all sagas.
+///
+/// # What This Tests
+/// - Initial -> SetupComplete -> Signed -> Response state transitions
+/// - Database transactions commit successfully at each stage
+/// - Input proofs are marked as Pending during setup, then Spent after finalization
+/// - Output signatures are generated and returned correctly
+/// - Compensations are cleared on successful completion
+///
+/// # Flow
+/// 1. Create saga in Initial state
+/// 2. setup_swap: Transition to SetupComplete (TX1: add proofs + blinded messages)
+/// 3. sign_outputs: Transition to Signed (blind signing, no DB operations)
+/// 4. finalize: Complete saga (TX2: add signatures, mark proofs spent)
+///
+/// # Success Criteria
+/// - All state transitions succeed
+/// - Response contains correct number of signatures
+/// - All input proofs are marked as Spent
+/// - No errors occur during the entire flow
+#[tokio::test]
+async fn test_swap_saga_full_flow_success() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages, _pre_mint) =
+        create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    let response = saga.finalize().await.expect("Finalize should succeed");
+
+    assert_eq!(
+        response.signatures.len(),
+        output_blinded_messages.len(),
+        "Should have signatures for all outputs"
+    );
+
+    let ys = input_proofs.ys().unwrap();
+    let states = mint
+        .localstore()
+        .get_proofs_states(&ys)
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert_eq!(
+            state.unwrap(),
+            State::Spent,
+            "Input proofs should be marked as spent"
+        );
+    }
+}
+
+/// Tests the Initial -> SetupComplete state transition.
+///
+/// # What This Tests
+/// - setup_swap() successfully transitions saga from Initial to SetupComplete state
+/// - State data contains blinded messages and input proof Y values
+/// - Database transaction (TX1) commits successfully
+/// - Input proofs are marked as Pending (not Spent)
+/// - Compensation action is registered for potential rollback
+///
+/// # Database Operations (TX1)
+/// 1. Verify transaction is balanced
+/// 2. Add input proofs to database
+/// 3. Update proof states to Pending
+/// 4. Add output blinded messages to database
+/// 5. Commit transaction
+///
+/// # Success Criteria
+/// - Saga transitions to SetupComplete state
+/// - State data correctly stores blinded messages and input Ys
+/// - All input proofs have state = Pending in database
+#[tokio::test]
+async fn test_swap_saga_setup_transition() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(64);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    assert_eq!(
+        saga.state_data.blinded_messages.len(),
+        output_blinded_messages.len(),
+        "SetupComplete state should contain blinded messages"
+    );
+
+    assert_eq!(
+        saga.state_data.ys.len(),
+        input_proofs.len(),
+        "SetupComplete state should contain input ys"
+    );
+
+    let ys = input_proofs.ys().unwrap();
+    let states = mint
+        .localstore()
+        .get_proofs_states(&ys)
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert_eq!(
+            state.unwrap(),
+            State::Pending,
+            "Input proofs should be marked as pending after setup"
+        );
+    }
+}
+
+/// Tests the SetupComplete -> Signed state transition.
+///
+/// # What This Tests
+/// - sign_outputs() successfully transitions saga from SetupComplete to Signed state
+/// - Blind signatures are generated for all output blinded messages
+/// - No database operations occur during signing (cryptographic operation only)
+/// - State data contains signatures matching the number of blinded messages
+///
+/// # Operations
+/// 1. Performs blind signing on blinded messages (non-transactional)
+/// 2. Stores signatures in Signed state
+/// 3. Preserves blinded messages and input Ys from previous state
+///
+/// # Success Criteria
+/// - Saga transitions to Signed state
+/// - Number of signatures equals number of blinded messages
+/// - Compensations are still registered (cleared only on finalize)
+#[tokio::test]
+async fn test_swap_saga_sign_outputs_transition() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(128);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    assert_eq!(
+        saga.state_data.signatures.len(),
+        output_blinded_messages.len(),
+        "Signed state should contain signatures for all outputs"
+    );
+}
+
+/// Tests that duplicate input proofs are rejected during setup.
+///
+/// # What This Tests
+/// - Database detects and rejects duplicate proof additions
+/// - setup_swap() fails with appropriate error (TokenPending or duplicate error)
+/// - Transaction is rolled back, leaving no partial state
+///
+/// # Attack Vector
+/// This prevents an attacker from trying to spend the same proof twice
+/// within a single swap request.
+///
+/// # Success Criteria
+/// - setup_swap() returns an error
+/// - Database remains unchanged (transaction rollback)
+#[tokio::test]
+async fn test_swap_saga_duplicate_inputs() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (mut input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    input_proofs.push(input_proofs[0].clone());
+
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(result.is_err(), "Setup should fail with duplicate inputs");
+}
+
+/// Tests that duplicate output blinded messages are rejected during setup.
+///
+/// # What This Tests
+/// - Database detects and rejects duplicate blinded message additions
+/// - setup_swap() fails with DuplicateOutputs error
+/// - Transaction is rolled back, leaving no partial state
+///
+/// # Attack Vector
+/// This prevents reuse of blinded messages, which would allow an attacker
+/// to receive the same blind signature multiple times.
+///
+/// # Success Criteria
+/// - setup_swap() returns an error
+/// - Database remains unchanged (transaction rollback)
+#[tokio::test]
+async fn test_swap_saga_duplicate_outputs() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (mut output_blinded_messages, _) =
+        create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    output_blinded_messages.push(output_blinded_messages[0].clone());
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(result.is_err(), "Setup should fail with duplicate outputs");
+}
+
+/// Tests that unbalanced swap requests are rejected (outputs > inputs).
+///
+/// # What This Tests
+/// - Balance verification detects when output amount exceeds input amount
+/// - setup_swap() fails with TransactionUnbalanced error
+/// - Transaction is rolled back before any database changes
+///
+/// # Attack Vector
+/// This prevents an attacker from creating value out of thin air by
+/// requesting more outputs than they provided in inputs.
+///
+/// # Success Criteria
+/// - setup_swap() returns an error
+/// - Database remains unchanged (no proofs or blinded messages added)
+#[tokio::test]
+async fn test_swap_saga_unbalanced_transaction_more_outputs() {
+    let mint = create_test_mint().await.unwrap();
+
+    let input_amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, input_amount).await;
+
+    let output_amount = Amount::from(150);
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, output_amount)
+        .await
+        .unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(
+        result.is_err(),
+        "Setup should fail when outputs exceed inputs"
+    );
+}
+
+/// Tests that compensation actions are registered and cleared correctly.
+///
+/// # What This Tests
+/// - Compensations start empty
+/// - setup_swap() registers one compensation action (RemoveSwapSetup)
+/// - sign_outputs() preserves compensations (no change)
+/// - finalize() clears all compensations on success
+///
+/// # Saga Pattern
+/// Compensations allow rollback if any step fails. They are cleared only
+/// when the entire saga completes successfully. This test verifies the
+/// lifecycle of compensation tracking.
+///
+/// # Success Criteria
+/// - 0 compensations initially
+/// - 1 compensation after setup
+/// - 1 compensation after signing
+/// - Compensations cleared after successful finalize
+#[tokio::test]
+async fn test_swap_saga_compensation_clears_on_success() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let compensations_before = saga.compensations.lock().await.len();
+
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let compensations_after_setup = saga.compensations.lock().await.len();
+    assert_eq!(
+        compensations_after_setup, 1,
+        "Should have one compensation after setup"
+    );
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    let compensations_after_sign = saga.compensations.lock().await.len();
+    assert_eq!(
+        compensations_after_sign, 1,
+        "Should still have one compensation after signing"
+    );
+
+    let _response = saga.finalize().await.expect("Finalize should succeed");
+
+    assert_eq!(
+        compensations_before, 0,
+        "Should start with no compensations"
+    );
+}
+
+/// Tests that empty input proofs are rejected during setup.
+///
+/// # What This Tests
+/// - Swap with empty input proofs should fail gracefully
+/// - No database changes should occur
+///
+/// # Success Criteria
+/// - setup_swap() returns an error (not panic)
+/// - Database remains unchanged
+///
+/// # Note
+/// Empty inputs with non-empty outputs creates an unbalanced transaction
+/// (trying to create value from nothing), which should be rejected by
+/// the balance verification step.
+#[tokio::test]
+async fn test_swap_saga_empty_inputs() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+
+    let empty_proofs = Proofs::new();
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    // Verification must match the actual input amount (zero for empty proofs)
+    let verification = create_verification(Amount::from(0));
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(&empty_proofs, &output_blinded_messages, None, verification)
+        .await;
+
+    // This should fail because outputs (100) > inputs (0)
+    assert!(
+        result.is_err(),
+        "Empty inputs with non-empty outputs should be rejected (unbalanced)"
+    );
+}
+
+/// Tests that empty output blinded messages are rejected during setup.
+///
+/// # What This Tests
+/// - Swap with empty output blinded messages should fail gracefully
+/// - No database changes should occur
+///
+/// # Success Criteria
+/// - setup_swap() returns an error (not panic)
+/// - Database remains unchanged
+#[tokio::test]
+async fn test_swap_saga_empty_outputs() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let empty_blinded_messages = vec![];
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(
+            &input_proofs,
+            &empty_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(result.is_err(), "Empty outputs should be rejected");
+}
+
+/// Tests that both empty inputs and outputs are rejected during setup.
+///
+/// # What This Tests
+/// - Swap with both empty inputs and outputs should fail gracefully
+/// - No database changes should occur
+///
+/// # Success Criteria
+/// - setup_swap() returns an error (not panic)
+/// - Database remains unchanged
+#[tokio::test]
+async fn test_swap_saga_both_empty() {
+    let mint = create_test_mint().await.unwrap();
+
+    let empty_proofs = Proofs::new();
+    let empty_blinded_messages = vec![];
+    let verification = create_verification(Amount::from(0));
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga
+        .setup_swap(&empty_proofs, &empty_blinded_messages, None, verification)
+        .await;
+
+    assert!(result.is_err(), "Empty swap should be rejected");
+}
+
+/// Tests that a saga dropped without finalize does not auto-cleanup.
+///
+/// # What This Tests
+/// - When a saga is dropped after setup but before finalize:
+///   - Proofs remain in Pending state (no automatic cleanup)
+///   - Blinded messages remain in database
+///   - No compensations run automatically on drop
+///
+/// # Design Choice
+/// This tests for resource leaks and documents expected behavior.
+/// Cleanup requires explicit compensation or timeout mechanism.
+///
+/// # Success Criteria
+/// - After saga drop, proofs still Pending
+/// - Blinded messages still exist in database
+#[tokio::test]
+async fn test_swap_saga_drop_without_finalize() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let ys = input_proofs.ys().unwrap();
+
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let _saga = saga
+            .setup_swap(
+                &input_proofs,
+                &output_blinded_messages,
+                None,
+                input_verification,
+            )
+            .await
+            .expect("Setup should succeed");
+
+        // Verify setup state
+        let states = db.get_proofs_states(&ys).await.unwrap();
+        assert!(states.iter().all(|s| s == &Some(State::Pending)));
+
+        // _saga is dropped here without calling finalize
+    }
+
+    // Verify state is NOT automatically cleaned up
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s == &Some(State::Pending)),
+        "Proofs should remain Pending after saga drop (no auto-cleanup)"
+    );
+
+    // NOTE: This is expected behavior - compensations don't run on drop
+    // Cleanup requires either:
+    // 1. Explicit compensation call
+    // 2. Timeout mechanism to clean up stale Pending proofs
+    // 3. Manual intervention
+}
+
+/// Tests that a saga dropped after signing loses signatures.
+///
+/// # What This Tests
+/// - When a saga is dropped after signing but before finalize:
+///   - Proofs remain Pending
+///   - Signatures are lost (not persisted)
+///   - Demonstrates the importance of calling finalize
+///
+/// # Success Criteria
+/// - Proofs still Pending after drop
+/// - No signatures in database (they were only in memory)
+#[tokio::test]
+async fn test_swap_saga_drop_after_signing() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let ys = input_proofs.ys().unwrap();
+    let _blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(
+                &input_proofs,
+                &output_blinded_messages,
+                None,
+                input_verification,
+            )
+            .await
+            .expect("Setup should succeed");
+
+        let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+        // Verify we're in Signed state (has signatures)
+        assert_eq!(
+            saga.state_data.signatures.len(),
+            output_blinded_messages.len()
+        );
+
+        // saga is dropped here - signatures are lost!
+    }
+
+    // Verify proofs still Pending
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_after.iter().all(|s| s == &Some(State::Pending)));
+
+    // Verify signatures were NOT persisted (they were only in memory in the saga)
+    let signatures = db.get_blind_signatures(&_blinded_secrets).await.unwrap();
+    assert!(
+        signatures.iter().all(|s| s.is_none()),
+        "Signatures should be lost when saga is dropped (never persisted)"
+    );
+
+    // This demonstrates why finalize() is critical - without it, the signatures
+    // generated during signing are lost and the swap cannot complete
+}
+
+/// Tests that compensations execute when sign_outputs() fails.
+///
+/// # What This Tests
+/// - Verify that compensations execute when sign_outputs() fails
+/// - Verify that proofs are removed from database (rollback of setup)
+/// - Verify that blinded messages are removed from database
+/// - Verify that proof states are cleared (no longer Pending)
+///
+/// # Implementation
+/// Uses TEST_FAIL environment variable to make blind_sign() fail
+///
+/// # Success Criteria
+/// - Signing fails with error
+/// - Proofs are removed from database after failure
+/// - Blinded messages are removed after failure
+#[tokio::test]
+async fn test_swap_saga_compensation_on_signing_failure() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    // Setup should succeed
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    // Verify setup state
+    let ys = input_proofs.ys().unwrap();
+    let states = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states.iter().all(|s| s == &Some(State::Pending)));
+
+    // Enable test failure mode
+    std::env::set_var("TEST_FAIL", "1");
+
+    // Attempt signing (should fail due to TEST_FAIL)
+    let result = saga.sign_outputs().await;
+
+    // Clean up environment variable immediately
+    std::env::remove_var("TEST_FAIL");
+
+    assert!(result.is_err(), "Signing should fail");
+
+    // Verify compensation executed - proofs removed
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+
+    // Verify blinded messages removed (compensation removes blinded messages, not signatures)
+    // Since signatures are never created (only during finalize), we verify that
+    // if we query for them, we get None for all (they were never added)
+    let _blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+    let signatures = db.get_blind_signatures(&_blinded_secrets).await.unwrap();
+    assert!(
+        signatures.iter().all(|s| s.is_none()),
+        "No signatures should exist (never created)"
+    );
+}
+
+/// Tests that double-spend attempts are detected and rejected.
+///
+/// # What This Tests
+/// - First complete swap marks proofs as Spent
+/// - Second swap attempt with same proofs fails immediately
+/// - Database proof state prevents double-spending
+///
+/// # Security
+/// This is a critical security test. Double-spending would allow an
+/// attacker to reuse the same ecash tokens multiple times. The database
+/// must detect that proofs are already spent and reject the second swap.
+///
+/// # Flow
+/// 1. Complete first swap successfully (proofs marked Spent)
+/// 2. Attempt second swap with same proofs
+/// 3. Second setup_swap() fails with TokenAlreadySpent error
+///
+/// # Success Criteria
+/// - First swap completes successfully
+/// - Second swap fails with error
+/// - Proofs remain in Spent state
+#[tokio::test]
+async fn test_swap_saga_double_spend_detection() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (output_blinded_messages_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga1 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+
+    let saga1 = saga1
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages_1,
+            None,
+            input_verification.clone(),
+        )
+        .await
+        .expect("First setup should succeed");
+
+    let saga1 = saga1
+        .sign_outputs()
+        .await
+        .expect("First signing should succeed");
+
+    let _response1 = saga1
+        .finalize()
+        .await
+        .expect("First finalize should succeed");
+
+    let saga2 = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga2
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages_2,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(
+        result.is_err(),
+        "Second setup should fail due to double-spend"
+    );
+}
+
+/// Tests that pending proofs are detected and rejected.
+///
+/// # What This Tests
+/// - First swap marks proofs as Pending during setup
+/// - Second swap attempt with same proofs fails immediately
+/// - Database proof state prevents concurrent use of same proofs
+///
+/// # Concurrency Protection
+/// When proofs are marked Pending, they are reserved for an in-progress
+/// swap. No other swap should be able to use them until the first swap
+/// completes or rolls back.
+///
+/// # Flow
+/// 1. Start first swap (proofs marked Pending)
+/// 2. DO NOT finalize first swap
+/// 3. Attempt second swap with same proofs
+/// 4. Second setup_swap() fails with TokenPending error
+///
+/// # Success Criteria
+/// - First setup succeeds (proofs marked Pending)
+/// - Second setup fails with error
+/// - Proofs remain in Pending state
+#[tokio::test]
+async fn test_swap_saga_pending_proof_detection() {
+    let mint = create_test_mint().await.unwrap();
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (output_blinded_messages_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let saga1 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+
+    let saga1 = saga1
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages_1,
+            None,
+            input_verification.clone(),
+        )
+        .await
+        .expect("First setup should succeed");
+
+    // Keep saga1 in scope to maintain pending proofs
+    drop(saga1);
+
+    let saga2 = SwapSaga::new(&mint, db, pubsub);
+
+    let result = saga2
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages_2,
+            None,
+            input_verification,
+        )
+        .await;
+
+    assert!(
+        result.is_err(),
+        "Second setup should fail because proofs are pending"
+    );
+}
+
+/// Tests concurrent swap attempts with the same proofs.
+///
+/// # What This Tests
+/// - Database serialization ensures only one concurrent swap succeeds
+/// - Exactly one of N concurrent swaps with same proofs completes
+/// - Other swaps fail with TokenPending or TokenAlreadySpent errors
+/// - Final proof state is Spent (from the successful swap)
+///
+/// # Race Condition Protection
+/// This test verifies that the saga pattern combined with database
+/// transactions provides proper serialization. Even with 3 tasks racing
+/// to setup/sign/finalize, only one can succeed.
+///
+/// # Flow
+/// 1. Spawn 3 concurrent tasks, each trying to swap the same proofs
+/// 2. Each task creates its own saga and attempts full flow
+/// 3. Database ensures only one can mark proofs as Pending/Spent
+/// 4. Count successes and failures
+///
+/// # Success Criteria
+/// - Exactly 1 swap succeeds
+/// - Exactly 2 swaps fail
+/// - All proofs end up in Spent state
+#[tokio::test(flavor = "multi_thread", worker_threads = 3)]
+async fn test_swap_saga_concurrent_swaps() {
+    let mint = Arc::new(create_test_mint().await.unwrap());
+
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+
+    let (output_blinded_messages_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (output_blinded_messages_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (output_blinded_messages_3, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let mint1 = Arc::clone(&mint);
+    let mint2 = Arc::clone(&mint);
+    let mint3 = Arc::clone(&mint);
+
+    let proofs1 = input_proofs.clone();
+    let proofs2 = input_proofs.clone();
+    let proofs3 = input_proofs.clone();
+
+    let verification1 = input_verification.clone();
+    let verification2 = input_verification.clone();
+    let verification3 = input_verification.clone();
+
+    let task1 = tokio::spawn(async move {
+        let db = mint1.localstore();
+        let pubsub = mint1.pubsub_manager();
+        let saga = SwapSaga::new(&*mint1, db, pubsub);
+
+        let saga = saga
+            .setup_swap(&proofs1, &output_blinded_messages_1, None, verification1)
+            .await?;
+        let saga = saga.sign_outputs().await?;
+        saga.finalize().await
+    });
+
+    let task2 = tokio::spawn(async move {
+        let db = mint2.localstore();
+        let pubsub = mint2.pubsub_manager();
+        let saga = SwapSaga::new(&*mint2, db, pubsub);
+
+        let saga = saga
+            .setup_swap(&proofs2, &output_blinded_messages_2, None, verification2)
+            .await?;
+        let saga = saga.sign_outputs().await?;
+        saga.finalize().await
+    });
+
+    let task3 = tokio::spawn(async move {
+        let db = mint3.localstore();
+        let pubsub = mint3.pubsub_manager();
+        let saga = SwapSaga::new(&*mint3, db, pubsub);
+
+        let saga = saga
+            .setup_swap(&proofs3, &output_blinded_messages_3, None, verification3)
+            .await?;
+        let saga = saga.sign_outputs().await?;
+        saga.finalize().await
+    });
+
+    let results = tokio::try_join!(task1, task2, task3).expect("Tasks should complete");
+
+    let mut success_count = 0;
+    let mut error_count = 0;
+
+    for result in [results.0, results.1, results.2] {
+        match result {
+            Ok(_) => success_count += 1,
+            Err(_) => error_count += 1,
+        }
+    }
+
+    assert_eq!(success_count, 1, "Only one concurrent swap should succeed");
+    assert_eq!(error_count, 2, "Two concurrent swaps should fail");
+
+    let ys = input_proofs.ys().unwrap();
+    let states = mint
+        .localstore()
+        .get_proofs_states(&ys)
+        .await
+        .expect("Failed to get proof states");
+
+    for state in states {
+        assert_eq!(
+            state.unwrap(),
+            State::Spent,
+            "Proofs should be marked as spent after successful swap"
+        );
+    }
+}
+
+/// Tests that compensations execute when finalize() fails during add_blind_signatures.
+///
+/// # What This Tests
+/// - Verify that compensations execute when finalize() fails at signature addition
+/// - Verify that proofs are removed from database (compensation rollback)
+/// - Verify that blinded messages are removed from database
+/// - Verify that signatures are NOT persisted to database
+/// - Transaction rollback + compensation cleanup both occur
+///
+/// # Implementation
+/// Uses TEST_FAIL_ADD_SIGNATURES environment variable to inject failure
+/// at the signature addition step within the finalize transaction.
+///
+/// # Success Criteria
+/// - Finalize fails with error
+/// - Proofs are removed from database after failure
+/// - Blinded messages are removed after failure
+/// - No signatures persisted to database
+#[tokio::test]
+async fn test_swap_saga_compensation_on_finalize_add_signatures_failure() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    // Setup and sign should succeed
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Verify we're in Signed state
+    assert_eq!(
+        saga.state_data.signatures.len(),
+        output_blinded_messages.len()
+    );
+
+    // Enable test failure mode for ADD_SIGNATURES
+    std::env::set_var("TEST_FAIL_ADD_SIGNATURES", "1");
+
+    // Attempt finalize (should fail due to TEST_FAIL_ADD_SIGNATURES)
+    let result = saga.finalize().await;
+
+    // Clean up environment variable immediately
+    std::env::remove_var("TEST_FAIL_ADD_SIGNATURES");
+
+    assert!(result.is_err(), "Finalize should fail");
+
+    // Verify compensation executed - proofs removed
+    let ys = input_proofs.ys().unwrap();
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed by compensation"
+    );
+
+    // Verify signatures were NOT persisted (transaction rolled back)
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+    let signatures = db.get_blind_signatures(&blinded_secrets).await.unwrap();
+    assert!(
+        signatures.iter().all(|s| s.is_none()),
+        "Signatures should not be persisted after rollback"
+    );
+}
+
+/// Tests that compensations execute when finalize() fails during update_proofs_states.
+///
+/// # What This Tests
+/// - Verify that compensations execute when finalize() fails at proof state update
+/// - Verify that proofs are removed from database (compensation rollback)
+/// - Verify that blinded messages are removed from database
+/// - Verify that signatures are NOT persisted to database
+/// - Transaction rollback + compensation cleanup both occur
+///
+/// # Implementation
+/// Uses TEST_FAIL_UPDATE_PROOFS environment variable to inject failure
+/// at the proof state update step within the finalize transaction.
+///
+/// # Success Criteria
+/// - Finalize fails with error
+/// - Proofs are removed from database after failure
+/// - Blinded messages are removed after failure
+/// - No signatures persisted to database
+#[tokio::test]
+async fn test_swap_saga_compensation_on_finalize_update_proofs_failure() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    // Setup and sign should succeed
+    let saga = saga
+        .setup_swap(
+            &input_proofs,
+            &output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Setup should succeed");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Verify we're in Signed state
+    assert_eq!(
+        saga.state_data.signatures.len(),
+        output_blinded_messages.len()
+    );
+
+    // Enable test failure mode for UPDATE_PROOFS
+    std::env::set_var("TEST_FAIL_UPDATE_PROOFS", "1");
+
+    // Attempt finalize (should fail due to TEST_FAIL_UPDATE_PROOFS)
+    let result = saga.finalize().await;
+
+    // Clean up environment variable immediately
+    std::env::remove_var("TEST_FAIL_UPDATE_PROOFS");
+
+    assert!(result.is_err(), "Finalize should fail");
+
+    // Verify compensation executed - proofs removed
+    let ys = input_proofs.ys().unwrap();
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed by compensation"
+    );
+
+    // Verify signatures were NOT persisted (transaction rolled back)
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+    let signatures = db.get_blind_signatures(&blinded_secrets).await.unwrap();
+    assert!(
+        signatures.iter().all(|s| s.is_none()),
+        "Signatures should not be persisted after rollback"
+    );
+}
+
+// ==================== PHASE 1: FOUNDATION TESTS ====================
+// These tests verify the basic saga persistence mechanism.
+
+/// Tests that saga is persisted to the database after setup.
+///
+/// # What This Tests
+/// - Saga is written to database during setup_swap()
+/// - get_saga() can retrieve the persisted state
+/// - State content is correct (operation_id, state, blinded_secrets, input_ys)
+///
+/// # Success Criteria
+/// - Saga exists in database after setup
+/// - State matches SwapSagaState::SetupComplete
+/// - All expected data is present and correct
+#[tokio::test]
+async fn test_saga_state_persistence_after_setup() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = saga.operation.id();
+
+    // Verify saga exists in database
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(operation_id).await.expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result.expect("Saga should exist after setup")
+    };
+
+    // Verify state is SetupComplete
+    use cdk_common::mint::{SagaStateEnum, SwapSagaState};
+    assert_eq!(
+        saga.state,
+        SagaStateEnum::Swap(SwapSagaState::SetupComplete),
+        "Saga should be SetupComplete"
+    );
+
+    // Verify operation_id matches
+    assert_eq!(saga.operation_id, *operation_id);
+
+    // Verify blinded_secrets are stored correctly
+    let expected_blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+    assert_eq!(saga.blinded_secrets.len(), expected_blinded_secrets.len());
+    for bs in &expected_blinded_secrets {
+        assert!(
+            saga.blinded_secrets.contains(bs),
+            "Blinded secret should be in saga"
+        );
+    }
+
+    // Verify input_ys are stored correctly
+    let expected_ys = input_proofs.ys().unwrap();
+    assert_eq!(saga.input_ys.len(), expected_ys.len());
+    for y in &expected_ys {
+        assert!(saga.input_ys.contains(y), "Input Y should be in saga");
+    }
+}
+
+/// Tests that saga is deleted after successful finalization.
+///
+/// # What This Tests
+/// - Saga exists after setup
+/// - Saga still exists after signing
+/// - Saga is DELETED after successful finalize
+/// - get_incomplete_sagas() returns empty after success
+///
+/// # Success Criteria
+/// - Saga deleted from database
+/// - No incomplete sagas remain
+#[tokio::test]
+async fn test_saga_deletion_on_success() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+
+    // Verify saga exists after setup
+    let saga_after_setup = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after_setup.is_some(), "Saga should exist after setup");
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Verify saga still exists after signing
+    let saga_after_sign = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(
+        saga_after_sign.is_some(),
+        "Saga should still exist after signing"
+    );
+
+    let _response = saga.finalize().await.expect("Finalize should succeed");
+
+    // CRITICAL: Verify saga is DELETED after success
+    let saga_after_finalize = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(
+        saga_after_finalize.is_none(),
+        "Saga should be deleted after successful finalization"
+    );
+
+    // Verify no incomplete sagas exist
+    use cdk_common::mint::OperationKind;
+    let incomplete = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete.len(), 0, "No incomplete sagas should exist");
+}
+
+/// Tests querying incomplete sagas.
+///
+/// # What This Tests
+/// - get_incomplete_sagas() returns saga after setup
+/// - get_incomplete_sagas() still returns saga after signing
+/// - get_incomplete_sagas() returns empty after finalize
+/// - Multiple incomplete sagas can be queried
+///
+/// # Success Criteria
+/// - Incomplete saga appears in query results
+/// - Completed saga does not appear in query results
+#[tokio::test]
+async fn test_get_incomplete_sagas_basic() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs_1, verification_1) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let (input_proofs_2, verification_2) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    use cdk_common::mint::OperationKind;
+
+    // Initially no incomplete sagas
+    let incomplete_initial = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_initial.len(), 0);
+
+    let pubsub = mint.pubsub_manager();
+
+    // Setup first saga
+    let saga_1 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+    let saga_1 = saga_1
+        .setup_swap(
+            &input_proofs_1,
+            &output_blinded_messages_1,
+            None,
+            verification_1,
+        )
+        .await
+        .expect("Setup should succeed");
+    let op_id_1 = *saga_1.operation.id();
+
+    // Should have 1 incomplete saga
+    let incomplete_after_1 = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_after_1.len(), 1);
+    assert_eq!(incomplete_after_1[0].operation_id, op_id_1);
+
+    // Setup second saga
+    let saga_2 = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+    let saga_2 = saga_2
+        .setup_swap(
+            &input_proofs_2,
+            &output_blinded_messages_2,
+            None,
+            verification_2,
+        )
+        .await
+        .expect("Setup should succeed");
+    let op_id_2 = *saga_2.operation.id();
+
+    // Should have 2 incomplete sagas
+    let incomplete_after_2 = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_after_2.len(), 2);
+
+    // Finalize first saga
+    let saga_1 = saga_1.sign_outputs().await.expect("Signing should succeed");
+    let _response_1 = saga_1.finalize().await.expect("Finalize should succeed");
+
+    // Should have 1 incomplete saga (second one still incomplete)
+    let incomplete_after_finalize = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_after_finalize.len(), 1);
+    assert_eq!(incomplete_after_finalize[0].operation_id, op_id_2);
+
+    // Finalize second saga
+    let saga_2 = saga_2.sign_outputs().await.expect("Signing should succeed");
+    let _response_2 = saga_2.finalize().await.expect("Finalize should succeed");
+
+    // Should have 0 incomplete sagas
+    let incomplete_final = db
+        .get_incomplete_sagas(OperationKind::Swap)
+        .await
+        .expect("Failed to get incomplete sagas");
+    assert_eq!(incomplete_final.len(), 0);
+}
+
+/// Tests detailed validation of saga content.
+///
+/// # What This Tests
+/// - Operation ID is correct
+/// - Operation kind is correct
+/// - State enum is correct
+/// - Blinded secrets are all present
+/// - Input Ys are all present
+/// - Timestamps are reasonable (created_at, updated_at)
+///
+/// # Success Criteria
+/// - All fields match expected values
+/// - Timestamps are within reasonable range
+#[tokio::test]
+async fn test_saga_content_validation() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let expected_ys: Vec<_> = input_proofs.ys().unwrap();
+    let expected_blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+
+    // Query saga
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result.expect("Saga should exist after setup")
+    };
+
+    // Validate content
+    use cdk_common::mint::{OperationKind, SagaStateEnum, SwapSagaState};
+    assert_eq!(saga.operation_id, operation_id);
+    assert_eq!(saga.operation_kind, OperationKind::Swap);
+    assert_eq!(
+        saga.state,
+        SagaStateEnum::Swap(SwapSagaState::SetupComplete)
+    );
+
+    // Validate blinded secrets
+    assert_eq!(saga.blinded_secrets.len(), expected_blinded_secrets.len());
+    for bs in &expected_blinded_secrets {
+        assert!(saga.blinded_secrets.contains(bs));
+    }
+
+    // Validate input Ys
+    assert_eq!(saga.input_ys.len(), expected_ys.len());
+    for y in &expected_ys {
+        assert!(saga.input_ys.contains(y));
+    }
+
+    // Validate timestamps
+    use cdk_common::util::unix_time;
+    let now = unix_time();
+    assert!(
+        saga.created_at <= now,
+        "created_at should be <= current time"
+    );
+    assert!(
+        saga.updated_at <= now,
+        "updated_at should be <= current time"
+    );
+    assert!(
+        saga.created_at <= saga.updated_at,
+        "created_at should be <= updated_at"
+    );
+}
+
+/// Tests that saga updates are persisted correctly.
+///
+/// # What This Tests
+/// - Saga persisted after setup
+/// - updated_at timestamp changes after state updates
+/// - Other fields remain unchanged during updates
+///
+/// # Note
+/// Currently sign_outputs() does NOT update saga in the database
+/// (the "signed" state is not persisted). This test documents that behavior.
+///
+/// # Success Criteria
+/// - State exists after setup
+/// - If state is updated, updated_at increases
+/// - Other fields remain consistent
+#[tokio::test]
+async fn test_saga_state_updates_persisted() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+
+    // Query saga
+    let state_after_setup = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result.expect("Saga should exist after setup")
+    };
+
+    use cdk_common::mint::{SagaStateEnum, SwapSagaState};
+    assert_eq!(
+        state_after_setup.state,
+        SagaStateEnum::Swap(SwapSagaState::SetupComplete)
+    );
+    let initial_created_at = state_after_setup.created_at;
+    let initial_updated_at = state_after_setup.updated_at;
+
+    // Small delay to ensure timestamp would change if updated
+    tokio::time::sleep(std::time::Duration::from_millis(10)).await;
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Query saga
+    let state_after_sign = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result.expect("Saga should exist after setup")
+    };
+
+    // State should still be SetupComplete (not updated to Signed)
+    assert_eq!(
+        state_after_sign.state,
+        SagaStateEnum::Swap(SwapSagaState::SetupComplete),
+        "Saga remains SetupComplete (signing doesn't update DB)"
+    );
+
+    // Verify other fields unchanged
+    assert_eq!(state_after_sign.operation_id, operation_id);
+    assert_eq!(
+        state_after_sign.blinded_secrets,
+        state_after_setup.blinded_secrets
+    );
+    assert_eq!(state_after_sign.input_ys, state_after_setup.input_ys);
+    assert_eq!(state_after_sign.created_at, initial_created_at);
+
+    // updated_at might not change since state wasn't updated
+    assert_eq!(state_after_sign.updated_at, initial_updated_at);
+
+    // Finalize and verify state is deleted (not updated)
+    let _response = saga.finalize().await.expect("Finalize should succeed");
+
+    // Query saga
+    let state_after_finalize = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+
+        result
+    };
+
+    assert!(
+        state_after_finalize.is_none(),
+        "Saga should be deleted after finalize"
+    );
+}
+
+// ==================== STARTUP RECOVERY TESTS ====================
+// These tests verify the `recover_from_bad_swaps()` startup check that
+// cleans up orphaned swap state when the mint restarts.
+
+/// Tests startup recovery when saga is dropped before signing.
+///
+/// # What This Tests
+/// - Saga dropped after setup (proofs PENDING, no signatures)
+/// - recover_from_bad_swaps() removes the proofs
+/// - Blinded messages are removed
+/// - Same proofs can be used in a new swap after recovery
+///
+/// # Recovery Behavior
+/// When no blind signatures exist for an operation_id:
+/// - Proofs are removed from database
+/// - Blinded messages are removed
+/// - User can retry the swap with same proofs
+///
+/// # Flow
+/// 1. Setup swap (proofs marked PENDING)
+/// 2. Drop saga without signing
+/// 3. Call recover_from_bad_swaps() (simulates mint restart)
+/// 4. Verify proofs removed
+/// 5. Verify can use same proofs in new swap
+///
+/// # Success Criteria
+/// - Recovery removes proofs completely
+/// - Blinded messages removed
+/// - Second swap with same proofs succeeds
+#[tokio::test]
+async fn test_startup_recovery_saga_dropped_before_signing() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let ys = input_proofs.ys().unwrap();
+
+    // Setup swap and drop without signing
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let _saga = saga
+            .setup_swap(
+                &input_proofs,
+                &output_blinded_messages,
+                None,
+                input_verification.clone(),
+            )
+            .await
+            .expect("Setup should succeed");
+
+        // Verify proofs are PENDING
+        let states = db.get_proofs_states(&ys).await.unwrap();
+        assert!(states.iter().all(|s| s == &Some(State::Pending)));
+
+        // Saga dropped here without signing
+    }
+
+    // Proofs still PENDING after drop (no auto-cleanup)
+    let states_before_recovery = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before_recovery
+        .iter()
+        .all(|s| s == &Some(State::Pending)));
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Recovery should succeed");
+    mint.start().await.expect("Recovery should succeed");
+
+    // Verify proofs are REMOVED (not just state cleared)
+    let states_after_recovery = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after_recovery.iter().all(|s| s.is_none()),
+        "Proofs should be removed after recovery (no signatures exist)"
+    );
+
+    // Verify we can now use the same proofs in a new swap
+    let (new_output_blinded_messages, _) =
+        create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let new_saga = SwapSaga::new(&mint, db, pubsub);
+
+    let new_saga = new_saga
+        .setup_swap(
+            &input_proofs,
+            &new_output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Second swap should succeed after recovery");
+
+    let new_saga = new_saga
+        .sign_outputs()
+        .await
+        .expect("Signing should succeed");
+
+    let _response = new_saga.finalize().await.expect("Finalize should succeed");
+
+    // Verify proofs are now SPENT
+    let final_states = mint.localstore().get_proofs_states(&ys).await.unwrap();
+    assert!(final_states.iter().all(|s| s == &Some(State::Spent)));
+}
+
+/// Tests startup recovery when saga is dropped after signing.
+///
+/// # What This Tests
+/// - Saga dropped after signing but before finalize
+/// - Signatures exist in memory but were never persisted to database
+/// - recover_from_bad_swaps() removes the proofs (no signatures in DB)
+/// - Same proofs can be used in a new swap after recovery
+///
+/// # Recovery Behavior
+/// When no blind signatures exist in database for an operation_id:
+/// - Proofs are removed from database
+/// - User can retry the swap
+///
+/// Note: Signatures from sign_outputs() are in memory only. They're only
+/// persisted during finalize(). So a dropped saga after signing has no
+/// signatures in the database.
+///
+/// # Flow
+/// 1. Setup swap and sign outputs
+/// 2. Drop saga without finalize (signatures lost)
+/// 3. Call recover_from_bad_swaps()
+/// 4. Verify proofs removed
+/// 5. Verify can use same proofs in new swap
+///
+/// # Success Criteria
+/// - Recovery removes proofs completely
+/// - No signatures in database (never persisted)
+/// - Second swap with same proofs succeeds
+#[tokio::test]
+async fn test_startup_recovery_saga_dropped_after_signing() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+    let (input_proofs, input_verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let ys = input_proofs.ys().unwrap();
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup swap, sign, and drop without finalize
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(
+                &input_proofs,
+                &output_blinded_messages,
+                None,
+                input_verification.clone(),
+            )
+            .await
+            .expect("Setup should succeed");
+
+        let _saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+        // Saga dropped here - signatures were in memory only, never persisted
+    }
+
+    // Verify proofs still PENDING
+    let states_before = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before.iter().all(|s| s == &Some(State::Pending)));
+
+    // Verify no signatures in database (they were only in memory)
+    let sigs_before = db.get_blind_signatures(&blinded_secrets).await.unwrap();
+    assert!(sigs_before.iter().all(|s| s.is_none()));
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Recovery should succeed");
+    mint.start().await.expect("Recovery should succeed");
+
+    // Verify proofs are REMOVED
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed (no signatures in DB)"
+    );
+
+    // Verify we can use the same proofs in a new swap
+    let (new_output_blinded_messages, _) =
+        create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let new_saga = SwapSaga::new(&mint, db, pubsub);
+
+    let new_saga = new_saga
+        .setup_swap(
+            &input_proofs,
+            &new_output_blinded_messages,
+            None,
+            input_verification,
+        )
+        .await
+        .expect("Second swap should succeed after recovery");
+
+    let new_saga = new_saga
+        .sign_outputs()
+        .await
+        .expect("Signing should succeed");
+
+    let _response = new_saga.finalize().await.expect("Finalize should succeed");
+}
+
+/// Tests startup recovery with multiple abandoned operations.
+///
+/// # What This Tests
+/// - Multiple swap operations in different states
+/// - recover_from_bad_swaps() processes all operations correctly
+/// - Each operation is handled according to its state
+///
+/// # Test Scenario
+/// - Operation A: Dropped after setup (no signatures) → proofs removed
+/// - Operation B: Dropped after signing (signatures not persisted) → proofs removed
+/// - Operation C: Completed successfully (has signatures, SPENT) → untouched
+///
+/// # Success Criteria
+/// - Operation A proofs removed
+/// - Operation B proofs removed
+/// - Operation C proofs remain SPENT
+/// - All operations processed in single recovery call
+#[tokio::test]
+async fn test_startup_recovery_multiple_operations() {
+    let mint = create_test_mint().await.unwrap();
+    let amount = Amount::from(100);
+
+    // Create three separate sets of proofs for three operations
+    let (proofs_a, verification_a) = create_swap_inputs(&mint, amount).await;
+    let (proofs_b, verification_b) = create_swap_inputs(&mint, amount).await;
+    let (proofs_c, verification_c) = create_swap_inputs(&mint, amount).await;
+
+    let (outputs_a, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_b, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_c, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+    let pubsub = mint.pubsub_manager();
+
+    let ys_a = proofs_a.ys().unwrap();
+    let ys_b = proofs_b.ys().unwrap();
+    let ys_c = proofs_c.ys().unwrap();
+
+    // Operation A: Setup only (dropped before signing)
+    {
+        let saga_a = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+        let _saga_a = saga_a
+            .setup_swap(&proofs_a, &outputs_a, None, verification_a)
+            .await
+            .expect("Operation A setup should succeed");
+        // Dropped without signing
+    }
+
+    // Operation B: Setup + Sign (dropped before finalize)
+    {
+        let saga_b = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+        let saga_b = saga_b
+            .setup_swap(&proofs_b, &outputs_b, None, verification_b)
+            .await
+            .expect("Operation B setup should succeed");
+        let _saga_b = saga_b
+            .sign_outputs()
+            .await
+            .expect("Operation B signing should succeed");
+        // Dropped without finalize
+    }
+
+    // Operation C: Complete successfully
+    {
+        let saga_c = SwapSaga::new(&mint, db.clone(), pubsub.clone());
+        let saga_c = saga_c
+            .setup_swap(&proofs_c, &outputs_c, None, verification_c)
+            .await
+            .expect("Operation C setup should succeed");
+        let saga_c = saga_c
+            .sign_outputs()
+            .await
+            .expect("Operation C signing should succeed");
+        let _response = saga_c
+            .finalize()
+            .await
+            .expect("Operation C finalize should succeed");
+    }
+
+    // Verify states before recovery
+    let states_a_before = db.get_proofs_states(&ys_a).await.unwrap();
+    let states_b_before = db.get_proofs_states(&ys_b).await.unwrap();
+    let states_c_before = db.get_proofs_states(&ys_c).await.unwrap();
+
+    assert!(states_a_before.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_b_before.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_c_before.iter().all(|s| s == &Some(State::Spent)));
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Recovery should succeed");
+    mint.start().await.expect("Recovery should succeed");
+
+    // Verify states after recovery
+    let states_a_after = db.get_proofs_states(&ys_a).await.unwrap();
+    let states_b_after = db.get_proofs_states(&ys_b).await.unwrap();
+    let states_c_after = db.get_proofs_states(&ys_c).await.unwrap();
+
+    assert!(
+        states_a_after.iter().all(|s| s.is_none()),
+        "Operation A proofs should be removed (no signatures)"
+    );
+    assert!(
+        states_b_after.iter().all(|s| s.is_none()),
+        "Operation B proofs should be removed (no signatures in DB)"
+    );
+    assert!(
+        states_c_after.iter().all(|s| s == &Some(State::Spent)),
+        "Operation C proofs should remain SPENT (completed successfully)"
+    );
+}
+
+/// Tests startup recovery with operation ID uniqueness and tracking.
+///
+/// # What This Tests
+/// - Multiple concurrent swaps get unique operation_ids
+/// - Proofs are correctly associated with their operation_ids
+/// - Recovery can distinguish between different operations
+/// - Each operation is tracked independently
+///
+/// # Flow
+/// 1. Create multiple swaps concurrently
+/// 2. Drop all sagas without finalize
+/// 3. Verify proofs are associated with different operations
+/// 4. Run recovery
+/// 5. Verify all operations cleaned up correctly
+///
+/// # Success Criteria
+/// - Each swap has unique operation_id
+/// - Proofs correctly tracked per operation
+/// - Recovery processes each operation independently
+/// - All proofs removed after recovery
+#[tokio::test]
+async fn test_operation_id_uniqueness_and_tracking() {
+    let mint = Arc::new(create_test_mint().await.unwrap());
+    let amount = Amount::from(100);
+
+    // Create three separate sets of proofs
+    let (proofs_1, verification_1) = create_swap_inputs(&mint, amount).await;
+    let (proofs_2, verification_2) = create_swap_inputs(&mint, amount).await;
+    let (proofs_3, verification_3) = create_swap_inputs(&mint, amount).await;
+
+    let (outputs_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_2, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_3, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let db = mint.localstore();
+
+    let ys_1 = proofs_1.ys().unwrap();
+    let ys_2 = proofs_2.ys().unwrap();
+    let ys_3 = proofs_3.ys().unwrap();
+
+    // Create all three swaps and drop without finalize
+    {
+        let pubsub = mint.pubsub_manager();
+
+        let saga_1 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let _saga_1 = saga_1
+            .setup_swap(&proofs_1, &outputs_1, None, verification_1)
+            .await
+            .expect("Swap 1 setup should succeed");
+
+        let saga_2 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let _saga_2 = saga_2
+            .setup_swap(&proofs_2, &outputs_2, None, verification_2)
+            .await
+            .expect("Swap 2 setup should succeed");
+
+        let saga_3 = SwapSaga::new(&*mint, db.clone(), pubsub.clone());
+        let _saga_3 = saga_3
+            .setup_swap(&proofs_3, &outputs_3, None, verification_3)
+            .await
+            .expect("Swap 3 setup should succeed");
+
+        // All sagas dropped without finalize
+    }
+
+    // Verify all proofs are PENDING
+    let states_1 = db.get_proofs_states(&ys_1).await.unwrap();
+    let states_2 = db.get_proofs_states(&ys_2).await.unwrap();
+    let states_3 = db.get_proofs_states(&ys_3).await.unwrap();
+
+    assert!(states_1.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_2.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_3.iter().all(|s| s == &Some(State::Pending)));
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Recovery should succeed");
+    mint.start().await.expect("Recovery should succeed");
+
+    // Verify all proofs removed
+    let states_1_after = db.get_proofs_states(&ys_1).await.unwrap();
+    let states_2_after = db.get_proofs_states(&ys_2).await.unwrap();
+    let states_3_after = db.get_proofs_states(&ys_3).await.unwrap();
+
+    assert!(
+        states_1_after.iter().all(|s| s.is_none()),
+        "Swap 1 proofs should be removed"
+    );
+    assert!(
+        states_2_after.iter().all(|s| s.is_none()),
+        "Swap 2 proofs should be removed"
+    );
+    assert!(
+        states_3_after.iter().all(|s| s.is_none()),
+        "Swap 3 proofs should be removed"
+    );
+
+    // Verify each set of proofs can now be used in new swaps
+    let (new_outputs_1, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let verification = create_verification(amount);
+
+    let pubsub = mint.pubsub_manager();
+    let new_saga = SwapSaga::new(&*mint, db, pubsub);
+
+    let result = new_saga
+        .setup_swap(&proofs_1, &new_outputs_1, None, verification)
+        .await;
+
+    assert!(
+        result.is_ok(),
+        "Should be able to reuse proofs after recovery"
+    );
+}
+
+// ==================== PHASE 2: CRASH RECOVERY TESTS ====================
+// These tests verify crash recovery using saga persistence.
+
+/// Tests crash recovery without calling compensate_all().
+///
+/// # What This Tests
+/// - Saga dropped WITHOUT calling compensate_all() (simulates process crash)
+/// - Saga persists in database after crash
+/// - Proofs remain PENDING after crash (not cleaned up)
+/// - Recovery mechanism finds incomplete saga via get_incomplete_sagas()
+/// - Recovery cleans up orphaned state (proofs, blinded messages, saga)
+///
+/// # This Is The PRIMARY USE CASE for Saga Persistence
+/// The in-memory compensation mechanism only works if the process stays alive.
+/// When the process crashes, we lose in-memory compensations and must rely
+/// on persisted saga to recover.
+///
+/// # Success Criteria
+/// - Saga exists after crash
+/// - Proofs are PENDING after crash (compensation didn't run)
+/// - Recovery removes proofs
+/// - Recovery removes blinded messages
+/// - Recovery deletes saga
+#[tokio::test]
+async fn test_crash_recovery_without_compensation() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let operation_id;
+    let ys = input_proofs.ys().unwrap();
+    let _blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Simulate crash: setup swap, then drop WITHOUT calling compensate_all()
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        operation_id = *saga.operation.id();
+
+        // CRITICAL: Drop saga WITHOUT calling compensate_all()
+        // This simulates a crash where in-memory compensations are lost
+        drop(saga);
+    }
+
+    // Verify saga still exists in database (persisted during setup)
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga.is_some(), "Saga should persist after crash");
+
+    // Verify proofs are still Pending (compensation didn't run)
+    let states = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states.iter().all(|s| s == &Some(State::Pending)),
+        "Proofs should still be Pending after crash (compensation didn't run)"
+    );
+
+    // Note: We cannot directly verify blinded messages exist (no query method)
+    // but the recovery process will delete them along with proofs
+
+    // Simulate mint restart - run recovery
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify recovery cleaned up:
+    // 1. Proofs removed from database
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Recovery should remove proofs"
+    );
+
+    // 2. Blinded messages removed (implicitly - no query method available)
+
+    // 3. Saga deleted
+    let saga_after = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx
+            .get_saga(&operation_id)
+            .await
+            .expect("Failed to get saga");
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after.is_none(), "Recovery should delete saga");
+}
+
+/// Tests crash recovery after setup only (before signing).
+///
+/// # What This Tests
+/// - Saga in SetupComplete state when crashed
+/// - No signatures exist in database
+/// - Recovery removes all swap state
+///
+/// # Success Criteria
+/// - Saga exists before recovery
+/// - Proofs are Pending before recovery
+/// - Everything cleaned up after recovery
+#[tokio::test]
+async fn test_crash_recovery_after_setup_only() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let operation_id;
+    let ys = input_proofs.ys().unwrap();
+    let _blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup and crash
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        operation_id = *saga.operation.id();
+
+        // Verify saga was persisted
+        let saga = {
+            let mut tx = db.begin_transaction().await.unwrap();
+            let result = tx.get_saga(&operation_id).await.unwrap();
+            tx.commit().await.unwrap();
+            result
+        };
+        assert!(saga.is_some());
+
+        // Drop without compensation (crash)
+        drop(saga);
+    }
+
+    // Verify state before recovery
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    let states_before = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before.iter().all(|s| s == &Some(State::Pending)));
+
+    // Run recovery
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify cleanup
+    let saga_after = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after.is_none(), "Saga should be deleted");
+
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+
+    // Blinded messages also removed by recovery (no query method to verify)
+}
+
+/// Tests crash recovery after signing (before finalize).
+///
+/// # What This Tests
+/// - Saga crashed after sign_outputs() but before finalize()
+/// - Signatures were in memory only (never persisted)
+/// - Recovery treats this the same as crashed after setup
+/// - All state is cleaned up
+///
+/// # Success Criteria
+/// - Saga exists before recovery
+/// - No signatures in database (never persisted)
+/// - Everything cleaned up after recovery
+#[tokio::test]
+async fn test_crash_recovery_after_signing() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let operation_id;
+    let ys = input_proofs.ys().unwrap();
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup, sign, and crash
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        operation_id = *saga.operation.id();
+
+        let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+        // Verify we have signatures in memory
+        assert_eq!(
+            saga.state_data.signatures.len(),
+            output_blinded_messages.len()
+        );
+
+        // Drop without finalize (crash) - signatures lost
+        drop(saga);
+    }
+
+    // Verify state before recovery
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    // Verify no signatures in database (they were in memory only)
+    let sigs_before = db.get_blind_signatures(&blinded_secrets).await.unwrap();
+    assert!(
+        sigs_before.iter().all(|s| s.is_none()),
+        "Signatures should not be in DB (never persisted)"
+    );
+
+    // Run recovery
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify cleanup
+    let saga_after = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after.is_none(), "Saga should be deleted");
+
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+
+    // Blinded messages also removed by recovery (no query method to verify)
+}
+
+/// Tests recovery with multiple incomplete sagas in different states.
+///
+/// # What This Tests
+/// - Multiple sagas can be incomplete simultaneously
+/// - Recovery processes all incomplete sagas
+/// - Each saga is handled correctly based on its state
+///
+/// # Test Scenario
+/// - Saga A: Setup only (incomplete)
+/// - Saga B: Setup + Sign (incomplete, signatures lost)
+/// - Saga C: Completed (should NOT be affected by recovery)
+///
+/// # Success Criteria
+/// - Saga A cleaned up
+/// - Saga B cleaned up
+/// - Saga C unaffected
+#[tokio::test]
+async fn test_recovery_multiple_incomplete_sagas() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+
+    // Create three sets of inputs/outputs
+    let (proofs_a, verification_a) = create_swap_inputs(&mint, amount).await;
+    let (proofs_b, verification_b) = create_swap_inputs(&mint, amount).await;
+    let (proofs_c, verification_c) = create_swap_inputs(&mint, amount).await;
+
+    let (outputs_a, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_b, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+    let (outputs_c, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let ys_a = proofs_a.ys().unwrap();
+    let ys_b = proofs_b.ys().unwrap();
+    let ys_c = proofs_c.ys().unwrap();
+
+    let op_id_a;
+    let op_id_b;
+    let op_id_c;
+
+    // Saga A: Setup only, then crash
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+        let saga = saga
+            .setup_swap(&proofs_a, &outputs_a, None, verification_a)
+            .await
+            .expect("Setup A should succeed");
+        op_id_a = *saga.operation.id();
+        drop(saga);
+    }
+
+    // Saga B: Setup + Sign, then crash
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+        let saga = saga
+            .setup_swap(&proofs_b, &outputs_b, None, verification_b)
+            .await
+            .expect("Setup B should succeed");
+        op_id_b = *saga.operation.id();
+        let saga = saga.sign_outputs().await.expect("Sign B should succeed");
+        drop(saga);
+    }
+
+    // Saga C: Complete successfully
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+        let saga = saga
+            .setup_swap(&proofs_c, &outputs_c, None, verification_c)
+            .await
+            .expect("Setup C should succeed");
+        op_id_c = *saga.operation.id();
+        let saga = saga.sign_outputs().await.expect("Sign C should succeed");
+        let _response = saga.finalize().await.expect("Finalize C should succeed");
+    }
+
+    // Verify state before recovery
+    use cdk_common::mint::OperationKind;
+    let incomplete_before = db.get_incomplete_sagas(OperationKind::Swap).await.unwrap();
+    assert_eq!(
+        incomplete_before.len(),
+        2,
+        "Should have 2 incomplete sagas (A and B)"
+    );
+
+    let states_a_before = db.get_proofs_states(&ys_a).await.unwrap();
+    let states_b_before = db.get_proofs_states(&ys_b).await.unwrap();
+    let states_c_before = db.get_proofs_states(&ys_c).await.unwrap();
+
+    assert!(states_a_before.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_b_before.iter().all(|s| s == &Some(State::Pending)));
+    assert!(states_c_before.iter().all(|s| s == &Some(State::Spent)));
+
+    // Run recovery
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify cleanup
+    let incomplete_after = db.get_incomplete_sagas(OperationKind::Swap).await.unwrap();
+    assert_eq!(
+        incomplete_after.len(),
+        0,
+        "No incomplete sagas after recovery"
+    );
+
+    // Saga A cleaned up
+    let saga_a = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&op_id_a).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_a.is_none());
+    let states_a_after = db.get_proofs_states(&ys_a).await.unwrap();
+    assert!(states_a_after.iter().all(|s| s.is_none()));
+
+    // Saga B cleaned up
+    let saga_b = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&op_id_b).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_b.is_none());
+    let states_b_after = db.get_proofs_states(&ys_b).await.unwrap();
+    assert!(states_b_after.iter().all(|s| s.is_none()));
+
+    // Saga C unaffected (still spent, saga was already deleted)
+    let saga_c = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&op_id_c).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_c.is_none(), "Completed saga was deleted");
+    let states_c_after = db.get_proofs_states(&ys_c).await.unwrap();
+    assert!(
+        states_c_after.iter().all(|s| s == &Some(State::Spent)),
+        "Completed saga proofs remain spent"
+    );
+}
+
+/// Tests that recovery is idempotent (can be run multiple times safely).
+///
+/// # What This Tests
+/// - Recovery can be run multiple times without errors
+/// - Second recovery run is a no-op
+/// - State remains consistent after multiple recoveries
+///
+/// # Success Criteria
+/// - First recovery cleans up incomplete saga
+/// - Second recovery succeeds (no incomplete sagas to process)
+/// - State is consistent after both runs
+#[tokio::test]
+async fn test_recovery_idempotence() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let operation_id;
+    let ys = input_proofs.ys().unwrap();
+
+    // Create incomplete saga
+    {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+        operation_id = *saga.operation.id();
+        drop(saga);
+    }
+
+    // Verify incomplete saga exists
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    // First recovery
+    mint.stop().await.expect("First stop should succeed");
+    mint.start().await.expect("First start should succeed");
+
+    // Verify cleanup
+    let saga_after_1 = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after_1.is_none());
+    let states_after_1 = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_after_1.iter().all(|s| s.is_none()));
+
+    // Second recovery (should be idempotent - no work to do)
+    mint.stop().await.expect("Second stop should succeed");
+    mint.start().await.expect("Second start should succeed");
+
+    // Verify state unchanged
+    let saga_after_2 = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after_2.is_none());
+    let states_after_2 = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_after_2.iter().all(|s| s.is_none()));
+
+    // Third recovery for good measure
+    mint.stop().await.expect("Third stop should succeed");
+    mint.start().await.expect("Third start should succeed");
+
+    let saga_after_3 = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after_3.is_none());
+}
+
+// ==================== PHASE 3: EDGE CASE TESTS ====================
+// These tests verify edge cases and error handling scenarios.
+
+/// Tests cleanup of orphaned saga (saga deletion fails but swap succeeds).
+///
+/// # What This Tests
+/// - Swap completes successfully (proofs marked SPENT)
+/// - Saga deletion fails (simulated by test hook)
+/// - Swap still succeeds (best-effort deletion)
+/// - Saga remains orphaned in database
+/// - Recovery detects orphaned saga (proofs already SPENT)
+/// - Recovery deletes orphaned saga
+///
+/// # Why This Matters
+/// According to the implementation, saga deletion is best-effort. If it fails,
+/// the swap should still succeed. The orphaned saga will be cleaned up
+/// on next recovery.
+///
+/// # Success Criteria
+/// - Swap succeeds despite deletion failure
+/// - Proofs are SPENT after swap
+/// - Saga remains after swap (orphaned)
+/// - Recovery cleans up orphaned saga
+#[tokio::test]
+async fn test_orphaned_saga_cleanup() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+    let ys = input_proofs.ys().unwrap();
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // Note: We cannot easily inject a failure for saga deletion within finalize
+    // because the deletion happens inside a database transaction and uses the
+    // transaction trait. For now, we'll test the recovery side: create a saga
+    // that completes, then manually verify recovery can handle scenarios where
+    // saga exists but proofs are already SPENT.
+
+    let _response = saga.finalize().await.expect("Finalize should succeed");
+
+    // Verify swap succeeded (proofs SPENT)
+    let states = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states.iter().all(|s| s == &Some(State::Spent)),
+        "Proofs should be SPENT after successful swap"
+    );
+
+    // In a real scenario with deletion failure, saga would remain.
+    // For this test, we'll verify that saga is properly deleted.
+    // TODO: Add failure injection for delete_saga to properly test this.
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(
+        saga.is_none(),
+        "Saga should be deleted after successful swap"
+    );
+
+    // If we had a way to inject deletion failure, we would:
+    // 1. Verify saga remains (orphaned)
+    // 2. Run recovery
+    // 3. Verify recovery detects proofs are SPENT
+    // 4. Verify recovery deletes orphaned saga
+}
+
+/// Tests recovery with orphaned proofs (proofs without corresponding saga).
+///
+/// # What This Tests
+/// - Proofs exist in database without saga
+/// - Recovery handles this gracefully (no crash)
+/// - Proofs remain in their current state
+///
+/// # Scenario
+/// This could happen if:
+/// - Manual database intervention removed saga but not proofs
+/// - A bug caused saga deletion without proof cleanup
+/// - Database corruption
+///
+/// # Success Criteria
+/// - Recovery runs without errors
+/// - Proofs remain in database (recovery doesn't remove them without saga)
+/// - No crashes or panics
+#[tokio::test]
+async fn test_recovery_with_orphaned_proofs() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let ys = input_proofs.ys().unwrap();
+
+    // Setup saga to get proofs into PENDING state
+    let operation_id = {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        let op_id = *saga.operation.id();
+
+        // Drop saga (crash simulation)
+        drop(saga);
+
+        op_id
+    };
+
+    // Verify proofs are PENDING and saga exists
+    let states_before = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before.iter().all(|s| s == &Some(State::Pending)));
+
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    // Manually delete saga (simulating orphaned proofs scenario)
+    {
+        let mut tx = db.begin_transaction().await.unwrap();
+        tx.delete_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+    }
+
+    // Verify saga is gone but proofs remain
+    let saga_after_delete = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after_delete.is_none(), "Saga should be deleted");
+
+    let states_after_delete = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after_delete
+            .iter()
+            .all(|s| s == &Some(State::Pending)),
+        "Proofs should still be PENDING (orphaned)"
+    );
+
+    // Run recovery - should handle gracefully
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify recovery completed without errors
+    // Orphaned PENDING proofs without saga should remain (not cleaned up)
+    // This is by design - recovery only acts on incomplete sagas, not orphaned proofs
+    let states_after_recovery = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after_recovery
+            .iter()
+            .all(|s| s == &Some(State::Pending)),
+        "Orphaned proofs remain PENDING (recovery doesn't clean up proofs without saga)"
+    );
+
+    // Note: In production, a separate cleanup mechanism (e.g., timeout-based)
+    // would be needed to handle such orphaned resources. Saga recovery only
+    // processes incomplete sagas that have saga.
+}
+
+/// Tests recovery with partial state (missing blinded messages).
+///
+/// # What This Tests
+/// - Saga exists
+/// - Proofs exist
+/// - Blinded messages are missing (deleted manually)
+/// - Recovery handles this gracefully
+///
+/// # Scenario
+/// This could occur due to:
+/// - Partial transaction commit (unlikely with proper atomicity)
+/// - Manual database intervention
+/// - Database corruption
+///
+/// # Success Criteria
+/// - Recovery runs without errors
+/// - Saga is cleaned up
+/// - Proofs are removed
+/// - No crashes due to missing blinded messages
+#[tokio::test]
+async fn test_recovery_with_partial_state() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let ys = input_proofs.ys().unwrap();
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup saga
+    let operation_id = {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        let op_id = *saga.operation.id();
+
+        // Drop saga (crash simulation)
+        drop(saga);
+
+        op_id
+    };
+
+    // Verify setup
+    let saga_before = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_before.is_some());
+
+    let states_before = db.get_proofs_states(&ys).await.unwrap();
+    assert!(states_before.iter().all(|s| s == &Some(State::Pending)));
+
+    // Manually delete blinded messages (simulating partial state)
+    {
+        let mut tx = db.begin_transaction().await.unwrap();
+        tx.delete_blinded_messages(&blinded_secrets).await.unwrap();
+        tx.commit().await.unwrap();
+    }
+
+    // Verify blinded messages are gone but saga and proofs remain
+    // (Note: We can't directly query blinded messages to verify they're gone,
+    // but the recovery mechanism will attempt to delete them regardless)
+
+    // Run recovery - should handle missing blinded messages gracefully
+    mint.stop().await.expect("Stop should succeed");
+    mint.start().await.expect("Start should succeed");
+
+    // Verify recovery completed successfully
+    let saga_after = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after.is_none(), "Saga should be deleted");
+
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+
+    // Recovery should succeed even if blinded messages were already gone
+}
+
+/// Tests recovery when blinded messages are missing (but proofs and saga exist).
+///
+/// # What This Tests
+/// - Saga exists with blinded_secrets
+/// - Proofs exist and are PENDING
+/// - Blinded messages themselves are missing from database
+/// - Recovery completes without errors
+/// - Saga is cleaned up
+/// - Proofs are removed
+///
+/// # Success Criteria
+/// - No errors when trying to delete missing blinded messages
+/// - Recovery completes successfully
+/// - All saga cleaned up
+#[tokio::test]
+async fn test_recovery_with_missing_blinded_messages() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let ys = input_proofs.ys().unwrap();
+    let blinded_secrets: Vec<_> = output_blinded_messages
+        .iter()
+        .map(|bm| bm.blinded_secret)
+        .collect();
+
+    // Setup saga and crash
+    let operation_id = {
+        let pubsub = mint.pubsub_manager();
+        let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+        let saga = saga
+            .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+            .await
+            .expect("Setup should succeed");
+
+        let op_id = *saga.operation.id();
+        drop(saga); // Crash
+
+        op_id
+    };
+
+    // Verify initial state
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga.is_some(), "Saga should exist");
+
+    // Manually delete blinded messages before recovery
+    {
+        let mut tx = db.begin_transaction().await.unwrap();
+        tx.delete_blinded_messages(&blinded_secrets).await.unwrap();
+        tx.commit().await.unwrap();
+    }
+
+    // Run recovery - should handle missing blinded messages gracefully
+    mint.stop().await.expect("Stop should succeed");
+    mint.start()
+        .await
+        .expect("Start should succeed despite missing blinded messages");
+
+    // Verify cleanup
+    let saga_after = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga_after.is_none(), "Saga should be cleaned up");
+
+    let states_after = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states_after.iter().all(|s| s.is_none()),
+        "Proofs should be removed"
+    );
+}
+
+/// Tests that saga deletion failure is handled gracefully during finalize.
+///
+/// # What This Tests
+/// - Swap completes successfully through finalize
+/// - Even if saga deletion fails internally, swap succeeds
+/// - Best-effort saga deletion doesn't fail the swap
+///
+/// # Note
+/// This test verifies the design decision that saga deletion is best-effort.
+/// Currently we cannot easily inject deletion failures, so this test documents
+/// the expected behavior and verifies normal deletion.
+///
+/// # Success Criteria
+/// - Swap completes successfully
+/// - Saga is deleted (in normal case)
+/// - If deletion fails (not testable yet), swap still succeeds
+#[tokio::test]
+async fn test_saga_deletion_failure_handling() {
+    let mint = create_test_mint().await.unwrap();
+    let db = mint.localstore();
+
+    let amount = Amount::from(100);
+    let (input_proofs, verification) = create_swap_inputs(&mint, amount).await;
+    let (output_blinded_messages, _) = create_test_blinded_messages(&mint, amount).await.unwrap();
+
+    let pubsub = mint.pubsub_manager();
+    let saga = SwapSaga::new(&mint, db.clone(), pubsub);
+
+    let saga = saga
+        .setup_swap(&input_proofs, &output_blinded_messages, None, verification)
+        .await
+        .expect("Setup should succeed");
+
+    let operation_id = *saga.operation.id();
+    let ys = input_proofs.ys().unwrap();
+
+    let saga = saga.sign_outputs().await.expect("Signing should succeed");
+
+    // In normal operation, deletion succeeds
+    let response = saga.finalize().await.expect("Finalize should succeed");
+
+    // Verify swap succeeded
+    assert_eq!(
+        response.signatures.len(),
+        output_blinded_messages.len(),
+        "Should have signatures for all outputs"
+    );
+
+    let states = db.get_proofs_states(&ys).await.unwrap();
+    assert!(
+        states.iter().all(|s| s == &Some(State::Spent)),
+        "Proofs should be SPENT"
+    );
+
+    // Verify saga is deleted
+    let saga = {
+        let mut tx = db.begin_transaction().await.unwrap();
+        let result = tx.get_saga(&operation_id).await.unwrap();
+        tx.commit().await.unwrap();
+        result
+    };
+    assert!(saga.is_none(), "Saga should be deleted");
+
+    // TODO: Add test failure injection for delete_saga to verify that:
+    // 1. Swap still succeeds even if deletion fails
+    // 2. Orphaned saga remains
+    // 3. Recovery can clean it up later
+    //
+    // This would require adding a TEST_FAIL_DELETE_SAGA env var check in the
+    // database implementation's delete_saga method.
+}

+ 218 - 0
crates/cdk/src/test_helpers/mint.rs

@@ -0,0 +1,218 @@
+#![cfg(test)]
+//! Test helpers for creating test mints and related utilities
+
+use std::collections::{HashMap, HashSet};
+use std::str::FromStr;
+use std::sync::Arc;
+use std::time::Duration;
+
+use bip39::Mnemonic;
+use cdk_common::amount::SplitTarget;
+use cdk_common::dhke::construct_proofs;
+use cdk_common::nuts::{BlindedMessage, CurrencyUnit, Id, PaymentMethod, PreMintSecrets, Proofs};
+use cdk_common::{
+    Amount, MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteState, MintRequest,
+};
+use cdk_fake_wallet::FakeWallet;
+use tokio::time::sleep;
+
+use crate::mint::{Mint, MintBuilder, MintMeltLimits};
+use crate::types::{FeeReserve, QuoteTTL};
+use crate::Error;
+
+#[cfg(test)]
+pub(crate) fn should_fail_in_test() -> bool {
+    // Some condition that determines when to fail in tests
+    std::env::var("TEST_FAIL").is_ok()
+}
+
+#[cfg(test)]
+pub(crate) fn should_fail_for(operation: &str) -> bool {
+    // Check for specific failure modes using environment variables
+    // Format: TEST_FAIL_<OPERATION>
+    let var_name = format!("TEST_FAIL_{}", operation);
+    std::env::var(&var_name).is_ok()
+}
+
+/// Creates and starts a test mint with in-memory storage and a fake Lightning backend.
+///
+/// This mint can be used for unit tests without requiring external dependencies
+/// like Lightning nodes or persistent databases.
+///
+/// # Example
+///
+/// ```
+/// use cdk::test_helpers::mint::create_test_mint;
+///
+/// #[tokio::test]
+/// async fn test_something() {
+///     let mint = create_test_mint().await.unwrap();
+///     // Use the mint for testing
+/// }
+/// ```
+pub async fn create_test_mint() -> Result<Mint, Error> {
+    let db = Arc::new(cdk_sqlite::mint::memory::empty().await?);
+
+    let mut mint_builder = MintBuilder::new(db.clone());
+
+    let fee_reserve = FeeReserve {
+        min_fee_reserve: 1.into(),
+        percent_fee_reserve: 1.0,
+    };
+
+    let ln_fake_backend = FakeWallet::new(
+        fee_reserve.clone(),
+        HashMap::default(),
+        HashSet::default(),
+        2,
+        CurrencyUnit::Sat,
+    );
+
+    mint_builder
+        .add_payment_processor(
+            CurrencyUnit::Sat,
+            PaymentMethod::Bolt11,
+            MintMeltLimits::new(1, 10_000),
+            Arc::new(ln_fake_backend),
+        )
+        .await?;
+
+    let mnemonic = Mnemonic::generate(12).map_err(|e| Error::Custom(e.to_string()))?;
+
+    mint_builder = mint_builder
+        .with_name("test mint".to_string())
+        .with_description("test mint for unit tests".to_string())
+        .with_urls(vec!["https://test-mint".to_string()]);
+
+    let quote_ttl = QuoteTTL::new(10000, 10000);
+
+    let mint = mint_builder
+        .build_with_seed(db.clone(), &mnemonic.to_seed_normalized(""))
+        .await?;
+
+    mint.set_quote_ttl(quote_ttl).await?;
+
+    mint.start().await?;
+
+    Ok(mint)
+}
+
+/// Creates test proofs by performing a mock mint operation.
+///
+/// This helper creates valid proofs for the given amount by:
+/// 1. Creating blinded messages
+/// 2. Performing a swap to get signatures
+/// 3. Constructing valid proofs from the signatures
+///
+/// # Arguments
+///
+/// * `mint` - The test mint to use for creating proofs
+/// * `amount` - The total amount to create proofs for
+pub async fn mint_test_proofs(mint: &Mint, amount: Amount) -> Result<Proofs, Error> {
+    // Just use fund_mint_with_proofs which creates proofs via swap
+    let mint_quote: MintQuoteBolt11Response<_> = mint
+        .get_mint_quote(
+            MintQuoteBolt11Request {
+                amount,
+                unit: CurrencyUnit::Sat,
+                description: None,
+                pubkey: None,
+            }
+            .into(),
+        )
+        .await?
+        .into();
+
+    loop {
+        let check: MintQuoteBolt11Response<_> = mint
+            .check_mint_quote(&cdk_common::QuoteId::from_str(&mint_quote.quote).unwrap())
+            .await
+            .unwrap()
+            .into();
+
+        if check.state == MintQuoteState::Paid {
+            break;
+        }
+
+        sleep(Duration::from_secs(1)).await;
+    }
+
+    let keysets = mint
+        .get_active_keysets()
+        .get(&CurrencyUnit::Sat)
+        .unwrap()
+        .clone();
+
+    let keys = mint
+        .keyset_pubkeys(&keysets)?
+        .keysets
+        .first()
+        .unwrap()
+        .keys
+        .clone();
+
+    let fees: (u64, Vec<u64>) = (
+        0,
+        keys.iter().map(|a| a.0.to_u64()).collect::<Vec<_>>().into(),
+    );
+
+    let premint_secrets =
+        PreMintSecrets::random(keysets, amount, &SplitTarget::None, &fees.into()).unwrap();
+
+    let request = MintRequest {
+        quote: mint_quote.quote,
+        outputs: premint_secrets.blinded_messages(),
+        signature: None,
+    };
+
+    let mint_res = mint
+        .process_mint_request(request.try_into().unwrap())
+        .await?;
+
+    Ok(construct_proofs(
+        mint_res.signatures,
+        premint_secrets.rs(),
+        premint_secrets.secrets(),
+        &keys,
+    )?)
+}
+
+/// Creates test blinded messages for the given amount.
+///
+/// This is useful for testing operations that require blinded messages as input.
+///
+/// # Arguments
+///
+/// * `mint` - The test mint (used to get the active keyset)
+/// * `amount` - The total amount to create blinded messages for
+///
+/// # Returns
+///
+/// A tuple containing:
+/// - Vector of blinded messages
+/// - PreMintSecrets (needed to construct proofs later)
+pub async fn create_test_blinded_messages(
+    mint: &Mint,
+    amount: Amount,
+) -> Result<(Vec<BlindedMessage>, PreMintSecrets), Error> {
+    let keyset_id = get_active_keyset_id(mint).await?;
+    let split_target = SplitTarget::default();
+    let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::<Vec<_>>())).into();
+
+    let pre_mint = PreMintSecrets::random(keyset_id, amount, &split_target, &fee_and_amounts)?;
+    let blinded_messages = pre_mint.blinded_messages().to_vec();
+
+    Ok((blinded_messages, pre_mint))
+}
+
+/// Gets the active keyset ID from the mint.
+pub async fn get_active_keyset_id(mint: &Mint) -> Result<Id, Error> {
+    let keys = mint
+        .pubkeys()
+        .keysets
+        .first()
+        .ok_or(Error::Internal)?
+        .clone();
+    keys.verify_id()?;
+    Ok(keys.id)
+}

+ 10 - 0
crates/cdk/src/test_helpers/mod.rs

@@ -0,0 +1,10 @@
+#![cfg(test)]
+//! Test helper utilities for CDK unit tests
+//!
+//! This module provides shared test utilities for creating test mints, wallets,
+//! and test data without external dependencies (Lightning nodes, databases).
+//!
+//! These helpers are only compiled when running tests.
+
+#[cfg(feature = "mint")]
+pub mod mint;

+ 13 - 0
crates/cdk/src/wallet/auth/mod.rs

@@ -65,4 +65,17 @@ impl Wallet {
         }
         Ok(())
     }
+
+    /// Set the auth client (AuthWallet) for this wallet
+    ///
+    /// This allows updating the auth wallet without recreating the wallet.
+    /// Also updates the client's auth wallet to keep them in sync.
+    #[instrument(skip_all)]
+    pub async fn set_auth_client(&self, auth_wallet: Option<AuthWallet>) {
+        let mut auth_wallet_guard = self.auth_wallet.write().await;
+        *auth_wallet_guard = auth_wallet.clone();
+
+        // Also update the client's auth wallet to keep them in sync
+        self.client.set_auth_wallet(auth_wallet).await;
+    }
 }

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

@@ -673,6 +673,20 @@ impl Wallet {
 
         Ok(())
     }
+
+    /// Set the client (MintConnector) for this wallet
+    ///
+    /// This allows updating the connector without recreating the wallet.
+    pub fn set_client(&mut self, client: Arc<dyn MintConnector + Send + Sync>) {
+        self.client = client;
+    }
+
+    /// Set the target proof count for this wallet
+    ///
+    /// This controls how many proofs of each denomination the wallet tries to maintain.
+    pub fn set_target_proof_count(&mut self, count: usize) {
+        self.target_proof_count = count;
+    }
 }
 
 impl Drop for Wallet {

+ 289 - 21
crates/cdk/src/wallet/multi_mint_wallet.rs

@@ -60,6 +60,50 @@ pub struct TransferResult {
     pub target_balance_after: Amount,
 }
 
+/// Configuration for individual wallets within MultiMintWallet
+#[derive(Clone, Default, Debug)]
+pub struct WalletConfig {
+    /// Custom mint connector implementation
+    pub mint_connector: Option<Arc<dyn super::MintConnector + Send + Sync>>,
+    /// Custom auth connector implementation
+    #[cfg(feature = "auth")]
+    pub auth_connector: Option<Arc<dyn super::auth::AuthMintConnector + Send + Sync>>,
+    /// Target number of proofs to maintain at each denomination
+    pub target_proof_count: Option<usize>,
+}
+
+impl WalletConfig {
+    /// Create a new empty WalletConfig
+    pub fn new() -> Self {
+        Self::default()
+    }
+
+    /// Set custom mint connector
+    pub fn with_mint_connector(
+        mut self,
+        connector: Arc<dyn super::MintConnector + Send + Sync>,
+    ) -> Self {
+        self.mint_connector = Some(connector);
+        self
+    }
+
+    /// Set custom auth connector
+    #[cfg(feature = "auth")]
+    pub fn with_auth_connector(
+        mut self,
+        connector: Arc<dyn super::auth::AuthMintConnector + Send + Sync>,
+    ) -> Self {
+        self.auth_connector = Some(connector);
+        self
+    }
+
+    /// Set target proof count
+    pub fn with_target_proof_count(mut self, count: usize) -> Self {
+        self.target_proof_count = Some(count);
+        self
+    }
+}
+
 /// Multi Mint Wallet
 ///
 /// A wallet that manages multiple mints but supports only one currency unit.
@@ -89,8 +133,8 @@ pub struct TransferResult {
 /// // Add mints to the wallet
 /// let mint_url1: MintUrl = "https://mint1.example.com".parse()?;
 /// let mint_url2: MintUrl = "https://mint2.example.com".parse()?;
-/// wallet.add_mint(mint_url1.clone(), None).await?;
-/// wallet.add_mint(mint_url2, None).await?;
+/// wallet.add_mint(mint_url1.clone()).await?;
+/// wallet.add_mint(mint_url2).await?;
 ///
 /// // Check total balance across all mints
 /// let balance = wallet.total_balance().await?;
@@ -199,12 +243,149 @@ impl MultiMintWallet {
     }
 
     /// Adds a mint to this [MultiMintWallet]
+    ///
+    /// Creates a wallet for the specified mint using default or global settings.
+    /// For custom configuration, use `add_mint_with_config()`.
     #[instrument(skip(self))]
-    pub async fn add_mint(
+    pub async fn add_mint(&self, mint_url: MintUrl) -> Result<(), Error> {
+        // Create wallet with default settings
+        let wallet = self
+            .create_wallet_with_config(mint_url.clone(), None)
+            .await?;
+
+        // Insert into wallets map
+        let mut wallets = self.wallets.write().await;
+        wallets.insert(mint_url, wallet);
+
+        Ok(())
+    }
+
+    /// Adds a mint to this [MultiMintWallet] with custom configuration
+    ///
+    /// The provided configuration is used to create the wallet with custom connectors
+    /// and settings. Configuration is stored within the Wallet instance itself.
+    #[instrument(skip(self))]
+    pub async fn add_mint_with_config(
+        &self,
+        mint_url: MintUrl,
+        config: WalletConfig,
+    ) -> Result<(), Error> {
+        // Create wallet with the provided config
+        let wallet = self
+            .create_wallet_with_config(mint_url.clone(), Some(&config))
+            .await?;
+
+        // Insert into wallets map
+        let mut wallets = self.wallets.write().await;
+        wallets.insert(mint_url, wallet);
+
+        Ok(())
+    }
+
+    /// Set or update configuration for a mint
+    ///
+    /// If the wallet already exists, it will be updated with the new config.
+    /// If the wallet doesn't exist, it will be created with the specified config.
+    #[instrument(skip(self))]
+    pub async fn set_mint_config(
         &self,
         mint_url: MintUrl,
-        target_proof_count: Option<usize>,
+        config: WalletConfig,
     ) -> Result<(), Error> {
+        // Check if wallet already exists
+        if self.has_mint(&mint_url).await {
+            // Update existing wallet in place
+            let mut wallets = self.wallets.write().await;
+            if let Some(wallet) = wallets.get_mut(&mint_url) {
+                // Update target_proof_count if provided
+                if let Some(count) = config.target_proof_count {
+                    wallet.set_target_proof_count(count);
+                }
+
+                // Update connector if provided
+                if let Some(connector) = config.mint_connector {
+                    wallet.set_client(connector);
+                }
+
+                // TODO: Handle auth_connector if provided
+                #[cfg(feature = "auth")]
+                if let Some(_auth_connector) = config.auth_connector {
+                    // For now, we can't easily inject auth_connector into the wallet
+                    // This would require additional work on the Wallet API
+                    // We'll note this as a future enhancement
+                }
+            }
+            Ok(())
+        } else {
+            // Wallet doesn't exist, create it with the provided config
+            self.add_mint_with_config(mint_url, config).await
+        }
+    }
+
+    /// Set the auth client (AuthWallet) for a specific mint
+    ///
+    /// This allows updating the auth wallet for an existing mint wallet without recreating it.
+    #[cfg(feature = "auth")]
+    #[instrument(skip_all)]
+    pub async fn set_auth_client(
+        &self,
+        mint_url: &MintUrl,
+        auth_wallet: Option<super::auth::AuthWallet>,
+    ) -> Result<(), Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.set_auth_client(auth_wallet).await;
+        Ok(())
+    }
+
+    /// Remove mint from MultiMintWallet
+    #[instrument(skip(self))]
+    pub async fn remove_mint(&self, mint_url: &MintUrl) {
+        let mut wallets = self.wallets.write().await;
+        wallets.remove(mint_url);
+    }
+
+    /// Internal: Create wallet with optional custom configuration
+    ///
+    /// Priority order for configuration:
+    /// 1. Custom connector from config (if provided)
+    /// 2. Global settings (proxy/Tor)
+    /// 3. Default HttpClient
+    async fn create_wallet_with_config(
+        &self,
+        mint_url: MintUrl,
+        config: Option<&WalletConfig>,
+    ) -> Result<Wallet, Error> {
+        // Check if custom connector is provided in config
+        if let Some(cfg) = config {
+            if let Some(custom_connector) = &cfg.mint_connector {
+                // Use custom connector with WalletBuilder
+                let builder = WalletBuilder::new()
+                    .mint_url(mint_url.clone())
+                    .unit(self.unit.clone())
+                    .localstore(self.localstore.clone())
+                    .seed(self.seed)
+                    .target_proof_count(cfg.target_proof_count.unwrap_or(3))
+                    .shared_client(custom_connector.clone());
+
+                // TODO: Handle auth_connector if provided
+                #[cfg(feature = "auth")]
+                if let Some(_auth_connector) = &cfg.auth_connector {
+                    // For now, we can't easily inject auth_connector into the wallet
+                    // This would require additional work on the Wallet/WalletBuilder API
+                    // We'll note this as a future enhancement
+                }
+
+                return builder.build();
+            }
+        }
+
+        // Fall back to existing logic: proxy/Tor/default
+        let target_proof_count = config.and_then(|c| c.target_proof_count).unwrap_or(3);
+
         let wallet = if let Some(proxy_url) = &self.proxy_config {
             // Create wallet with proxy-configured client
             let client = crate::wallet::HttpClient::with_proxy(
@@ -228,7 +409,7 @@ impl MultiMintWallet {
                 .unit(self.unit.clone())
                 .localstore(self.localstore.clone())
                 .seed(self.seed)
-                .target_proof_count(target_proof_count.unwrap_or(3))
+                .target_proof_count(target_proof_count)
                 .client(client)
                 .build()?
         } else {
@@ -256,7 +437,7 @@ impl MultiMintWallet {
                     .unit(self.unit.clone())
                     .localstore(self.localstore.clone())
                     .seed(self.seed)
-                    .target_proof_count(target_proof_count.unwrap_or(3))
+                    .target_proof_count(target_proof_count)
                     .client(client)
                     .build()?
             } else {
@@ -266,7 +447,7 @@ impl MultiMintWallet {
                     self.unit.clone(),
                     self.localstore.clone(),
                     self.seed,
-                    target_proof_count,
+                    Some(target_proof_count),
                 )?
             }
 
@@ -278,22 +459,12 @@ impl MultiMintWallet {
                     self.unit.clone(),
                     self.localstore.clone(),
                     self.seed,
-                    target_proof_count,
+                    Some(target_proof_count),
                 )?
             }
         };
 
-        let mut wallets = self.wallets.write().await;
-        wallets.insert(mint_url, wallet);
-
-        Ok(())
-    }
-
-    /// Remove mint from MultiMintWallet
-    #[instrument(skip(self))]
-    pub async fn remove_mint(&self, mint_url: &MintUrl) {
-        let mut wallets = self.wallets.write().await;
-        wallets.remove(mint_url);
+        Ok(wallet)
     }
 
     /// Load all wallets from database that have proofs for this currency unit
@@ -317,7 +488,7 @@ impl MultiMintWallet {
             if mint_has_proofs_for_unit {
                 // Add mint to the MultiMintWallet if not already present
                 if !self.has_mint(&mint_url).await {
-                    self.add_mint(mint_url.clone(), None).await?
+                    self.add_mint(mint_url.clone()).await?
                 }
             }
         }
@@ -980,7 +1151,7 @@ impl MultiMintWallet {
 
         // Add the untrusted mint temporarily if needed
         if !is_trusted {
-            self.add_mint(mint_url.clone(), None).await?;
+            self.add_mint(mint_url.clone()).await?;
         }
 
         let wallets = self.wallets.read().await;
@@ -1414,6 +1585,103 @@ impl MultiMintWallet {
 
         Ok(total_consolidated)
     }
+
+    /// Mint blind auth tokens for a specific mint
+    ///
+    /// This is a convenience method that calls the underlying wallet's mint_blind_auth.
+    #[cfg(feature = "auth")]
+    #[instrument(skip_all)]
+    pub async fn mint_blind_auth(
+        &self,
+        mint_url: &MintUrl,
+        amount: Amount,
+    ) -> Result<Proofs, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.mint_blind_auth(amount).await
+    }
+
+    /// Get unspent auth proofs for a specific mint
+    ///
+    /// This is a convenience method that calls the underlying wallet's get_unspent_auth_proofs.
+    #[cfg(feature = "auth")]
+    #[instrument(skip_all)]
+    pub async fn get_unspent_auth_proofs(
+        &self,
+        mint_url: &MintUrl,
+    ) -> Result<Vec<cdk_common::AuthProof>, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.get_unspent_auth_proofs().await
+    }
+
+    /// Set Clear Auth Token (CAT) for authentication at a specific mint
+    ///
+    /// This is a convenience method that calls the underlying wallet's set_cat.
+    #[cfg(feature = "auth")]
+    #[instrument(skip_all)]
+    pub async fn set_cat(&self, mint_url: &MintUrl, cat: String) -> Result<(), Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.set_cat(cat).await
+    }
+
+    /// Set refresh token for authentication at a specific mint
+    ///
+    /// This is a convenience method that calls the underlying wallet's set_refresh_token.
+    #[cfg(feature = "auth")]
+    #[instrument(skip_all)]
+    pub async fn set_refresh_token(
+        &self,
+        mint_url: &MintUrl,
+        refresh_token: String,
+    ) -> Result<(), Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.set_refresh_token(refresh_token).await
+    }
+
+    /// Refresh CAT token for a specific mint
+    ///
+    /// This is a convenience method that calls the underlying wallet's refresh_access_token.
+    #[cfg(feature = "auth")]
+    #[instrument(skip(self))]
+    pub async fn refresh_access_token(&self, mint_url: &MintUrl) -> Result<(), Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.refresh_access_token().await
+    }
+
+    /// Query mint for current mint information
+    ///
+    /// This is a convenience method that calls the underlying wallet's fetch_mint_info.
+    #[instrument(skip(self))]
+    pub async fn fetch_mint_info(
+        &self,
+        mint_url: &MintUrl,
+    ) -> Result<Option<crate::nuts::MintInfo>, Error> {
+        let wallets = self.wallets.read().await;
+        let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint {
+            mint_url: mint_url.to_string(),
+        })?;
+
+        wallet.fetch_mint_info().await
+    }
 }
 
 impl Drop for MultiMintWallet {

+ 20 - 1
docker-compose.ldk-node.yaml

@@ -46,6 +46,7 @@ services:
     container_name: mint-ldk-node
     ports:
       - "8085:8085"
+      - "8091:8091" # LDK admin dashboard (WARNING!!! Do not expose to network! Doing so will leave LDK node funds accessible by whole network)
     environment:
       - CDK_MINTD_URL=https://example.com
       - CDK_MINTD_LN_BACKEND=ldk-node
@@ -66,9 +67,27 @@ services:
       - CDK_MINTD_PROMETHEUS_ADDRESS=0.0.0.0
       - CDK_MINTD_PROMETHEUS_PORT=9000
       # LDK Node specific configuration
-      - CDK_MINTD_LDK_NODE_NETWORK=testnet
+      - CDK_MINTD_LDK_NODE_BITCOIN_NETWORK=testnet # or: testnet, signet, regtest
       - CDK_MINTD_LDK_NODE_ESPLORA_URL=https://blockstream.info/testnet/api
       - CDK_MINTD_LDK_NODE_LISTENING_ADDRESSES=0.0.0.0:9735
+      # LDK admin dashboard config
+      - CDK_MINTD_LDK_NODE_WEBSERVER_HOST=0.0.0.0
+      # Other Options
+      # - CDK_MINTD_LDK_NODE_WEBSERVER_PORT=
+      # - CDK_MINTD_LDK_NODE_FEE_PERCENT=
+      # - CDK_MINTD_LDK_NODE_RESERVE_FEE_MIN=
+      # - CDK_MINTD_LDK_NODE_CHAIN_SOURCE_TYPE=esplora # or: bitcoinrpc
+      # if chain source is set to bitcoinrpc, the following RPC options need to be set instead
+      # - CDK_MINTD_LDK_NODE_BITCOIND_RPC_HOST=
+      # - CDK_MINTD_LDK_NODE_BITCOIND_RPC_PORT=
+      # - CDK_MINTD_LDK_NODE_BITCOIND_RPC_USER=
+      # - CDK_MINTD_LDK_NODE_BITCOIND_RPC_PASSWORD=
+      # - CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH=
+      # - CDK_MINTD_LDK_NODE_LDK_NODE_HOST=
+      # - CDK_MINTD_LDK_NODE_LDK_NODE_PORT=
+      # - CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE=rgs # or: p2p
+      # - CDK_MINTD_LDK_NODE_RGS_URL=
+
     volumes:
       # Persist LDK node data
       - ldk_node_data:/usr/src/app/ldk_node_data

+ 0 - 36
docker-compose.yaml

@@ -74,42 +74,6 @@ services:
     # depends_on:
     #   - postgres
 
-  # LDK Node mint service - enable with: docker-compose --profile ldk-node up
-  mintd-ldk-node:
-    build:
-      context: .
-      dockerfile: Dockerfile.ldk-node
-    container_name: mint-ldk-node
-    profiles:
-      - ldk-node
-    ports:
-      - "8086:8085"  # Different port to avoid conflict with main mint
-    environment:
-      - CDK_MINTD_URL=https://example.com
-      - CDK_MINTD_LN_BACKEND=ldk-node
-      - CDK_MINTD_LISTEN_HOST=0.0.0.0
-      - CDK_MINTD_LISTEN_PORT=8085
-      - CDK_MINTD_MNEMONIC=
-      # Database configuration
-      - CDK_MINTD_DATABASE=sqlite
-      # Cache configuration
-      - CDK_MINTD_CACHE_BACKEND=memory
-      - CDK_MINTD_PROMETHEUS_ENABLED=true
-      - CDK_MINTD_PROMETHEUS_ADDRESS=0.0.0.0
-      - CDK_MINTD_PROMETHEUS_PORT=9000
-      # LDK Node specific configuration
-      - CDK_MINTD_LDK_NODE_NETWORK=testnet
-      - CDK_MINTD_LDK_NODE_ESPLORA_URL=https://blockstream.info/testnet/api
-      - CDK_MINTD_LDK_NODE_LISTENING_ADDRESSES=0.0.0.0:9735
-    volumes:
-      # Persist LDK node data
-      - ldk_node_data:/usr/src/app/ldk_node_data
-    command: ["cdk-mintd"]
-    depends_on:
-      - prometheus
-      - grafana
-    networks:
-      - cdk
 
   # PostgreSQL database service
   # Enable with: docker-compose --profile postgres up

+ 18 - 18
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
     "crane": {
       "locked": {
-        "lastModified": 1758758545,
-        "narHash": "sha256-NU5WaEdfwF6i8faJ2Yh+jcK9vVFrofLcwlD/mP65JrI=",
+        "lastModified": 1760924934,
+        "narHash": "sha256-tuuqY5aU7cUkR71sO2TraVKK2boYrdW3gCSXUkF4i44=",
         "owner": "ipetkov",
         "repo": "crane",
-        "rev": "95d528a5f54eaba0d12102249ce42f4d01f4e364",
+        "rev": "c6b4d5308293d0d04fcfeee92705017537cad02f",
         "type": "github"
       },
       "original": {
@@ -23,11 +23,11 @@
         "rust-analyzer-src": []
       },
       "locked": {
-        "lastModified": 1759387261,
-        "narHash": "sha256-u/gfYBMDnwD1mSAlzHnRVg3jst3ML0w9b3tYONXRsW8=",
+        "lastModified": 1761287960,
+        "narHash": "sha256-DbGYVbF0TgoKTFNQv/3jqaUWql8OYzewl4v2gw6jmQs=",
         "owner": "nix-community",
         "repo": "fenix",
-        "rev": "c61e7ab9cfa38119d5ab9e2d1156d23c19e9b8a0",
+        "rev": "64cb168ed9ec61ef18e28f1e4ee9f44381d6ecd2",
         "type": "github"
       },
       "original": {
@@ -93,11 +93,11 @@
     },
     "nixpkgs": {
       "locked": {
-        "lastModified": 1759281824,
-        "narHash": "sha256-FIBE1qXv9TKvSNwst6FumyHwCRH3BlWDpfsnqRDCll0=",
+        "lastModified": 1761173472,
+        "narHash": "sha256-m9W0dYXflzeGgKNravKJvTMR4Qqa2MVD11AwlGMufeE=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "5b5be50345d4113d04ba58c444348849f5585b4a",
+        "rev": "c8aa8cc00a5cb57fada0851a038d35c08a36a2bb",
         "type": "github"
       },
       "original": {
@@ -109,11 +109,11 @@
     },
     "nixpkgs_2": {
       "locked": {
-        "lastModified": 1758029226,
-        "narHash": "sha256-TjqVmbpoCqWywY9xIZLTf6ANFvDCXdctCjoYuYPYdMI=",
+        "lastModified": 1759070547,
+        "narHash": "sha256-JVZl8NaVRYb0+381nl7LvPE+A774/dRpif01FKLrYFQ=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "08b8f92ac6354983f5382124fef6006cade4a1c1",
+        "rev": "647e5c14cbd5067f44ac86b74f014962df460840",
         "type": "github"
       },
       "original": {
@@ -130,11 +130,11 @@
         "nixpkgs": "nixpkgs_2"
       },
       "locked": {
-        "lastModified": 1758108966,
-        "narHash": "sha256-ytw7ROXaWZ7OfwHrQ9xvjpUWeGVm86pwnEd1QhzawIo=",
+        "lastModified": 1760663237,
+        "narHash": "sha256-BflA6U4AM1bzuRMR8QqzPXqh8sWVCNDzOdsxXEguJIc=",
         "owner": "cachix",
         "repo": "pre-commit-hooks.nix",
-        "rev": "54df955a695a84cd47d4a43e08e1feaf90b1fd9b",
+        "rev": "ca5b894d3e3e151ffc1db040b6ce4dcc75d31c37",
         "type": "github"
       },
       "original": {
@@ -160,11 +160,11 @@
         ]
       },
       "locked": {
-        "lastModified": 1759372351,
-        "narHash": "sha256-kULiC2oMMuyaO92gPiu+6XBfeXuFcXaauwo0tXAwXdQ=",
+        "lastModified": 1761273263,
+        "narHash": "sha256-6d6ojnu6A6sVxIjig8OL6E1T8Ge9st3YGgVwg5MOY+Q=",
         "owner": "oxalica",
         "repo": "rust-overlay",
-        "rev": "7ef14552303de7128662666f9a71342099ffc725",
+        "rev": "28405834d4fdd458d28e123fae4db148daecec6f",
         "type": "github"
       },
       "original": {

+ 3 - 1
flake.nix

@@ -56,7 +56,7 @@
 
         # Toolchains
         # latest stable
-        stable_toolchain = pkgs.rust-bin.stable."1.86.0".default.override {
+        stable_toolchain = pkgs.rust-bin.stable."1.90.0".default.override {
           targets = [ "wasm32-unknown-unknown" ]; # wasm
           extensions = [
             "rustfmt"
@@ -172,6 +172,8 @@
             msrv = pkgs.mkShell (
               {
                 shellHook = "
+                  cargo update
+                  cargo update home --precise 0.5.11
               ${_shellHook}
               ";
                 buildInputs = buildInputs ++ [ msrv_toolchain ];

+ 3 - 0
justfile

@@ -75,6 +75,9 @@ test-pure db="memory":
 
   # Run pure integration tests (cargo test will only build what's needed for the test)
   CDK_TEST_DB_TYPE={{db}} cargo test -p cdk-integration-tests --test integration_tests_pure -- --test-threads 1
+  
+  # Run swap flow tests (detailed testing of swap operation)
+  CDK_TEST_DB_TYPE={{db}} cargo test -p cdk-integration-tests --test test_swap_flow -- --test-threads 1
 
 test-all db="memory":
     #!/usr/bin/env bash

+ 18 - 0
meetings/2025-04-02-agenda.md

@@ -0,0 +1,18 @@
+# CDK Dev Call 8
+April 2 2025 15:00 UTC 
+
+Meeting Link: https://signal.link/call/#key=pqqn-xzqm-rtrt-khhq-xtgg-sxdk-fpkh-mftk
+
+# Agenda 
+
+## Merged
+- Test with Nutshell wallet [PR](https://github.com/cashubtc/cdk/pull/695)
+- Test with Nutshell mint [PR](https://github.com/cashubtc/cdk/pull/691)
+- Remove DLEQ from requests to mint [PR](https://github.com/cashubtc/cdk/pull/690)
+- Refactor tests [PR](https://github.com/cashubtc/cdk/pull/685)
+- Rust Docs [PR](https://github.com/cashubtc/cdk/pull/681)
+
+## Demo
+- [cashudevkit.org](https://cashudevkit.org)
+- Cashu Proxy [PR](https://github.com/thesimplekid/cashu-proxy)
+

+ 24 - 0
meetings/2025-04-09-agenda.md

@@ -0,0 +1,24 @@
+# CDK Dev Call 9
+April 9 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+- Export DB traits [PR](https://github.com/cashubtc/cdk/pull/710)
+- Time metadata for quotes, proofs and signatures [PR](https://github.com/cashubtc/cdk/pull/708)
+- SQlite memory db fix [PR](https://github.com/cashubtc/cdk/pull/707)
+- Melt to amountless [PR](https://github.com/cashubtc/cdk/pull/497)
+- Fix Check of amountless settings [PR](https://github.com/cashubtc/cdk/pull/713)
+- Fix mint pending get mint info [PR](https://github.com/cashubtc/cdk/pull/704)
+- V0.9.0 [PR](https://github.com/cashubtc/cdk/pull/718)
+
+
+## Discuss
+- Prelude
+- BOLT12
+- SQLite dep
+
+
+

+ 12 - 0
meetings/2025-04-23-agenda.md

@@ -0,0 +1,12 @@
+# CDK Dev Call 10
+April 23 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+- Update lnbits [PR](https://github.com/cashubtc/cdk/pull/733)
+- Mint proof state transition [PR](https://github.com/cashubtc/cdk/pull/730)
+
+## Discuss

+ 24 - 0
meetings/2025-06-04-agenda.md

@@ -0,0 +1,24 @@
+# CDK Dev Call 13
+June 4th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+- Remove redundant filter [PR](https://github.com/cashubtc/cdk/pull/784)
+- Signatory loader [PR](https://github.com/cashubtc/cdk/pull/777/files)
+- Signatory custom stream [PR](https://github.com/cashubtc/cdk/pull/776)
+- Sqlite dep optional for signatory [PR](https://github.com/cashubtc/cdk/pull/775)
+- Revert transaction [PR](https://github.com/cashubtc/cdk/pull/774)
+- Docker build for arm [PR](https://github.com/cashubtc/cdk/pull/770)
+
+## Opened
+- Migrate from sqlx [PR](https://github.com/cashubtc/cdk/pull/783)
+- Remove pub properties [PR](https://github.com/cashubtc/cdk/pull/782)
+- Refactor mintd main fn [PR](https://github.com/cashubtc/cdk/pull/778)
+
+
+## Discuss
+
+

+ 26 - 0
meetings/2025-06-11-agenda.md

@@ -0,0 +1,26 @@
+# CDK Dev Call 14
+June 11th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+- Docker arm release [PR](https://github.com/cashubtc/cdk/pull/805)
+- Fix mint version [PR](https://github.com/cashubtc/cdk/pull/803)
+- Bump version 10 [PR](https://github.com/cashubtc/cdk/pull/797)
+
+## Opened
+- Update lnbits [PR](https://github.com/cashubtc/cdk/pull/802)
+- Update test matrix [PR](https://github.com/cashubtc/cdk/pull/799)
+- Remove redb [PR](https://github.com/cashubtc/cdk/pull/787)
+
+## Discuss
+- Upcoming release plan
+    - v0.11.0 Sqlx
+        - sqlx -> rusqlite
+            - [#783](https://github.com/cashubtc/cdk/pull/783) - Migrate from `sqlx` to rusqlite
+        - redb -> sqlite conversion
+            - https://github.com/thesimplekid/cdk-convert-redb-to-sqlite
+    - v0.12.0 bolt12  
+        - remove redb

+ 34 - 0
meetings/2025-06-25-agenda.md

@@ -0,0 +1,34 @@
+# CDK Dev Call 15
+June 25th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+- REDB conversion script [PR](https://github.com/cashubtc/cdk/pull/829)
+- Remove fedimint tonic [PR](https://github.com/cashubtc/cdk/pull/831)
+- CLN RPC remove mutex [PR](https://github.com/cashubtc/cdk/pull/832)
+- Install crypto providers [PR](https://github.com/cashubtc/cdk/pull/836)
+- Fix nonsat amounts on melt [PR](https://github.com/cashubtc/cdk/pull/839)
+- Remove multiple on conflicts on sqlite [PR](https://github.com/cashubtc/cdk/pull/820)
+- Fix cdk-cli create wallets for proper units [PR](https://github.com/cashubtc/cdk/pull/841)
+- Keyset V2 [PR](https://github.com/cashubtc/cdk/pull/702)
+- ARM override on release [PR](https://github.com/cashubtc/cdk/pull/825)
+- Remove melt request table [PR](https://github.com/cashubtc/cdk/pull/819)
+
+## Opened
+- DB transaction trait [PR](https://github.com/cashubtc/cdk/pull/826)
+
+## Discuss
+
+## Upcoming release plan
+- v0.11.0 Sqlx
+    - Features:
+        - sqlx -> rusqlite
+            - [#783](https://github.com/cashubtc/cdk/pull/783) - Migrate from `sqlx` to rusqlite
+        - redb -> sqlite conversion
+            - https://github.com/thesimplekid/cdk-convert-redb-to-sqlite
+    - Blocking: 
+        - [#826](https://github.com/cashubtc/cdk/pull/826) - Split the database trait into read and transactions.
+- v0.12.0 bolt12  

+ 41 - 0
meetings/2025-07-02-agenda.md

@@ -0,0 +1,41 @@
+# CDK Dev Call 16
+July 2nd 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+ 
+
+- Correct name of blinded_messages on sig table [PR](https://github.com/cashubtc/cdk/pull/845)
+- Limit send size of token [PR](https://github.com/cashubtc/cdk/pull/855)
+- Mint error codes [PR](https://github.com/cashubtc/cdk/pull/858)
+- Refund multi sig [PR](https://github.com/cashubtc/cdk/pull/860)
+- Bump v0.11 [PR](https://github.com/cashubtc/cdk/pull/863)
+- Check unpaid quotes on mint start up [PR](https://github.com/cashubtc/cdk/pull/844)
+- Remove unused protos [PR](https://github.com/cashubtc/cdk/pull/842)
+- cors headers on auth endpoints [PR](https://github.com/cashubtc/cdk/pull/866)
+- glibc compatibility [PR](https://github.com/cashubtc/cdk/pull/864)
+
+
+
+## Opened
+
+- Sig all [PR](https://github.com/cashubtc/cdk/pull/862)
+
+
+## Discuss
+
+## Next dev call
+ - outbox table
+     - outbox pattern by inserting a row into an "outbox" table that contains the message to be sent to the LNBackend. Another process will select pending entries from this outbox and dispatch the message to the LN API. Once the message is successfully delivered, we need to update the entry in the outbox table and set its state to completed or failed, depending on the outcome.
+ - Bolt12 PR review
+ - Sqlite -> postgresql migration 
+
+## Upcoming release plan
+- v0.12
+   - Bolt12 support
+   - cdk-ldk?
+   - postgresql?
+

+ 39 - 0
meetings/2025-07-09-agenda.md

@@ -0,0 +1,39 @@
+# CDK Dev Call 17
+July 9th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+- Remove left in REDB file [PR](https://github.com/cashubtc/cdk/pull/872)
+- Remove start up pending mint check [PR](https://github.com/cashubtc/cdk/pull/873)
+- Remove rexie [PR](https://github.com/cashubtc/cdk/pull/875)
+- Mprocs Regtest [PR](https://github.com/cashubtc/cdk/pull/876)
+ 
+
+
+## Opened
+
+- Postgresql [PR](https://github.com/cashubtc/cdk/pull/878)
+- Add proof state on db add proof [PR](https://github.com/cashubtc/cdk/pull/867)
+
+## Stalled
+- Sig all [PR](https://github.com/cashubtc/cdk/pull/862)
+
+
+
+## Discuss
+ - outbox table
+     - outbox pattern by inserting a row into an "outbox" table that contains the message to be sent to the LNBackend. Another process will select pending entries from this outbox and dispatch the message to the LN API. Once the message is successfully delivered, we need to update the entry in the outbox table and set its state to completed or failed, depending on the outcome.
+ - Bolt12 PR review
+ - Sqlite -> postgresql migration 
+
+## Next dev call
+
+
+## Upcoming release plan
+- v0.12
+   - Bolt12 support
+   - cdk-ldk?
+   - postgresql?

+ 51 - 0
meetings/2025-07-23-agenda.md

@@ -0,0 +1,51 @@
+# CDK Dev Call 18
+July 23th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+- Bolt12 [PR](https://github.com/cashubtc/cdk/pull/874)
+- Correct error when fetching config [PR](https://github.com/cashubtc/cdk/pull/888)
+- Refactor mintd main fn [PR](https://github.com/cashubtc/cdk/pull/778)
+- Get active mint quotes [PR](https://github.com/cashubtc/cdk/pull/884)
+- Check pending mint quote [PR](https://github.com/cashubtc/cdk/pull/895)
+- Refactor mint builder [PR](https://github.com/cashubtc/cdk/pull/887)
+- Fake wallet convent unit [PR](https://github.com/cashubtc/cdk/pull/899)
+- Goose recipes [PR](https://github.com/cashubtc/cdk/pull/902)
+- Refactor nut10 secret [PR](https://github.com/cashubtc/cdk/pull/900)
+- Change in melt ws [PR](https://github.com/cashubtc/cdk/pull/889)
+
+## Opened
+- Prometheus [PR](https://github.com/cashubtc/cdk/pull/883)
+- Uuid version [PR](https://github.com/cashubtc/cdk/pull/891)
+- Prepared send confirm [PR](https://github.com/cashubtc/cdk/pull/898)
+-  Increment keyset counter optimistically  [PR](https://github.com/cashubtc/cdk/pull/885)
+- fix: atomically increment keyset counter  [PR](https://github.com/cashubtc/cdk/pull/897)
+- Wallet event [PR](https://github.com/cashubtc/cdk/pull/806)
+- cdk-sql-common [PR](https://github.com/cashubtc/cdk/pull/890)
+- wallet keyset fns [PR](https://github.com/cashubtc/cdk/pull/901)
+- add mint lifecycle management with start/stop methods [PR](https://github.com/cashubtc/cdk/pull/903)
+- cdk-ldk-node [PR](https://github.com/cashubtc/cdk/pull/904)
+
+## Stalled
+- Sig all [PR](https://github.com/cashubtc/cdk/pull/862)
+
+
+
+## Discuss
+- bindings
+     - https://github.com/thesimplekid/cdk-ffi
+ - outbox table
+     - outbox pattern by inserting a row into an "outbox" table that contains the message to be sent to the LNBackend. Another process will select pending entries from this outbox and dispatch the message to the LN API. Once the message is successfully delivered, we need to update the entry in the outbox table and set its state to completed or failed, depending on the outcome.
+ - Sqlite -> postgresql migration 
+
+## Next dev call
+
+
+## Upcoming release plan
+- v0.12
+   - Bolt12 support
+   - cdk-ldk
+   - postgresql?

+ 26 - 0
meetings/2025-07-30-agenda.md

@@ -0,0 +1,26 @@
+# CDK Dev Call 19
+July 30th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda 
+
+## Merged
+- Simplify flake [PR](https://github.com/cashubtc/cdk/pull/907)
+- Mint stop start [PR](https://github.com/cashubtc/cdk/pull/903)
+- Case of custom units [PR](https://github.com/cashubtc/cdk/pull/909)
+- Nut19 wallet support [PR](https://github.com/cashubtc/cdk/pull/912)
+- TransactionId panic [PR](https://github.com/cashubtc/cdk/pull/915)
+- Get request by lookup id [PR](https://github.com/cashubtc/cdk/pull/917)
+- Common sql [PR](https://github.com/cashubtc/cdk/pull/890)
+- Payment parsing tests [PR](https://github.com/cashubtc/cdk/pull/920)
+
+## Opened
+- Mintd as lib [PR](https://github.com/cashubtc/cdk/pull/914)
+- RGLI [PR](https://github.com/cashubtc/cdk/pull/906)
+
+## In-progress
+- cdk-ldk-node [PR](https://github.com/cashubtc/cdk/pull/904) (tsk)
+- Prometheus crate [PR](https://github.com/cashubtc/cdk/pull/883) (asmo)
+- Postgres [PR](https://github.com/cashubtc/cdk/pull/878) (crodas)
+- wallet events [PR](https://github.com/cashubtc/cdk/pull/806)

+ 20 - 0
meetings/2025-08-05-agenda.md

@@ -0,0 +1,20 @@
+# CDK Dev Call 20
+Aug 5th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda
+- DB Trait [PR](https://github.com/cashubtc/cdk/pull/931)
+- FFI [PR](https://github.com/cashubtc/cdk/pull/932)
+
+## Merged
+- Mintd as lib [PR](https://github.com/cashubtc/cdk/pull/914)
+
+## Opened
+
+
+## In-progress
+- cdk-ldk-node [PR](https://github.com/cashubtc/cdk/pull/904) (tsk) (done dealing with CI errors)
+- Prometheus crate [PR](https://github.com/cashubtc/cdk/pull/883) (asmo)
+- Postgres [PR](https://github.com/cashubtc/cdk/pull/878) (crodas)
+- wallet events [PR](https://github.com/cashubtc/cdk/pull/806)

+ 35 - 0
meetings/2025-08-13-agenda.md

@@ -0,0 +1,35 @@
+# CDK Dev Call 21
+Aug 13th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda
+
+## Discuss
+
+- Better autoincrement for the wallet and the introduction of transactions all together, as the mint
+
+
+## Merged
+- External calls while db tx [PR](https://github.com/cashubtc/cdk/pull/954)
+- Remove unused mint table [PR](https://github.com/cashubtc/cdk/pull/953)
+- Counter at 0 [PR](https://github.com/cashubtc/cdk/pull/950)
+- Nix cache [PR](https://github.com/cashubtc/cdk/pull/949)
+- Explicit rollback [PR](https://github.com/cashubtc/cdk/pull/947)
+- Run db [PR](https://github.com/cashubtc/cdk/pull/946)
+- Empty db calls [PR](https://github.com/cashubtc/cdk/pull/943)
+
+## Opened
+- Bump msrv [PR](https://github.com/cashubtc/cdk/pull/957)
+- Fake mint multiple units [PR](https://github.com/cashubtc/cdk/pull/958)
+- Wallet wait for invoice [Issue](https://github.com/cashubtc/cdk/issues/941)
+- Wallet power of 2 [Issue](https://github.com/cashubtc/cdk/issues/955)
+- Mint with description [Issue](https://github.com/cashubtc/cdk/issues/935)
+- Wallet shouldn't retry on known error [Issue](https://github.com/cashubtc/cdk/issues/939)
+- Atomic Keyset [PR](https://github.com/cashubtc/cdk/pull/944)
+
+## In-progress
+- cdk-ldk-node [PR](https://github.com/cashubtc/cdk/pull/904) (tsk) (done dealing with CI errors)
+- Prometheus crate [PR](https://github.com/cashubtc/cdk/pull/883) (asmo)
+- Postgres [PR](https://github.com/cashubtc/cdk/pull/878) (crodas)
+- wallet events [PR](https://github.com/cashubtc/cdk/pull/806)

+ 41 - 0
meetings/2025-08-20-agenda.md

@@ -0,0 +1,41 @@
+# CDK Dev Call 22
+Aug 20th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda
+
+## Discuss
+
+## Merged
+- Bump msrv [PR](https://github.com/cashubtc/cdk/pull/957)
+- Fake mint multiple units [PR](https://github.com/cashubtc/cdk/pull/958)
+- Wallet wait for invoice [Issue](https://github.com/cashubtc/cdk/issues/941)
+- Wallet shouldn't retry on known error [Issue](https://github.com/cashubtc/cdk/issues/939)
+- Atomic Keyset [PR](https://github.com/cashubtc/cdk/pull/944)
+- Postgres [PR](https://github.com/cashubtc/cdk/pull/878) (crodas)
+- [#971](https://github.com/cashubtc/cdk/pull/971) - feat(cdk): allow minting less than paid amount for non-bolt11 payments
+- [#967](https://github.com/cashubtc/cdk/pull/967) - feat: log to file
+- [#971](https://github.com/cashubtc/cdk/pull/971) - feat(cdk): allow minting less than paid amount for non-bolt11 payments
+- [#972](https://github.com/cashubtc/cdk/pull/972) - fix: bolt12 ws on mint
+- [#974](https://github.com/cashubtc/cdk/pull/974) - feat: refresh keysets
+- [#976](https://github.com/cashubtc/cdk/pull/976) - feat(cdk): add Bolt12 mint quote subscription support
+- [#978](https://github.com/cashubtc/cdk/pull/978) - refactor(cdk): defer BOLT12 invoice fetching to payment execution
+
+
+## Opened
+- [#982](https://github.com/cashubtc/cdk/pull/982) - feat: cln as msats
+- [#981](https://github.com/cashubtc/cdk/pull/981) - fix: lnbits payment check and units
+- [#980](https://github.com/cashubtc/cdk/pull/980) - fix: reduce mmap_size to 5 MiB
+- [#969](https://github.com/cashubtc/cdk/pull/969) - feat: bip353
+- [#965](https://github.com/cashubtc/cdk/pull/965) - chore(flake): add NIX_PATH for flake
+- [#979](https://github.com/cashubtc/cdk/issues/979) - Secret Memory Leakage Due to Extensive Cloning
+
+
+## In-progress
+- cdk-ldk-node [PR](https://github.com/cashubtc/cdk/pull/904) (tsk) (done)
+- Prometheus crate [PR](https://github.com/cashubtc/cdk/pull/883) (asmo)
+
+Next Milestone (0.12.0):
+
+https://github.com/cashubtc/cdk/milestone/14

+ 40 - 0
meetings/2025-08-27-agenda.md

@@ -0,0 +1,40 @@
+# CDK Dev Call 23
+Aug 27th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda
+
+## Discuss
+
+## Merged
+- [#982](https://github.com/cashubtc/cdk/pull/982) - feat: cln as msats
+- [#981](https://github.com/cashubtc/cdk/pull/981) - fix: lnbits payment check and units
+- [#980](https://github.com/cashubtc/cdk/pull/980) - fix: reduce mmap_size to 5 MiB
+- [#969](https://github.com/cashubtc/cdk/pull/969) - feat: bip353
+- [#965](https://github.com/cashubtc/cdk/pull/965) - chore(flake): add NIX_PATH for flake
+- [#979](https://github.com/cashubtc/cdk/issues/979) - Secret Memory Leakage Due to Extensive Cloning
+    - [#988](https://github.com/cashubtc/cdk/pull/988) - feat: zeroize cryptographic secrets on drop
+- [#904](https://github.com/cashubtc/cdk/pull/904) - Cdk ldk node
+- [#999](https://github.com/cashubtc/cdk/pull/999) - replace transports: Option<Vec<Transport>> with just Vec<Transport>
+- [#998](https://github.com/cashubtc/cdk/pull/998) - feat: use trixie
+- [#996](https://github.com/cashubtc/cdk/pull/996) - Fix p2pk
+- [#991](https://github.com/cashubtc/cdk/pull/991) - fix: left-over `y` in blind_signatures table auth database
+- [#989](https://github.com/cashubtc/cdk/pull/989) - Fixed bolt12 missing payments notifications
+- [#987](https://github.com/cashubtc/cdk/pull/987) - refactor(cdk-lnbits): migrate to LNbits v1 websocket API and remove w…
+- [#985](https://github.com/cashubtc/cdk/pull/985) - Introduce Future Streams for Payments and Minting Proofs
+- https://github.com/cashubtc/cdk/milestone/14
+
+## Opened
+- [#984](https://github.com/cashubtc/cdk/pull/984) - compatibility for migrating Nutshell Mints
+- [#995](https://github.com/cashubtc/cdk/pull/995) - onchain
+- [#1005](https://github.com/cashubtc/cdk/pull/1005) - feat: redact secrets from Debug and Display impls
+- [#1006](https://github.com/cashubtc/cdk/pull/1006) - Minor file organization
+- [#1007](https://github.com/cashubtc/cdk/pull/1007) - Add support for Bolt12 notifications for HTTP subscription
+- [#1002](https://github.com/cashubtc/cdk/pull/1002) - feat: add TLS support for PostgreSQL connections
+- [#1003](https://github.com/cashubtc/cdk/pull/1003) - feat: LDK Lightning KVStore support with PostgreSQL integration
+- [#1001](https://github.com/cashubtc/cdk/pull/1001) - MultiMintWallet Refactor
+- [#1000](https://github.com/cashubtc/cdk/issues/1000) - Move most of this pay request logic to a cdk lib fn
+- [#992](https://github.com/cashubtc/cdk/issues/992) - Emulate `NotificationPayload::MintQuoteBolt12Response` for http subscription
+
+

+ 50 - 0
meetings/2025-09-03-agenda.md

@@ -0,0 +1,50 @@
+# CDK Dev Call 24
+Sep 3th 2025 15:00 UTC 
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+# Agenda
+
+## Discuss
+
+## Merged
+
+### Opened Last week
+- [#984](https://github.com/cashubtc/cdk/pull/984) - compatibility for migrating Nutshell Mints
+- [#1007](https://github.com/cashubtc/cdk/pull/1007) - Add support for Bolt12 notifications for HTTP subscription
+- [#1002](https://github.com/cashubtc/cdk/pull/1002) - feat: add TLS support for PostgreSQL connections
+- [#992](https://github.com/cashubtc/cdk/issues/992) - Emulate `NotificationPayload::MintQuoteBolt12Response` for http subscription
+
+
+### New this week
+- [#999](https://github.com/cashubtc/cdk/pull/999) - replace transports: Option<Vec<Transport>> with just Vec<Transport>
+- [#1012](https://github.com/cashubtc/cdk/pull/1012) - Abstract the HTTP Transport
+- [#1019](https://github.com/cashubtc/cdk/pull/1019) - refactor(payment): replace wait_any_incoming_payment with event
+- [#1020](https://github.com/cashubtc/cdk/pull/1020) - fix: bolt12 is nut25
+- [#1021](https://github.com/cashubtc/cdk/pull/1021) - fix: cdk melt quote track payment method
+- [#1023](https://github.com/cashubtc/cdk/pull/1023) - Fix missed events race when creating subscriptions
+- [#1025](https://github.com/cashubtc/cdk/pull/1025) - fix: get all mint quotes
+- [#1026](https://github.com/cashubtc/cdk/pull/1026) - refactor: use quote id to string
+
+
+## Open
+
+### New
+- [#1028](https://github.com/cashubtc/cdk/pull/1028) - chore: move `pay_request` logic into cdk lib
+- [#1027](https://github.com/cashubtc/cdk/pull/1027) - UI rev4
+- [#1022](https://github.com/cashubtc/cdk/pull/1022) - feat(cdk): add generic key-value store functionality for mint databases
+- [#1015](https://github.com/cashubtc/cdk/pull/1015) - add pubkey to mint info if not set
+- [#1029](https://github.com/cashubtc/cdk/issues/1029) - Feature request cdk-wallet: store P2PK key and lookup automatically on token receive
+
+### Ongoing 
+- [#995](https://github.com/cashubtc/cdk/pull/995) - onchain
+- [#1005](https://github.com/cashubtc/cdk/pull/1005) - feat: redact secrets from Debug and Display impls
+- [#1006](https://github.com/cashubtc/cdk/pull/1006) - Minor file organization
+- [#1003](https://github.com/cashubtc/cdk/pull/1003) - feat: LDK Lightning KVStore support with PostgreSQL integration
+
+- [#1000](https://github.com/cashubtc/cdk/issues/1000) - Move most of this pay request logic to a cdk lib fn
+
+## Needs Review
+- [#1001](https://github.com/cashubtc/cdk/pull/1001) - MultiMintWallet Refactor
+
+

+ 44 - 0
meetings/2025-09-10-agenda.md

@@ -0,0 +1,44 @@
+## Open
+
+### Merged
+
+- [#1027](https://github.com/cashubtc/cdk/pull/1027) - UI rev4
+- [#1022](https://github.com/cashubtc/cdk/pull/1022) - feat(cdk): add generic key-value store functionality for mint databases
+- [#1015](https://github.com/cashubtc/cdk/pull/1015) - add pubkey to mint info if not set
+- [#1061](https://github.com/cashubtc/cdk/pull/1061) - Do not fallback to HTTP on first error
+- [#1059](https://github.com/cashubtc/cdk/pull/1059) - feat: remove unused ln_routers
+- [#1058](https://github.com/cashubtc/cdk/pull/1058) - Fix Amount::split_with_fees
+- [#1054](https://github.com/cashubtc/cdk/pull/1054) - fix: None `host_matcher` applies the proxy to all hosts
+- [#1052](https://github.com/cashubtc/cdk/pull/1052) - feat: bolt12 ws
+- [#1051](https://github.com/cashubtc/cdk/pull/1051) - fix: used check math
+- [#1050](https://github.com/cashubtc/cdk/pull/1050) - Close websocket connections sooner
+- [#1043](https://github.com/cashubtc/cdk/pull/1043) - Fix race conditions in minting tests
+- [#1048](https://github.com/cashubtc/cdk/pull/1048) - Reorganize tests, add mint quote/payment coverage, and prevent over-issuing
+- [#1041](https://github.com/cashubtc/cdk/pull/1041) - feat(cdk): add quote_id field to transactions for quote tracking
+- [#1038](https://github.com/cashubtc/cdk/pull/1038) - fix: sig error code
+- [#1037](https://github.com/cashubtc/cdk/pull/1037) - Fix postgres migration prefixes
+- [#1032](https://github.com/cashubtc/cdk/pull/1032) - Update the signatory.proto file to match NUT-XXX
+- [#932](https://github.com/cashubtc/cdk/pull/932) - FFI bindings for Wallet
+
+
+### New
+- [#1028](https://github.com/cashubtc/cdk/pull/1028) - chore: move `pay_request` logic into cdk lib
+- [#1029](https://github.com/cashubtc/cdk/issues/1029) - Feature request cdk-wallet: store P2PK key and lookup automatically on token receive
+- [#1064](https://github.com/cashubtc/cdk/pull/1064) - feat: per-request tor circuits with arti
+- [#1060](https://github.com/cashubtc/cdk/pull/1060) - fix: replace std::time with web_time for wasm
+- [#1055](https://github.com/cashubtc/cdk/pull/1055) - Include supported amounts instead of assuming the power of 2
+- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive
+- [#1045](https://github.com/cashubtc/cdk/pull/1045) - feat: store melt_request
+- [#1031](https://github.com/cashubtc/cdk/pull/1031) - feat:  prefer async melt
+
+### Ongoing 
+- [#995](https://github.com/cashubtc/cdk/pull/995) - onchain
+- [#1005](https://github.com/cashubtc/cdk/pull/1005) - feat: redact secrets from Debug and Display impls
+- [#1006](https://github.com/cashubtc/cdk/pull/1006) - Minor file organization
+- [#1003](https://github.com/cashubtc/cdk/pull/1003) - feat: LDK Lightning KVStore support with PostgreSQL integration
+
+
+- [#1000](https://github.com/cashubtc/cdk/issues/1000) - Move most of this pay request logic to a cdk lib fn
+
+## Needs Review
+- [#1001](https://github.com/cashubtc/cdk/pull/1001) - MultiMintWallet Refactor

+ 47 - 0
meetings/2025-09-17-agenda.md

@@ -0,0 +1,47 @@
+Sep 17th 2025 15:00 UTC
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+### Merged
+- [#1028](https://github.com/cashubtc/cdk/pull/1028) - chore: move `pay_request` logic into cdk lib
+- [#1060](https://github.com/cashubtc/cdk/pull/1060) - fix: replace std::time with web_time for wasm
+- [#1045](https://github.com/cashubtc/cdk/pull/1045) - feat: store melt_request
+- [#1000](https://github.com/cashubtc/cdk/issues/1000) - Move most of this pay request logic to a cdk lib fn
+- [#1079](https://github.com/cashubtc/cdk/pull/1079) - refactor: check mint request
+- [#1078](https://github.com/cashubtc/cdk/pull/1078) - Fixed bug with postgres reconnection in the connection pool
+- [#1077](https://github.com/cashubtc/cdk/pull/1077) - Store last pay index
+- [#1075](https://github.com/cashubtc/cdk/pull/1075) - feat(cdk): add amount_mintable method and improve mint quote validation
+- [#1073](https://github.com/cashubtc/cdk/pull/1073) - Improve web interface with dynamic status, navigation, and mobile support
+- [#1071](https://github.com/cashubtc/cdk/pull/1071) - feat: update redb
+- [#1070](https://github.com/cashubtc/cdk/pull/1070) - fix: keyset max order checked
+- [#1069](https://github.com/cashubtc/cdk/pull/1069) - Fixed error with wrong placeholder
+- [#1068](https://github.com/cashubtc/cdk/pull/1068) - Add `resolve_dns_txt` to HttpTransport and MintConnector
+- [#1062](https://github.com/cashubtc/cdk/pull/1062) - fix: make http wallet subscriptions wasm compatible
+
+### Can merge
+- [#1064](https://github.com/cashubtc/cdk/pull/1064) - feat: per-request tor circuits with arti
+- [#1001](https://github.com/cashubtc/cdk/pull/1001) - MultiMintWallet Refactor
+
+## New
+- [#1067](https://github.com/cashubtc/cdk/pull/1067) - Nutxx ohttp
+- [#1076](https://github.com/cashubtc/cdk/pull/1076) - feat: balance for units cdk-cli
+- [#1081](https://github.com/cashubtc/cdk/pull/1081) - fix: config overwrite on start up
+- [#1084](https://github.com/cashubtc/cdk/pull/1084) - optional client identity in grpc payment processor
+- [#1085](https://github.com/cashubtc/cdk/pull/1085) - Fix Async FFI Constructors
+
+
+### Ongoing 
+- [#1055](https://github.com/cashubtc/cdk/pull/1055) - Include supported amounts instead of assuming the power of 2
+- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive
+- [#1031](https://github.com/cashubtc/cdk/pull/1031) - feat:  prefer async melt
+
+### Discussion 
+
+- Event based payment processor refactor (@thesimplekid)
+- Event based wallet (@crodas / @thesimplekid)
+- Web socket refactor (@crodas)
+
+
+
+
+## Needs Review

+ 49 - 0
meetings/2025-09-24-agenda.md

@@ -0,0 +1,49 @@
+Sep 24th 2025 15:00 UTC
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+
+
+
+## Merged
+- [#1067](https://github.com/cashubtc/cdk/pull/1067) - Nutxx ohttp
+- [#1076](https://github.com/cashubtc/cdk/pull/1076) - feat: balance for units cdk-cli
+- [#1081](https://github.com/cashubtc/cdk/pull/1081) - fix: config overwrite on start up
+- [#1084](https://github.com/cashubtc/cdk/pull/1084) - optional client identity in grpc payment processor
+- [#1085](https://github.com/cashubtc/cdk/pull/1085) - Fix Async FFI Constructors
+- [#1055](https://github.com/cashubtc/cdk/pull/1055) - Include supported amounts instead of assuming the power of 2
+- [#1090](https://github.com/cashubtc/cdk/pull/1090) - fix: error response detail
+- [#1091](https://github.com/cashubtc/cdk/pull/1091) - fix: add free space to auth test
+- [#1095](https://github.com/cashubtc/cdk/pull/1095) - Psgl auth db
+- [#1096](https://github.com/cashubtc/cdk/pull/1096) - feat: remove redis cache
+- [#1097](https://github.com/cashubtc/cdk/pull/1097) - Remove generated files
+- [#1099](https://github.com/cashubtc/cdk/pull/1099) - fix(cdk): improve error handling when adding mint to MultiMintWallet
+- [#1101](https://github.com/cashubtc/cdk/pull/1101) - add FFI types for NUT-04 and NUT-05
+- [#1102](https://github.com/cashubtc/cdk/pull/1102) - Remove cashu ffi
+- [#1103](https://github.com/cashubtc/cdk/pull/1103) - feat: remove features from auth
+- [#1108](https://github.com/cashubtc/cdk/pull/1108) - feat(docker): add LDK Node mint service with dedicated Docker setup
+
+## Released
+- https://github.com/cashubtc/cdk/releases/tag/v0.13.0
+
+
+### Updated
+- [#1064](https://github.com/cashubtc/cdk/pull/1064) - feat: per-request tor circuits with arti
+
+### Ongoing 
+
+- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive
+- [#1031](https://github.com/cashubtc/cdk/pull/1031) - feat:  prefer async melt
+
+
+### New
+- [#1111](https://github.com/cashubtc/cdk/issues/1111) - cdk wallet not closing ws connections
+- [#1105](https://github.com/cashubtc/cdk/issues/1105) - Workspace cashu dep should not have default features
+- [#1104](https://github.com/cashubtc/cdk/issues/1104) - New websocket subscriptions should check mint quote states
+- [#1082](https://github.com/cashubtc/cdk/issues/1082) - Broken Link for cdk-python in the cdk-ffi/ README -> goes to 404
+- [#1109](https://github.com/cashubtc/cdk/pull/1109) - fix: handle fiat melt amount conversions
+- [#1098](https://github.com/cashubtc/cdk/pull/1098) - Introduce a generic pubsub mod in `cdk-common`
+- [#1100](https://github.com/cashubtc/cdk/pull/1100) - NUT-XX: Cairo Spending Conditions implementation
+- [#1110](https://github.com/cashubtc/cdk/pull/1110) - feat: optimize SQL balance calculation
+- [#1112](https://github.com/cashubtc/cdk/pull/1112) - Check change unique
+

+ 45 - 0
meetings/2025-10-08-agenda.md

@@ -0,0 +1,45 @@
+Oct 8th 2025 15:00 UTC
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+## Merged
+- [#1064](https://github.com/cashubtc/cdk/pull/1064) - feat: per-request tor circuits with arti
+- [#1111](https://github.com/cashubtc/cdk/issues/1111) - cdk wallet not closing ws connections
+-  [#1109](https://github.com/cashubtc/cdk/pull/1109) - fix: handle fiat melt amount conversions
+- [#1098](https://github.com/cashubtc/cdk/pull/1098) - Introduce a generic pubsub mod in `cdk-common`
+- [#1112](https://github.com/cashubtc/cdk/pull/1112) - Check change unique
+- [#1142](https://github.com/cashubtc/cdk/pull/1142) - Split uniffi types into multiple mods
+- [#1144](https://github.com/cashubtc/cdk/pull/1144) - Fix bug with websocket close
+- [#1146](https://github.com/cashubtc/cdk/pull/1146) - Add MultiMintWallet check and wait for mint quotes
+- [#1147](https://github.com/cashubtc/cdk/pull/1147) - Make sorting Transactions a stable sort
+- [#1148](https://github.com/cashubtc/cdk/pull/1148) - Allow passing metadata to a melt
+- [#1152](https://github.com/cashubtc/cdk/pull/1152) - feat: optimize SQL balance calculation
+- [#1155](https://github.com/cashubtc/cdk/pull/1155) - feat(cdk): add payment request and proof to transaction records
+- [#1158](https://github.com/cashubtc/cdk/pull/1158) - fix(cashu): skip serializing empty NUT15 settings in mint info
+- [#1159](https://github.com/cashubtc/cdk/pull/1159) - mintd: remove non-existent stdout logging from docs
+- [#1161](https://github.com/cashubtc/cdk/pull/1161) - fix(database): add parent directory validation before database creation
+- [#1167](https://github.com/cashubtc/cdk/pull/1167) - chore: nostr-sdk as workspace dep
+- [#1168](https://github.com/cashubtc/cdk/pull/1168) - chore: remove ctor
+
+
+## Released
+- https://github.com/cashubtc/cdk/releases/tag/v0.13.1
+
+
+### Updated
+
+
+### Ongoing 
+
+- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive
+
+### New
+- [#1166](https://github.com/cashubtc/cdk/pull/1166) - Read the latest mint quote status in a transaction to avoid race conditions
+- [#1164](https://github.com/cashubtc/cdk/pull/1164) - Improve add transaction
+- [#1127](https://github.com/cashubtc/cdk/pull/1127) - rename ln settings in toml configuration
+- [#1118](https://github.com/cashubtc/cdk/pull/1118) - feat: uniffi bindings for golang
+- [#1132](https://github.com/cashubtc/cdk/pull/1132) - Quote id as lookup
+- [#1153](https://github.com/cashubtc/cdk/pull/1153) - Add cdk-mintd module and package
+- [#1171](https://github.com/cashubtc/cdk/pull/1171) - Add Dart Bindings Support
+- [#1173](https://github.com/cashubtc/cdk/pull/1173) - Prefer async
+

+ 39 - 0
meetings/2025-10-15-agenda.md

@@ -0,0 +1,39 @@
+Oct 15th 2025 15:00 UTC
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+## Merged
+- [#1166](https://github.com/cashubtc/cdk/pull/1166) - Read the latest mint quote status in a transaction to avoid race conditions
+- [#1164](https://github.com/cashubtc/cdk/pull/1164) - Improve add transaction
+- [#1177](https://github.com/cashubtc/cdk/pull/1177) - Configure internal Wallets of a MultiMintWallet
+- [#1179](https://github.com/cashubtc/cdk/pull/1179) - Remove the `amounts` from amounts
+- [#1187](https://github.com/cashubtc/cdk/pull/1187) - feat: swap tests
+- [#1188](https://github.com/cashubtc/cdk/pull/1188) - feat(cdk): add melt quote state transition validation
+- [#1166](https://github.com/cashubtc/cdk/pull/1166) - Read the latest mint quote status in a transaction to avoid race conditions
+
+
+### Ongoing 
+
+- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive
+- [#1127](https://github.com/cashubtc/cdk/pull/1127) - rename ln settings in toml configuration
+- [#1118](https://github.com/cashubtc/cdk/pull/1118) - feat: uniffi bindings for golang
+- [#1132](https://github.com/cashubtc/cdk/pull/1132) - Quote id as lookup
+- [#1153](https://github.com/cashubtc/cdk/pull/1153) - Add cdk-mintd module and package
+- [#1171](https://github.com/cashubtc/cdk/pull/1171) - Add Dart Bindings Support
+- [#1149](https://github.com/cashubtc/cdk/pull/1149) - Update FFI Database Objects to Records
+
+### New
+#### Issues 
+- [#1191](https://github.com/cashubtc/cdk/issues/1191) - mintd: Error: Internal Empty SQL > v0.11.1
+- [#1189](https://github.com/cashubtc/cdk/issues/1189) - wallet add db fn to get pending melt quotes
+- [#1172](https://github.com/cashubtc/cdk/issues/1172) - cdk-cli: mint pending command maybe broken
+- [#1180](https://github.com/cashubtc/cdk/issues/1180) - Stored proofs remain in pending state when melt fails due to mint's maximum proof limit
+#### PRs
+- [#1181](https://github.com/cashubtc/cdk/pull/1181) - Deterministic Currency Unit Derivation Paths
+- [#1182](https://github.com/cashubtc/cdk/pull/1182) - ehash: add support for mining share mint quotes
+- [#1190](https://github.com/cashubtc/cdk/pull/1190) - feat(cashu): add NUT-26 bech32m encoding for payment requests
+- [#1183](https://github.com/cashubtc/cdk/pull/1183) - Swap saga
+- [#1186](https://github.com/cashubtc/cdk/pull/1186) - Melt saga
+- [#1192](https://github.com/cashubtc/cdk/pull/1192) - Melt async saga
+
+

+ 57 - 0
meetings/2025-10-27-agenda.md

@@ -0,0 +1,57 @@
+# CDK Development Meeting
+
+Oct 27 2025 19:58 UTC
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+## Merged
+
+- [#1213](https://github.com/cashubtc/cdk/pull/1213) - ffix: improve Melted error handling and add debug logging
+- [#1207](https://github.com/cashubtc/cdk/pull/1207) - feat: update stable rust
+- [#1183](https://github.com/cashubtc/cdk/pull/1183) - Swap saga
+- [#1149](https://github.com/cashubtc/cdk/pull/1149) - Update FFI Database Objects to Records
+
+## Ongoing
+
+- [#1196](https://github.com/cashubtc/cdk/pull/1196) - feat(ci): add merge queue workflow and simplify clippy checks
+- [#1192](https://github.com/cashubtc/cdk/pull/1192) - Melt async saga
+- [#1190](https://github.com/cashubtc/cdk/pull/1190) - feat(cashu): add NUT-26 bech32m encoding for payment requests
+- [#1186](https://github.com/cashubtc/cdk/pull/1186) - Melt saga
+- [#1182](https://github.com/cashubtc/cdk/pull/1182) - ehash: add support for mining share mint quotes
+- [#1181](https://github.com/cashubtc/cdk/pull/1181) - Deterministic Currency Unit Derivation Paths
+- [#1173](https://github.com/cashubtc/cdk/pull/1173) - Prefer async
+- [#1171](https://github.com/cashubtc/cdk/pull/1171) - Add Dart Bindings Support
+- [#1153](https://github.com/cashubtc/cdk/pull/1153) - Add cdk-mintd module and package
+- [#1132](https://github.com/cashubtc/cdk/pull/1132) - Quote id as lookup
+- [#1127](https://github.com/cashubtc/cdk/pull/1127) - rename ln settings in toml configuration
+- [#1118](https://github.com/cashubtc/cdk/pull/1118) - feat: uniffi bindings for golang
+- [#1100](https://github.com/cashubtc/cdk/pull/1100) - NUT-XX: Cairo Spending Conditions implementation
+- [#1067](https://github.com/cashubtc/cdk/pull/1067) - Nutxx ohttp
+- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive
+- [#1049](https://github.com/cashubtc/cdk/pull/1049) - feat: ldk-node run mintd
+
+## New
+
+### Issues
+
+- [#1209](https://github.com/cashubtc/cdk/issues/1209) - Fix issue with websocket
+- [#1205](https://github.com/cashubtc/cdk/issues/1205) - cdk-kotlin we need to check the page size is set correctly there is a change coming up to the google play store
+- [#1203](https://github.com/cashubtc/cdk/issues/1203) - Update the Database trait for wallet and support for transactions
+- [#1199](https://github.com/cashubtc/cdk/issues/1199) - Remove auth feature and just always include auth fns
+
+### PRs
+
+- [#1217](https://github.com/cashubtc/cdk/pull/1217) - Typo fix
+- [#1216](https://github.com/cashubtc/cdk/pull/1216) - Add Spark SDK as a nodeless backend for CDK mints
+- [#1215](https://github.com/cashubtc/cdk/pull/1215) - feat: backport bot
+- [#1214](https://github.com/cashubtc/cdk/pull/1214) - feat(cdk-payment-processor): add currency unit parameter to make_payment
+- [#1212](https://github.com/cashubtc/cdk/pull/1212) - Various mint fixes for swap. SIG_INPUTS+SIG_ALL, locktimes, P2PK+HTLC. Also updates the SIG_ALL message for amount-switching
+- [#1211](https://github.com/cashubtc/cdk/pull/1211) - Regtest setup
+- [#1210](https://github.com/cashubtc/cdk/pull/1210) - test: add mutation testing infrastructure
+- [#1208](https://github.com/cashubtc/cdk/pull/1208) - Sig all fixes
+- [#1206](https://github.com/cashubtc/cdk/pull/1206) - fix: sig_all_msg_to_sign
+- [#1204](https://github.com/cashubtc/cdk/pull/1204) - Add database transaction trait for cdk wallet
+- [#1202](https://github.com/cashubtc/cdk/pull/1202) - Onchain
+- [#1201](https://github.com/cashubtc/cdk/pull/1201) - feat: npubcash
+- [#1200](https://github.com/cashubtc/cdk/pull/1200) - feat: optimize pending mint quotes query performance
+- [#1198](https://github.com/cashubtc/cdk/pull/1198) - fix: check the removed_ys argument before creating the delete query

+ 62 - 0
meetings/2025-10-29-agenda.md

@@ -0,0 +1,62 @@
+# CDK Development Meeting
+
+Oct 29 2025 12:41 UTC
+
+Meeting Link: https://meet.fulmo.org/cdk-dev
+
+## Merged
+
+- [#1237](https://github.com/cashubtc/cdk/pull/1237) - feat(cdk-lnbits): add websocket reconnection with exponential backoff
+- [#1230](https://github.com/cashubtc/cdk/pull/1230) - added missing env params in docker-compose.ldk-node.yaml
+- [#1220](https://github.com/cashubtc/cdk/pull/1220) - fix: con group
+- [#1219](https://github.com/cashubtc/cdk/pull/1219) - Backports
+- [#1218](https://github.com/cashubtc/cdk/pull/1218) - feat: add auto generated meetings template
+- [#1215](https://github.com/cashubtc/cdk/pull/1215) - feat: backport bot
+- [#1213](https://github.com/cashubtc/cdk/pull/1213) - ffix: improve Melted error handling and add debug logging
+- [#1207](https://github.com/cashubtc/cdk/pull/1207) - feat: update stable rust
+- [#1183](https://github.com/cashubtc/cdk/pull/1183) - Swap saga
+- [#1149](https://github.com/cashubtc/cdk/pull/1149) - Update FFI Database Objects to Records
+
+## Ongoing
+
+- [#1200](https://github.com/cashubtc/cdk/pull/1200) - feat: optimize pending mint quotes query performance
+- [#1198](https://github.com/cashubtc/cdk/pull/1198) - fix: check the removed_ys argument before creating the delete query
+- [#1196](https://github.com/cashubtc/cdk/pull/1196) - feat(ci): add merge queue workflow and simplify clippy checks
+- [#1190](https://github.com/cashubtc/cdk/pull/1190) - feat(cashu): add NUT-26 bech32m encoding for payment requests
+- [#1186](https://github.com/cashubtc/cdk/pull/1186) - Melt saga
+- [#1182](https://github.com/cashubtc/cdk/pull/1182) - ehash: add support for mining share mint quotes
+- [#1181](https://github.com/cashubtc/cdk/pull/1181) - Deterministic Currency Unit Derivation Paths
+- [#1173](https://github.com/cashubtc/cdk/pull/1173) - Prefer async
+- [#1171](https://github.com/cashubtc/cdk/pull/1171) - Add Dart Bindings Support
+- [#1153](https://github.com/cashubtc/cdk/pull/1153) - Add cdk-mintd module and package
+- [#1132](https://github.com/cashubtc/cdk/pull/1132) - Quote id as lookup
+- [#1127](https://github.com/cashubtc/cdk/pull/1127) - rename ln settings in toml configuration
+- [#1118](https://github.com/cashubtc/cdk/pull/1118) - feat: uniffi bindings for golang
+- [#1067](https://github.com/cashubtc/cdk/pull/1067) - Nutxx ohttp
+- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive
+- [#1049](https://github.com/cashubtc/cdk/pull/1049) - feat: ldk-node run mintd
+- [#1011](https://github.com/cashubtc/cdk/pull/1011) - fix: migrate check_mint_quote_paid fn from ln.rs to mod.rs
+- [#1010](https://github.com/cashubtc/cdk/pull/1010) - adding more LDK configuration settings
+
+## New
+
+### Issues
+
+- [#1209](https://github.com/cashubtc/cdk/issues/1209) - Fix issue with websocket
+- [#1205](https://github.com/cashubtc/cdk/issues/1205) - cdk-kotlin we need to check the page size is set correctly there is a change coming up to the google play store
+- [#1203](https://github.com/cashubtc/cdk/issues/1203) - Update the Database trait for wallet and support for transactions
+
+### PRs
+
+- [#1216](https://github.com/cashubtc/cdk/pull/1216) - Add Spark SDK as a nodeless backend for CDK mints
+- [#1214](https://github.com/cashubtc/cdk/pull/1214) - feat(cdk-payment-processor): add currency unit parameter to make_payment
+- [#1212](https://github.com/cashubtc/cdk/pull/1212) - Various mint fixes for swap (and now melt also). SIG_INPUTS+SIG_ALL, locktimes, P2PK+HTLC. Also updates the SIG_ALL message for amount-switching
+- [#1211](https://github.com/cashubtc/cdk/pull/1211) - Regtest setup
+- [#1210](https://github.com/cashubtc/cdk/pull/1210) - test: add mutation testing infrastructure
+- [#1208](https://github.com/cashubtc/cdk/pull/1208) - Sig all fixes
+- [#1204](https://github.com/cashubtc/cdk/pull/1204) - Add database transaction trait for cdk wallet
+- [#1202](https://github.com/cashubtc/cdk/pull/1202) - Onchain
+- [#1201](https://github.com/cashubtc/cdk/pull/1201) - feat: npubcash
+
+Template processor: https://github.com/thesimplekid/cdk-template-payment-processor
+Spark payment processor: https://github.com/thesimplekid/cdk-spark-payment-prcoessor

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.