diff --git a/.backportrc.json b/.backportrc.json new file mode 100644 index 000000000..7978aa601 --- /dev/null +++ b/.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" +} diff --git a/.cargo-mutants.toml b/.cargo-mutants.toml new file mode 100644 index 000000000..fc9ec3ce1 --- /dev/null +++ b/.cargo-mutants.toml @@ -0,0 +1,9 @@ +# Cargo mutants configuration +# See: https://mutants.rs/ + +# Skip simple getters that are trivially correct and tested by integration tests +# These mutations would be caught by integration tests like test_split_with_fee +exclude_re = [ + "cashu/src/amount.rs.*FeeAndAmounts::fee", + "cashu/src/amount.rs.*FeeAndAmounts::amounts", +] diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 000000000..c2a1160e7 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,43 @@ +# Mutation Testing Configuration for CDK +# Phase 1: Focus on cashu crate only + +# Start with cashu crate only - exclude other crates initially +exclude_globs = [ + "crates/cdk-*/**", # Exclude other crates initially + "**/tests/**", # Don't mutate test code + "**/benches/**", # Don't mutate benchmarks +] + +# Reasonable timeout to catch hangs (5 minutes minimum) +minimum_test_timeout = 300 + +# Skip specific mutations that cause infinite loops +# These mutations create scenarios where loops never terminate or recursive functions never return. +# Format: "file.rs:line.*pattern" +exclude_re = [ + # dhke.rs:61 - Mutating counter += to *= causes infinite loop (counter stays 0) + "crates/cashu/src/dhke.rs:61:.*replace \\+= with \\*=", + + # amount.rs:108 - Mutating % to / in split causes infinite loop + "crates/cashu/src/amount.rs:108:.*replace % with /", + + # amount.rs:100 - split() returning empty vec causes infinite loops + "crates/cashu/src/amount.rs:100:.*replace.*split.*with vec!\\[\\]", + "crates/cashu/src/amount.rs:100:.*replace.*split.*with vec!\\[Default", + + # amount.rs:203 - checked_add returning Some(Default/0) causes infinite increment loops + "crates/cashu/src/amount.rs:203:.*replace.*checked_add.*with Some\\(Default", + + # amount.rs:226 - try_sum returning Ok(Default/0) causes infinite loops + "crates/cashu/src/amount.rs:226:.*replace.*try_sum.*with Ok\\(Default", + + # amount.rs:288 - From returning Default/0 causes infinite loops + "crates/cashu/src/amount.rs:288:.*replace.*from.*with Default", + + # amount.rs:331 - Sub returning Default/0 causes infinite loops + "crates/cashu/src/amount.rs:331:.*replace.*sub.*with Default", + + # Trivial getters - not worth testing + "FeeAndAmounts::fee", + "FeeAndAmounts::amounts", +] diff --git a/.github/ISSUE_TEMPLATE/mutation-testing.md b/.github/ISSUE_TEMPLATE/mutation-testing.md new file mode 100644 index 000000000..53dea1444 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/mutation-testing.md @@ -0,0 +1,58 @@ +--- +name: Mutation Testing Improvement +about: Track improvements to mutation test coverage +title: '[Mutation] ' +labels: 'mutation-testing, enhancement' +assignees: '' + +--- + +## Mutation Details + +**File:** `crates/cashu/src/...` +**Line:** 123 +**Mutation:** replace foo() with bar() + +**Current Status:** MISSED + +## Why This Matters + + + +## Proposed Fix + + + +### Test Strategy + +- [ ] Add negative test case +- [ ] Add edge case test +- [ ] Add integration test +- [ ] Other: ___________ + +### Expected Test + +```rust +#[test] +fn test_() { + // Test that ensures this mutation would be caught +} +``` + +## Verification + +After implementing the fix: + +```bash +# Run mutation test on specific file +cargo mutants --file crates/cashu/src/... + +# Or run mutation test on the specific function +cargo mutants --file crates/cashu/src/... --re "function_name" +``` + +Expected result: Mutation should be **CAUGHT** ✅ + +## Related + + diff --git a/.github/scripts/generate-agenda.sh b/.github/scripts/generate-agenda.sh new file mode 100755 index 000000000..432dd95f3 --- /dev/null +++ b/.github/scripts/generate-agenda.sh @@ -0,0 +1,148 @@ +#!/bin/bash +set -e + +# Configuration +REPO="${GITHUB_REPOSITORY:-cashubtc/cdk}" +DAYS_BACK="${DAYS_BACK:-7}" +MEETING_LINK="https://meet.fulmo.org/cdk-dev" +OUTPUT_DIR="meetings" + +# Calculate date range (last 7 days) +SINCE_DATE=$(date -d "$DAYS_BACK days ago" -u +"%Y-%m-%dT%H:%M:%SZ") +MEETING_DATE=$(date -u +"%b %d %Y 15:00 UTC") +FILE_DATE=$(date -u +"%Y-%m-%d") + +echo "Generating meeting agenda for $MEETING_DATE" +echo "Fetching data since $SINCE_DATE" + +# Function to format PR/issue list +format_list() { + local items="$1" + if [ -z "$items" ]; then + echo "- None" + else + echo "$items" | while IFS=$'\t' read -r number title url; do + echo "- [#$number]($url) - $title" + done + fi +} + +# Fetch merged PRs +echo "Fetching merged PRs..." +MERGED_PRS=$(gh pr list \ + --repo "$REPO" \ + --state merged \ + --search "merged:>=$SINCE_DATE" \ + --json number,title,url \ + --jq '.[] | [.number, .title, .url] | @tsv' \ + 2>/dev/null || echo "") + +# Fetch recently active PRs (updated in last week, but not newly created) +echo "Fetching recently active PRs..." +RECENTLY_ACTIVE_PRS=$(gh pr list \ + --repo "$REPO" \ + --state open \ + --search "updated:>=$SINCE_DATE -created:>=$SINCE_DATE" \ + --json number,title,url \ + --jq '.[] | [.number, .title, .url] | @tsv' \ + 2>/dev/null || echo "") + +# Fetch new PRs (opened in the last week) +echo "Fetching new PRs..." +NEW_PRS=$(gh pr list \ + --repo "$REPO" \ + --state open \ + --search "created:>=$SINCE_DATE" \ + --json number,title,url \ + --jq '.[] | [.number, .title, .url] | @tsv' \ + 2>/dev/null || echo "") + +# Fetch new issues +echo "Fetching new issues..." +NEW_ISSUES=$(gh issue list \ + --repo "$REPO" \ + --state open \ + --search "created:>=$SINCE_DATE" \ + --json number,title,url \ + --jq '.[] | [.number, .title, .url] | @tsv' \ + 2>/dev/null || echo "") + +# Fetch discussion items (labeled with meeting-discussion) +echo "Fetching discussion items..." +DISCUSSION_PRS=$(gh pr list \ + --repo "$REPO" \ + --state open \ + --label "meeting-discussion" \ + --json number,title,url \ + --jq '.[] | [.number, .title, .url] | @tsv' \ + 2>/dev/null || echo "") + +DISCUSSION_ISSUES=$(gh issue list \ + --repo "$REPO" \ + --state open \ + --label "meeting-discussion" \ + --json number,title,url \ + --jq '.[] | [.number, .title, .url] | @tsv' \ + 2>/dev/null || echo "") + +# Combine discussion items (PRs and issues) +DISCUSSION_ITEMS=$(printf "%s\n%s" "$DISCUSSION_PRS" "$DISCUSSION_ISSUES" | grep -v '^$' || echo "") + +# Generate markdown +AGENDA=$(cat < "$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 diff --git a/.github/templates/failed-backport-issue.md b/.github/templates/failed-backport-issue.md new file mode 100644 index 000000000..9e0b9c7af --- /dev/null +++ b/.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. diff --git a/.github/workflows/autoclose.yml b/.github/workflows/autoclose.yml new file mode 100644 index 000000000..9939fc97e --- /dev/null +++ b/.github/workflows/autoclose.yml @@ -0,0 +1,27 @@ +name: Close inactive issues and PRs +on: + schedule: + - cron: "30 1 * * *" + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v10 + with: + days-before-issue-stale: 60 + days-before-issue-close: 21 + stale-issue-label: "stale" + stale-issue-message: "This issue is stale because it has been open for 60 days with no activity." + close-issue-message: "This issue was closed because it has been inactive for 21 days since being marked as stale." + days-before-pr-stale: 60 + days-before-pr-close: 21 + stale-pr-label: "stale" + stale-pr-message: "This PR is stale because it has been open for 60 days with no activity." + close-pr-message: "This PR was closed because it has been inactive for 21 days since being marked as stale." + exempt-issue-labels: "keep-open" + exempt-pr-labels: "keep-open" + repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 000000000..57e88a9f8 --- /dev/null +++ b/.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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6a43fae51..bf915677d 100644 --- a/.github/workflows/ci.yml +++ b/.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] @@ -12,17 +14,6 @@ env: CARGO_TERM_COLOR: always jobs: - self-care: - name: Flake self-check - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@v4 - - name: Check Nix flake inputs - uses: DeterminateSystems/flake-checker-action@v9 - with: - fail-mode: true - pre-commit-checks: name: "Cargo fmt, typos" runs-on: ubuntu-latest @@ -30,42 +21,51 @@ 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-${{ steps.flake-hash.outputs.hash }}" - name: Cargo fmt - run: | - nix develop -i -L .#nightly --command bash -c ' - # Force use of Nix-provided rustfmt - export RUSTFMT=$(command -v rustfmt) - cargo fmt --check - ' + run: nix develop -i -L .#stable --command cargo fmt --check - name: typos - run: nix develop -i -L .#nightly --command typos - + run: nix develop -i -L .#stable --command typos + examples: name: "Run examples" runs-on: ubuntu-latest timeout-minutes: 30 - needs: [pre-commit-checks, clippy] + needs: pre-commit-checks strategy: + fail-fast: true matrix: - build-args: - [ - mint-token, - melt-token, - p2pk, - proof-selection, - wallet - ] + build-args: [mint-token, melt-token, p2pk, proof-selection, wallet] steps: - name: checkout uses: actions/checkout@v4 + - name: Get flake hash + id: flake-hash + run: echo "hash=$(sha256sum flake.lock | cut -d' ' -f1 | cut -c1-8)" >> $GITHUB_OUTPUT - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" - name: Run example run: nix develop -i -L .#stable --command cargo r --example ${{ matrix.build-args }} @@ -75,79 +75,97 @@ jobs: timeout-minutes: 30 needs: pre-commit-checks strategy: + fail-fast: true matrix: - build-args: - [ + build-args: [ + # Core crate testing -p cashu, -p cashu --no-default-features, -p cashu --no-default-features --features wallet, -p cashu --no-default-features --features mint, - -p cashu --no-default-features --features "mint swagger", -p cashu --no-default-features --features auth, - -p cashu --no-default-features --features "mint auth", - -p cashu --no-default-features --features "wallet auth", -p cdk-common, -p cdk-common --no-default-features, -p cdk-common --no-default-features --features wallet, -p cdk-common --no-default-features --features mint, - -p cdk-common --no-default-features --features "mint swagger", - -p cdk-common --no-default-features --features "auth", - -p cdk-common --no-default-features --features "mint auth", - -p cdk-common --no-default-features --features "wallet auth", + -p cdk-common --no-default-features --features auth, -p cdk, -p cdk --no-default-features, -p cdk --no-default-features --features wallet, -p cdk --no-default-features --features mint, - -p cdk --no-default-features --features "mint swagger", -p cdk --no-default-features --features auth, - -p cdk --features auth, - -p cdk --no-default-features --features "auth mint", - -p cdk --no-default-features --features "auth wallet", + -p cdk-sql-common, + -p cdk-sql-common --no-default-features --features wallet, + -p cdk-sql-common --no-default-features --features mint, + + # Database and infrastructure crates -p cdk-redb, -p cdk-sqlite, -p cdk-sqlite --features sqlcipher, + + # HTTP/API layer - consolidated + -p cdk-axum, -p cdk-axum --no-default-features, - -p cdk-axum --no-default-features --features swagger, -p cdk-axum --no-default-features --features redis, -p cdk-axum --no-default-features --features "redis swagger", - -p cdk-axum --no-default-features --features "auth redis", - -p cdk-axum, + + # Lightning backends -p cdk-cln, -p cdk-lnd, -p cdk-lnbits, -p cdk-fake-wallet, -p cdk-payment-processor, + -p cdk-ldk-node, + + -p cdk-signatory, + -p cdk-mint-rpc, + + -p cdk-prometheus, + + # FFI bindings + -p cdk-ffi, + -p cdk-ffi --no-default-features, + + # Binaries --bin cdk-cli, --bin cdk-cli --features sqlcipher, --bin cdk-cli --features redb, - --bin cdk-cli --features "sqlcipher redb", --bin cdk-mintd, --bin cdk-mintd --features redis, - --bin cdk-mintd --features "redis swagger", --bin cdk-mintd --features sqlcipher, - --bin cdk-mintd --no-default-features --features lnd, - --bin cdk-mintd --no-default-features --features cln, - --bin cdk-mintd --no-default-features --features lnbits, - --bin cdk-mintd --no-default-features --features fakewallet, - --bin cdk-mintd --no-default-features --features grpc-processor, - --bin cdk-mintd --no-default-features --features "management-rpc lnd", - --bin cdk-mintd --no-default-features --features "management-rpc cln", - --bin cdk-mintd --no-default-features --features "management-rpc lnbits", - --bin cdk-mintd --no-default-features --features "management-rpc grpc-processor", - --bin cdk-mintd --no-default-features --features "swagger lnd", - --bin cdk-mintd --no-default-features --features "swagger cln", - --bin cdk-mintd --no-default-features --features "swagger lnbits", - --bin cdk-mintd --no-default-features --features "auth lnd", - --bin cdk-mintd --no-default-features --features "auth cln", + --bin cdk-mintd --no-default-features --features lnd --features sqlite, + --bin cdk-mintd --no-default-features --features cln --features postgres, + --bin cdk-mintd --no-default-features --features lnbits --features sqlite, + --bin cdk-mintd --no-default-features --features fakewallet --features sqlite, + --bin cdk-mintd --no-default-features --features grpc-processor --features sqlite, + --bin cdk-mintd --no-default-features --features "management-rpc lnd sqlite", + --bin cdk-mintd --no-default-features --features cln --features sqlite, + --bin cdk-mintd --no-default-features --features lnd --features postgres, + --bin cdk-mintd --no-default-features --features lnbits --features postgres, + --bin cdk-mintd --no-default-features --features fakewallet --features postgres, + --bin cdk-mintd --no-default-features --features grpc-processor --features postgres, + --bin cdk-mintd --no-default-features --features "management-rpc cln postgres", + --bin cdk-mintd --no-default-features --features "auth sqlite fakewallet", + --bin cdk-mintd --no-default-features --features "auth postgres lnd", --bin cdk-mint-cli, ] steps: - name: checkout uses: actions/checkout@v4 + - name: Get flake hash + id: flake-hash + run: echo "hash=$(sha256sum flake.lock | cut -d' ' -f1 | cut -c1-8)" >> $GITHUB_OUTPUT - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" - name: Clippy run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings - name: Test @@ -157,100 +175,163 @@ jobs: name: "Integration regtest tests" runs-on: ubuntu-latest timeout-minutes: 30 - needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest] + needs: pre-commit-checks strategy: + fail-fast: true matrix: - build-args: - [ - -p cdk-integration-tests, - ] - database: - [ - SQLITE, - ] + build-args: [-p cdk-integration-tests] + database: [SQLITE, POSTGRES] 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: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" - name: Test run: nix develop -i -L .#stable --command just itest ${{ matrix.database }} - + fake-mint-itest: name: "Integration fake mint tests" runs-on: ubuntu-latest timeout-minutes: 30 - needs: [pre-commit-checks, clippy] + needs: pre-commit-checks strategy: + fail-fast: true matrix: - build-args: - [ - -p cdk-integration-tests, - ] - database: - [ - SQLITE, - ] + build-args: [-p cdk-integration-tests] + database: [SQLITE] 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: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" - name: Clippy run: nix develop -i -L .#stable --command cargo clippy -- -D warnings - name: Test fake auth mint run: nix develop -i -L .#stable --command just fake-mint-itest ${{ matrix.database }} - + pure-itest: name: "Integration fake wallet tests" runs-on: ubuntu-latest timeout-minutes: 30 - needs: [pre-commit-checks, clippy] + needs: pre-commit-checks strategy: + fail-fast: true matrix: - database: - [ - memory, - sqlite, - redb - ] + database: [memory, sqlite, redb] 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: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" - name: Test fake mint run: nix develop -i -L .#stable --command just test-pure ${{ matrix.database }} + - name: Install Postgres + run: bash -x crates/cdk-postgres/start_db_for_test.sh - name: Test mint run: nix develop -i -L .#stable --command just test - payment-processor-itests: name: "Payment processor tests" runs-on: ubuntu-latest timeout-minutes: 30 - needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest, regtest-itest] + needs: pre-commit-checks strategy: + fail-fast: true matrix: - ln: - [ - FAKEWALLET, - CLN, - LND - ] + ln: [FAKEWALLET, CLN, LND] 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: + tool-cache: true + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" - name: Test run: nix develop -i -L .#stable --command just itest-payment-processor ${{matrix.ln}} @@ -258,73 +339,51 @@ jobs: name: "MSRV build" runs-on: ubuntu-latest timeout-minutes: 30 - needs: [pre-commit-checks, clippy, pure-itest] + needs: pre-commit-checks strategy: + fail-fast: true matrix: - build-args: - [ - -p cashu --no-default-features --features "wallet mint", - -p cdk-common --no-default-features --features "wallet mint", - -p cdk, - -p cdk --no-default-features --features "mint auth", - -p cdk --no-default-features --features "wallet auth", - -p cdk --no-default-features --features "http_subscription", - -p cdk-axum, - -p cdk-axum --no-default-features --features redis, - -p cdk-lnbits, - -p cdk-fake-wallet, - -p cdk-cln, - -p cdk-lnd, - -p cdk-mint-rpc, - -p cdk-sqlite, - -p cdk-mintd, - -p cdk-payment-processor --no-default-features, - ] - steps: - - name: checkout - uses: actions/checkout@v4 - - name: Install Nix - uses: DeterminateSystems/nix-installer-action@v17 - - name: Rust Cache - uses: Swatinem/rust-cache@v2 - - name: Build - run: nix develop -i -L .#msrv --command cargo build ${{ matrix.build-args }} + build-args: [ + # Core library - all features EXCEPT swagger (which breaks MSRV) + '-p cdk --features "mint,wallet,auth,nostr,bip353,tor,prometheus"', - - check-wasm: - name: Check WASM - runs-on: ubuntu-latest - timeout-minutes: 30 - needs: [pre-commit-checks, clippy, pure-itest] - strategy: - matrix: - rust: - - stable - target: - - wasm32-unknown-unknown - build-args: - [ - -p cdk, - -p cdk --no-default-features, + # Mintd with all backends, databases, and features (no swagger) + # This also validates cdk-axum, all LN backends, all databases as dependencies + '-p cdk-mintd --no-default-features --features "cln,lnd,lnbits,fakewallet,ldk-node,grpc-processor,sqlite,postgres,auth,prometheus,redis,management-rpc"', + + # CLI - default features (excludes redb which breaks MSRV) + -p cdk-cli, + + # Minimal builds to ensure no-default-features works -p cdk --no-default-features --features wallet, ] steps: - 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 - - name: Build cdk and binding - run: nix develop -i -L ".#${{ matrix.rust }}" --command cargo build ${{ matrix.build-args }} --target ${{ matrix.target }} + with: + shared-key: "msrv-${{ steps.flake-hash.outputs.hash }}" + - name: Build + run: nix develop -i -L .#msrv --command cargo build ${{ matrix.build-args }} - check-wasm-msrv: name: Check WASM runs-on: ubuntu-latest timeout-minutes: 30 - needs: [pre-commit-checks, clippy, msrv-build] + needs: pre-commit-checks strategy: + fail-fast: true matrix: rust: - msrv @@ -339,10 +398,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-${{ 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 }} @@ -350,20 +419,38 @@ jobs: name: "Integration fake mint auth tests" runs-on: ubuntu-latest timeout-minutes: 30 - needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest] + needs: pre-commit-checks strategy: + fail-fast: true matrix: - database: - [ - SQLITE, - ] + database: [SQLITE] 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: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" - name: Start Keycloak with Backup run: | docker compose -f misc/keycloak/docker-compose-recover.yml up -d @@ -377,33 +464,65 @@ jobs: - name: Stop and clean up Docker Compose run: | docker compose -f misc/keycloak/docker-compose-recover.yml down - - doc-tests: - name: "Documentation Tests" + + docs: + name: "Documentation tests and checks" runs-on: ubuntu-latest timeout-minutes: 30 needs: pre-commit-checks 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: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Install Nix uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" - name: Run doc tests run: nix develop -i -L .#stable --command cargo test --doc - - strict-docs: - name: "Strict Documentation Check" + - name: Check docs with strict warnings + run: nix develop -i -L .#stable --command just docs-strict + + ffi-tests: + name: "FFI Python tests" runs-on: ubuntu-latest timeout-minutes: 30 - needs: doc-tests + needs: pre-commit-checks 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 - - name: Check docs with strict warnings - run: nix develop -i -L .#stable --command just docs-strict + with: + shared-key: "stable-${{ steps.flake-hash.outputs.hash }}" + - name: Run FFI tests + run: nix develop -i -L .#integration --command just ffi-test diff --git a/.github/workflows/daily-flake-check.yml b/.github/workflows/daily-flake-check.yml new file mode 100644 index 000000000..659ca6c46 --- /dev/null +++ b/.github/workflows/daily-flake-check.yml @@ -0,0 +1,20 @@ +name: Daily Flake Check + +on: + schedule: + # Run daily at 6 AM UTC + - cron: '0 6 * * *' + # Allow manual trigger + workflow_dispatch: + +jobs: + flake-check: + name: Daily Nix Flake Check + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: Check Nix flake inputs + uses: DeterminateSystems/flake-checker-action@v9 + with: + fail-mode: true diff --git a/.github/workflows/docker-publish-ldk-node-arm.yml b/.github/workflows/docker-publish-ldk-node-arm.yml new file mode 100644 index 000000000..9b3881087 --- /dev/null +++ b/.github/workflows/docker-publish-ldk-node-arm.yml @@ -0,0 +1,62 @@ +name: Publish Docker Image LDK Node ARM64 + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Tag to build and publish' + required: true + default: 'latest' + +env: + REGISTRY: docker.io + IMAGE_NAME: cashubtc/mintd + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=ldk-node-arm64,enable=${{ github.event_name == 'release' }} + type=semver,pattern={{version}}-ldk-node-arm64 + type=semver,pattern={{major}}.{{minor}}-ldk-node-arm64 + type=ref,event=branch,suffix=-ldk-node-arm64 + type=ref,event=pr,suffix=-ldk-node-arm64 + type=sha,suffix=-ldk-node-arm64 + ${{ github.event.inputs.tag != '' && format('{0}-ldk-node-arm64', github.event.inputs.tag) || '' }} + + # Build and push ARM64 image with architecture suffix + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/arm64 + file: ./Dockerfile.ldk-node.arm + tags: ${{ steps.meta.outputs.tags }}-arm64 + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/docker-publish-ldk-node.yml b/.github/workflows/docker-publish-ldk-node.yml new file mode 100644 index 000000000..7ef2b505c --- /dev/null +++ b/.github/workflows/docker-publish-ldk-node.yml @@ -0,0 +1,62 @@ +name: Publish Docker Image LDK Node AMD64 + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Tag to build and publish' + required: true + default: 'latest' + +env: + REGISTRY: docker.io + IMAGE_NAME: cashubtc/mintd + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=ldk-node,enable=${{ github.event_name == 'release' }} + type=semver,pattern={{version}}-ldk-node + type=semver,pattern={{major}}.{{minor}}-ldk-node + type=ref,event=branch,suffix=-ldk-node + type=ref,event=pr,suffix=-ldk-node + type=sha,suffix=-ldk-node + ${{ github.event.inputs.tag != '' && format('{0}-ldk-node', github.event.inputs.tag) || '' }} + + # Build and push AMD64 image with architecture suffix + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile.ldk-node + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }}-amd64 + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/mutation-testing-weekly.yml b/.github/workflows/mutation-testing-weekly.yml new file mode 100644 index 000000000..73c8c2961 --- /dev/null +++ b/.github/workflows/mutation-testing-weekly.yml @@ -0,0 +1,83 @@ +name: Weekly Mutation Testing + +on: + # Run every Friday at 3 AM UTC + schedule: + - cron: '0 3 * * 5' + # Allow manual trigger + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + cargo-mutants: + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - uses: taiki-e/install-action@v2 + with: + tool: cargo-mutants + + - name: Run mutation tests on cashu crate + run: cargo mutants --package cashu --in-place --no-shuffle + continue-on-error: true + + - name: Upload mutation results + uses: actions/upload-artifact@v4 + if: always() + with: + name: mutants.out + path: mutants.out + retention-days: 90 + + - name: Check for missed mutants and create issue + if: always() + run: | + if [ -s mutants.out/missed.txt ]; then + echo "Missed mutants found" + MUTANTS_VERSION=$(cargo mutants --version) + MISSED_COUNT=$(wc -l < mutants.out/missed.txt) + CAUGHT_COUNT=$(wc -l < mutants.out/caught.txt 2>/dev/null || echo "0") + + gh issue create \ + --title "🧬 Weekly Mutation Testing Report - $(date +%Y-%m-%d)" \ + --label "mutation-testing,weekly-report" \ + --body "$(cat <> $GITHUB_OUTPUT + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v17 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@main + with: + diagnostic-endpoint: "" + use-flakehub: false + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: "nightly-${{ steps.flake-hash.outputs.hash }}" + - name: Run Nightly rustfmt + run: | + nix develop -i -L .#nightly --command bash -c ' + # Force use of Nix-provided rustfmt + export RUSTFMT=$(command -v rustfmt) + cargo fmt + ' + # Manually remove trailing whitespace + find . -name '*.rs' -type f -exec sed -E -i 's/[[:space:]]+$//' {} + + - name: Get the current date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_ENV + - name: Create Pull Request + uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + env: + PRE_COMMIT_ALLOW_NO_CONFIG: 1 + with: + token: ${{ secrets.BACKPORT_TOKEN }} + author: Fmt Bot + title: Automated nightly rustfmt (${{ env.date }}) + body: | + Automated nightly `rustfmt` changes by [create-pull-request](https://github.com/peter-evans/create-pull-request) GitHub action + commit-message: ${{ env.date }} automated rustfmt nightly + labels: rustfmt + branch: automated-rustfmt-${{ env.date }} diff --git a/.github/workflows/nutshell_itest.yml b/.github/workflows/nutshell_itest.yml index 5a97c5d05..a572d2cff 100644 --- a/.github/workflows/nutshell_itest.yml +++ b/.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,10 +18,30 @@ 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: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - 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: "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 @@ -27,15 +55,35 @@ 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: + tool-cache: false + android: true + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true - name: Pull Nutshell Docker image run: docker pull cashubtc/nutshell:latest - 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: "integration-${{ steps.flake-hash.outputs.hash }}" - name: Test Nutshell Wallet run: | nix develop -i -L .#integration --command just nutshell-wallet-itest - name: Show Docker logs if tests fail if: failure() - run: docker logs nutshell-wallet || true \ No newline at end of file + run: docker logs nutshell-wallet || true diff --git a/.github/workflows/update-rust-version.yml b/.github/workflows/update-rust-version.yml new file mode 100644 index 000000000..4af900d73 --- /dev/null +++ b/.github/workflows/update-rust-version.yml @@ -0,0 +1,77 @@ +name: Update Rust Version + +on: + schedule: + # Run weekly on Monday at 9 AM UTC + - cron: '0 9 * * 1' + workflow_dispatch: # Allow manual triggering + +permissions: {} + +jobs: + check-rust-version: + name: Check and update Rust version + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get latest stable Rust version + id: latest-rust + run: | + # Fetch the latest stable Rust version from GitHub releases API + LATEST_VERSION=$(curl -s https://api.github.com/repos/rust-lang/rust/releases | jq -r '[.[] | select(.prerelease == false and .draft == false)][0].tag_name') + echo "version=$LATEST_VERSION" >> $GITHUB_OUTPUT + echo "Latest stable Rust version: $LATEST_VERSION" + + - name: Get current Rust version + id: current-rust + run: | + # Extract current version from rust-toolchain.toml + CURRENT_VERSION=$(grep '^channel=' rust-toolchain.toml | sed 's/channel="\(.*\)"/\1/') + echo "version=$CURRENT_VERSION" >> $GITHUB_OUTPUT + echo "Current Rust version: $CURRENT_VERSION" + + - name: Compare versions + id: compare + run: | + if [ "${{ steps.latest-rust.outputs.version }}" != "${{ steps.current-rust.outputs.version }}" ]; then + echo "needs_update=true" >> $GITHUB_OUTPUT + echo "Rust version needs update: ${{ steps.current-rust.outputs.version }} -> ${{ steps.latest-rust.outputs.version }}" + else + echo "needs_update=false" >> $GITHUB_OUTPUT + echo "Rust version is up to date" + fi + + - name: Create Issue + if: steps.compare.outputs.needs_update == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + gh issue create \ + --title "Update Rust to ${{ steps.latest-rust.outputs.version }}" \ + --label "rust-version" \ + --assignee thesimplekid \ + --body "$(cat <<'EOF' + New Rust version **${{ steps.latest-rust.outputs.version }}** is available (currently on **${{ steps.current-rust.outputs.version }}**). + + ## Files to update + - \`rust-toolchain.toml\` - Update channel to \`${{ steps.latest-rust.outputs.version }}\` + - \`flake.nix\` - Update stable_toolchain to \`pkgs.rust-bin.stable."${{ steps.latest-rust.outputs.version }}".default\` + - Run \`nix flake update rust-overlay\` to update \`flake.lock\` + + ## Release Notes + Check the [Rust release notes](https://github.com/rust-lang/rust/blob/master/RELEASES.md) for details on what's new in this version. + + --- + 🤖 Automated issue created by update-rust-version workflow + EOF + )" + + - name: No update needed + if: steps.compare.outputs.needs_update == 'false' + run: | + echo "✓ Rust version is already up to date (${{ steps.current-rust.outputs.version }})" diff --git a/.github/workflows/weekly-meeting-agenda.yml b/.github/workflows/weekly-meeting-agenda.yml new file mode 100644 index 000000000..8ad991ce9 --- /dev/null +++ b/.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" diff --git a/.gitignore b/.gitignore index 4790adc35..8b984c1f2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ Cargo.lock .aider* **/postgres_data/ **/.env + +# Mutation testing artifacts +mutants.out/ +mutants-*.log +.mutants.lock diff --git a/.goosehints b/.goosehints new file mode 100644 index 000000000..46e2f875a --- /dev/null +++ b/.goosehints @@ -0,0 +1,5 @@ +This is a rust project with crates in crate dir. + +tips: +- can look at unstaged changes for what is being worked on if starting +- Do not make code comments that explain *what* is happening. Only explain *why* something is being done. diff --git a/.typos.toml b/.typos.toml index e3cc2980e..6a6405af2 100644 --- a/.typos.toml +++ b/.typos.toml @@ -6,5 +6,6 @@ extend-ignore-re = [ "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9", "autheticator", "Gam", - "flate2" + "flate2", + "lnbc[A-Za-z0-9-_]+" ] diff --git a/CHANGELOG.md b/CHANGELOG.md index d454f1148..aa460ef84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,301 @@ -[Unreleased] + +## [0.14.0](https://github.com/cashubtc/cdk/releases/tag/v0.14.0) + +### Summary + +This release focuses on reliability and robustness improvements across the codebase. The mint now implements saga patterns for both melt and swap operations, providing better error recovery and state consistency during these critical operations. Async melt processing has been added for improved throughput. The wallet gains a new Tor mint connector with isolated circuits support for enhanced privacy when communicating with mints, along with a MintMetadataCache that delivers significant performance improvements for key and metadata management. A new proof recovery mechanism automatically handles failed wallet operations. MultiMintWallet receives improvements including the ability to check and wait for mint quotes and configure internal wallets. NUT-11 SIG_ALL message aggregation has been updated to match the latest specification. On the infrastructure side, a generic pubsub module has been introduced in cdk-common, and cdk-ffi adds postgres support. Additional highlights include keyset amount tracking and SQL balance calculation optimization for improved performance, wallet functions to pay human readable addresses (BIP353 and Lightning address), invoice decoding for BOLT11 and BOLT12 in the FFI bindings, and a mutation testing infrastructure to ensure security-critical code coverage. The release also brings numerous bug fixes addressing database contention, HTLC witness handling, and quote state management. + +### Added +- cdk: Add wallet functions to pay human readable addresses (BIP353 and Lightning address) ([thesimplekid]). +- cdk-ffi: Add invoice decoding for bolt11 and bolt12 ([thesimplekid]). +- cdk: Add keyset_amounts table to track issued and redeemed amounts for improved performance ([crodas]). +- cdk: Add melt quote state transition validation ([thesimplekid]). +- cdk: Add payment request and proof to transaction records ([thesimplekid]). +- cdk: Add WebSocket authentication support ([thesimplekid]). +- cdk: Add proof recovery mechanism for failed wallet operations ([crodas]). +- cdk-ffi: Added postgres support ([asmo]). +- cdk: Optimize SQL balance calculation ([vnprc]). +- cdk: Add tor mint connector for wallet with isolated circuits support ([lollerfirst]). +- cdk-common: Introduce a generic pubsub module ([crodas]). +- cdk: Add MultiMintWallet check and wait for mint quotes ([davidcaseria]). +- cdk: Allow passing metadata to a melt ([benthecarman]). +- cashu: Include supported amounts instead of assuming the power of 2 ([crodas]). +- test: Add mutation testing infrastructure and security-critical coverage ([thesimplekid]). + +### Changed +- cdk: Introduce MintMetadataCache for efficient key and metadata management ([crodas]). +- cdk: Implement saga pattern for melt operations ([thesimplekid]). +- cdk: Implement saga pattern for swap operations ([thesimplekid]). +- cdk: Async melt processing ([thesimplekid]). +- cdk: Extract keyset key loading into helper method ([thesimplekid]). +- cdk: Update Wallet::fetch_mint_info ([crodas]). +- cdk-ffi: Update FFI Database Objects to Records ([davidcaseria]). +- cdk: Redesign Lightning invoice creation and display with better UX and status handling ([erik]). +- cdk: Configure internal Wallets of a MultiMintWallet ([davidcaseria]). +- cdk-ffi: Split uniffi types into multiple mods ([davidcaseria]). +- cdk-ffi: Make Uniffi Records Codable in Swift ([davidcaseria]). +- cdk: Replace proof swap with state check in error recovery ([crodas]). +- cdk: Simplify mint addition in MultiMintWallet ([thesimplekid]). +- cdk: Update NUT-11 SIG_ALL message aggregation per spec ([SatsAndSports]). +- cdk: Remove delete functions for quotes ([thesimplekid]). + +### Fixed +- cdk: Enable pure environment variable configuration for Lightning backends ([thesimplekid]). +- cdk: Prevent database contention in metadata cache load operations ([crodas]). +- cdk: Allow starting insecure mint server ([thesimplekid]). +- cdk: Load keyset keys from database to prevent duplicate insertions ([thesimplekid]). +- cdk: Fix missing try_proof_operation_or_reclaim wrapping of a swap ([crodas]). +- cdk: Don't read keys from the database unnecessarily ([crodas]). +- cdk: Return actual error from get_payment_quote ([gudnuf]). +- cdk: Require 0 signatures for HTLC with no pubkeys specified ([thesimplekid]). +- cdk: Fix NUT-14 disabled in info ([thesimplekid]). +- cdk: Check the removed_ys argument before creating the delete query ([asmo]). +- cdk: Add parent directory validation before database creation ([thesimplekid]). +- cashu: Skip serializing empty NUT15 settings in mint info ([thesimplekid]). +- cdk: Fix bug with websocket close ([crodas]). +- cdk: Fix htlc witness deserialization ([stefanbitcr]). +- cdk-lnbits: Fix msats error handling ([thesimplekid]). +- cdk: Handle fiat melt amount conversions ([gudnuf]). +- cdk: Only settle same unit quote internally ([gudnuf]). +- cdk: Revert redis cache removal ([thesimplekid]). +- cdk: Improve add transaction handling ([thesimplekid]). +- cdk: Read the latest mint quote status in a transaction to avoid race conditions ([crodas]). +- cdk: Fix websocket issues and mint quotes ([crodas]). +- cashu: Fix PreMintSecrets into_iter() ([codingpeanut157]). + +## [0.13.4](https://github.com/cashubtc/cdk/releases/tag/v0.13.4) + +### Added +- cdk-lnbits: Update LNbits integration ([thesimplekid]). +- cdk: Clean witness data ([thesimplekid]). + +## [0.13.3](https://github.com/cashubtc/cdk/releases/tag/v0.13.3) + +### Fixed +- cdk-lnbits: Fix lnbits fee calc ([thesimplekid]). + +## [0.13.2](https://github.com/cashubtc/cdk/releases/tag/v0.13.2) + +### Added +- cashu: Add spending-condition inspection helpers and token_secrets() ([lollerfirst]). + +### Changed +- cdk: Make sorting Transactions a stable sort ([benthecarman]). +- Updated stable Rust to 1.85.0 ([thesimplekid]). + +### Fixed +- cdk-lnbits: Add websocket reconnection with exponential backoff ([thesimplekid]). +- cdk: Add parent directory validation before database creation ([thesimplekid]). +- cashu: Skip serializing empty NUT15 settings in mint info ([lollerfirst]). +- cdk: Improve Melted error handling and add debug logging ([thesimplekid]). +- cdk: Read the latest mint quote status in a transaction to avoid race conditions ([crodas]). + +## [0.13.1](https://github.com/cashubtc/cdk/releases/tag/v0.13.1) + +### Fixed +- cdk: Only settle same unit quote internally ([gudnuf]). +- cdk-cli: Show amounts correctly ([thesimplekid]). +- cdk-lnbits: Fix msats error handling ([thesimplekid]). + +### Changed +- cdk: Simplify mint addition in MultiMintWallet by removing unnecessary mint info fetching and keyset refresh ([thesimplekid]). +- cdk-ffi: Make UniFFI Records Codable in Swift ([davidcaseria]). + +## [0.13.0](https://github.com/cashubtc/cdk/releases/tag/v0.13.0) + +### Summary + +Version 0.13.0 marks a major milestone for mobile development with the introduction of comprehensive native mobile bindings that enable building Cashu wallets for iOS and Android using Swift and Kotlin. The release introduces cdk-ffi, a new Foreign Function Interface crate that provides UniFFI-based bindings for Swift, Kotlin, and Python, with full wallet functionality including multi-mint support, BOLT12 payments, BIP-353 address resolution, and advanced features like P2PK conditions and authentication. Mobile bindings are distributed through dedicated repositories at https://github.com/cashubtc/cdk-kotlin and https://github.com/cashubtc/cdk-swift that provide native package management for Android/JVM and iOS/macOS platforms respectively. The release also delivers significant infrastructure improvements including an event-driven payment architecture with real-time notifications, enhanced database layer with generic key-value storage, improved HTTP transport with proxy support and BIP-353 DNS resolution, and new operational features like Prometheus metrics collection and dedicated authentication database support. + +### Added +- cdk-common: New `Event` enum for payment event handling with `PaymentReceived` variant ([thesimplekid]). +- cdk-common: Added `payment_method` field to `MeltQuote` struct for tracking payment method type ([thesimplekid]). +- cdk-sql-common: Database migration to add `payment_method` column to melt_quote table for SQLite and PostgreSQL ([thesimplekid]). +- cdk-common: New `MintKVStoreDatabase` trait providing generic key-value storage functionality for mint databases ([thesimplekid]). +- cdk-common: Added `KVStoreTransaction` trait for transactional key-value operations with read, write, remove, and list capabilities ([thesimplekid]). +- cdk-common: Added validation functions for KV store namespace and key parameters with ASCII character and length restrictions ([thesimplekid]). +- cdk-common: Added comprehensive test module for KV store functionality with transaction and isolation testing ([thesimplekid]). +- cdk-sql-common: Database migration to add `kv_store` table for generic key-value storage in SQLite and PostgreSQL ([thesimplekid]). +- cdk-sql-common: Implementation of `MintKVStoreDatabase` trait for SQL-based databases with namespace support ([thesimplekid]). +- cdk-common: Added `quote_id` field to `Transaction` struct for tracking associated mint or melt quote IDs ([thesimplekid]). +- cdk-sql-common: Database migration to add `quote_id` column to transactions table for SQLite and PostgreSQL ([thesimplekid]). +- cdk: Added `amount_mintable()` helper and stricter mint quote validation ([thesimplekid]). +- cdk-sql-common: Added persistent `melt_request` storage with associated blinded messages; new migrations for SQLite and PostgreSQL ([thesimplekid]). +- cdk-cln: Persist last `pay_index` in the mint KV store to avoid missed events across restarts ([thesimplekid]). +- cdk: HTTP subscriptions emit BOLT12 notifications per NUT-17 ([crodas]). +- cdk: DNS TXT resolution in HttpTransport and MintConnector for BIP‑353 lookups ([crodas]). +- cdk-ffi: Wallet FFI bindings and async constructors ([davidcaseria]). +- cdk-prometheus: New metrics crate and optional embedded Prometheus server; integrated metrics across HTTP, database, payments, and mint (feature: `prometheus`) ([asmo]). +- cdk-mintd: Optional Prometheus metrics server with configurable address/port via config and env vars (feature: `prometheus`) ([thesimplekid]). +- cdk-ldk-node: Web UI improvements (dynamic status, navigation, mobile support) ([erik]). +- cdk-postgres: Dedicated auth database support with separate schema and migrations when auth is enabled ([thesimplekid]/[asmo]). + + +### Changed +- cdk-common: Refactored `MintPayment` trait method `wait_any_incoming_payment` to `wait_payment_event` with event-driven architecture ([thesimplekid]). +- cdk-common: Updated `wait_payment_event` return type to stream `Event` enum instead of `WaitPaymentResponse` directly ([thesimplekid]). +- cdk: Updated mint payment handling to process payment events through new `Event` enum pattern ([thesimplekid]). +- cashu: Updated BOLT12 payment method specification from NUT-24 to NUT-25 ([thesimplekid]). +- cdk: Updated BOLT12 import references from nut24 to nut25 module ([thesimplekid]). +- cdk: Do not fallback from WebSocket to HTTP on first error in subscriptions; retry only when appropriate ([crodas]). +- cdk: Abstracted HTTP Transport; centralized `pay_request` logic into the cdk library ([lollerfirst]). +- cdk: MultiMintWallet refactor for clearer APIs and behavior when managing multiple mints ([davidcaseria]/[thesimplekid]). +- cdk: Treat `None` proxy host matcher as "apply to all hosts" for HTTP transport ([lollerfirst]). +- cdk: QuoteId handling unified as string in APIs and storage ([thesimplekid]). +- cdk-axum: Close WebSocket connections sooner on shutdown/errors to avoid dangling clients ([crodas]). +- cdk-sql-common: Consistent ordering for SQL migrations and build uses `OUT_DIR` for embedded migration files ([vnprc]). +- cdk-signatory: Updated protobuf to latest spec and added signatory-related DB migrations ([crodas]). +- cdk-redb: Bumped dependency version ([thesimplekid]). + ### Fixed -- Mintd version updated when grpc is enabled [PR](https://github.com/cashubtc/cdk/pull/803) ([thesimplekid]). +- cdk: Wallet melt track and use payment method from quote for BOLT11/BOLT12 routing ([thesimplekid]). +- cdk: Improve error response details and mapping across HTTP/WS paths ([thesimplekid]). +- cdk: Fix config being overwritten on startup in certain scenarios ([thesimplekid]). +- cdk: WASM compatibility fixes for HTTP subscriptions and time handling (use `instant`) ([gudnuf]). +- cdk-postgres: Fix reconnection in connection pool and migration prefixes. +- cdk: Check keyset max order when generating/using keysets ([thesimplekid]). +- cdk: Correct error code returned for duplicate signature conditions ([lollerfirst]). +- cdk: Ensure all mint quotes are returned in listings ([thesimplekid]). +- cdk-axum: Improve error response detail structure ([thesimplekid]). + + +## [0.12.1](https://github.com/cashubtc/cdk/releases/tag/v0.12.1) + +### Fixed +- cdk-postgres: TLS support for PostgreSQL connections ([asmogo]). +- cdk: patch sha-512 derivation -> sha-256 derivation ([lollerfirst]). + +## [0.12.0](https://github.com/cashubtc/cdk/releases/tag/v0.12.0) + +### Summary + +Version 0.12.0 delivers end-to-end BOLT12 offers and payments, adds BIP‑353 address resolution for BOLT12 payments, and introduces cdk-ldk-node, an integrated Lightning backend that lets a single binary run both a Cashu mint and a Lightning node with full BOLT11 and BOLT12 support. It also adds a local, admin-focused web UI for cdk-ldk-node with dashboards for channels, invoices and offers, payments, and on-chain activity. On the data layer, the release expands storage with PostgreSQL via the new cdk-postgres crate and accelerates the shared SQL stack (cdk-sql-common) with statement caching and structured, namespaced/global migrations. Operationally, the mint now exposes explicit start and stop lifecycle methods, enabling graceful startup and shutdown of background services. Wallet keyset management has been clarified with renamed APIs that separate local storage from network fetches—making load_mint_keysets the primary entry point for token operations—and the MSRV is updated to 1.85.0. + +### Added +- dev: Goose recipes for changelog and commit message generation with Just commands ([thesimplekid]). +- cashu: `KeySetInfos` type alias and `KeySetInfosMethods` trait for filtering keysets ([thesimplekid]). +- cdk: Mint lifecycle management with `start()` and `stop()` methods for graceful background service control ([thesimplekid]). +- cdk: Background task management for invoice payment monitoring with proper shutdown handling ([thesimplekid]). +- cashu: NUT-19 support in the wallet ([crodas]). +- cdk: SIG_ALL support for swap and melt operations ([thesimplekid]). +- cdk-sql-common: Add cache to SQL statements for better performance ([crodas]). +- cdk-integration-tests: New binary `start_fake_auth_mint` for testing fake mint with authentication ([thesimplekid]). +- cdk-integration-tests: New binary `start_fake_mint` for testing fake mint instances ([thesimplekid]). +- cdk-integration-tests: New binary `start_regtest_mints` for testing regtest mints ([thesimplekid]). +- cdk-integration-tests: Shared utilities module for common integration test functionality ([thesimplekid]). +- cdk-redb: Database migration to increment keyset counters by 1 for existing keysets with counter > 0 ([thesimplekid]). +- cdk-sql-common: Database migration to increment keyset counters by 1 for existing keysets with counter > 0 ([thesimplekid]). +- cdk-ldk-node: New Lightning backend implementation using LDK Node for improved Lightning Network functionality ([thesimplekid]). +- cdk-ldk-node: Local web management UI (dashboard, channels, invoices/offers, payments, on‑chain). Intended for localhost/admin use only; do not expose publicly ([thesimplekid]/[erik]). +- cdk-common: Added `start()` and `stop()` methods to `MintPayment` trait for payment processor lifecycle management ([thesimplekid]). +- cdk-mintd: Added LDK Node backend support with comprehensive configuration options ([thesimplekid]). +- cdk-postgres: Postgres Database for mint and wallet ([crodas]). +- cdk: BOLT12 mint quote WebSocket subscriptions (NUT-17) ([crodas]). +- cdk: Future streams for payments and minting proofs ([crodas]). +- cdk: Log-to-file support ([thesimplekid]). +- cdk(wallet): BIP-353 support ([thesimplekid]). +- security: Zeroize secrets on drop ([vnprc]). + +### Changed +- cdk-common: Modified `Database::get_keyset_counter` trait method to return `u32` instead of `Option` for simpler keyset counter handling ([thesimplekid]). +- cdk: Refactored wallet keyset management methods for better clarity and separation of concerns ([thesimplekid]). +- cdk: Renamed `get_keyset_keys` to `fetch_keyset_keys` to indicate network operation ([thesimplekid]). +- cdk: Renamed `get_active_mint_keyset` to `fetch_active_keyset` for consistency ([thesimplekid]). +- cdk: Updated `get_active_mint_keysets` to `refresh_keysets` with improved keyset refresh logic ([thesimplekid]). +- cdk: Improved `load_mint_keysets` method to be the primary method for getting keysets for token operations ([thesimplekid]). +- cdk: Enhanced keyset management with better offline/online operation separation ([thesimplekid]). +- cdk: Updated method documentation to clarify storage vs network operations ([thesimplekid]). +- cdk: Refactored invoice payment monitoring to use centralized lifecycle management instead of manual task spawning ([thesimplekid]). +- cdk: Enhanced mint startup to initialize payment processors before starting background services ([thesimplekid]). +- cdk: Improved mint shutdown to gracefully stop payment processors alongside background services ([thesimplekid]). +- cdk-mintd: Updated to use new mint lifecycle methods for improved service management ([thesimplekid]). +- cdk-integration-tests: Updated test utilities to use new mint lifecycle management ([thesimplekid]). +- cdk: HTTP retry only on transport errors ([crodas]). +- cdk-lnbits: Migrate to LNBits v1 websocket API and remove pre-v1 code paths ([thesimplekid]). +- cdk-cln: Use millisatoshis (msats) for amounts ([thesimplekid]). +- cdk: NUT-20 support toggle in mint builder configuration ([thesimplekid]). +- cashu/cdk: New secret derivation per updated spec ([lollerfirst]). +- cdk-sqlite: Introduce `cdk-sql-common` crate for shared SQL storage codebase ([crodas]). +- cdk-sqlite: Rename `still_active` to `stale` for better clarity ([crodas]). +- cdk-integration-tests: Refactored regtest setup to use Rust binaries instead of shell scripts ([thesimplekid]). +- cdk-integration-tests: Improved environment variable handling for test configurations ([thesimplekid]). +- cdk-integration-tests: Enhanced CLN client connection with retry logic ([thesimplekid]). +- cdk-integration-tests: Updated integration tests to use proper temp directory management ([thesimplekid]). +- cdk-integration-tests: Simplified regtest shell scripts to use new binaries ([thesimplekid]). +- crates/cdk-mintd: Moved mintd library functions to separate module for better organization and testability ([thesimplekid]). +- dev/docker: Switch base image to Debian Trixie ([thesimplekid]). +- Updated MSRV to 1.85.0 ([thesimplekid]). +- dev: Simplified Nix flake configuration by removing specific dependency version constraints from MSRV shell hook ([thesimplekid]). + +### Fixed +- cashu: Fixed CurrencyUnit custom units preserving original case instead of being converted to uppercase ([thesimplekid]). +- cdk: Fix P2PK spending-condition validation and requirements ([thesimplekid]). +- cdk: Fixed BOLT12 missing payments notifications ([crodas]). +- cdk-axum/mint: Fix BOLT12 WebSocket behavior on mint ([thesimplekid]). +- cdk-lnbits: Fix payment check and unit handling ([thesimplekid]). +- cdk-sqlite: Fix `get_mint_quote_by_request_lookup_id` function synchronization ([crodas]). +- cdk-sqlite: Reduce mmap_size to 5 MiB to avoid resource issues ([thesimplekid]). +- cdk: Remove unwrap in startup checks ([thesimplekid]). +- cdk: Allow paid and issued BOLT12 quotes to settle internally ([gudnuf]). +- cdk: Include change in melt quote state updates ([thesimplekid]). +- cdk-mintd/axum: Pass auth config from mintd through to axum correctly ([thesimplekid]). + +### Migration +- cdk-sql-common: Improve migrations with namespaced and global migrations support ([crodas]). + + +## [0.11.0](https://github.com/cashubtc/cdk/releases/tag/v0.11.0) + +### Summary + +Version 0.11.0 brings significant architectural changes to enhance database reliability and performance. The major changes include: + +1. **Database Engine Change**: Replaced `sqlx` with `rusqlite` as the SQLite database driver and removed support for `redb`. This change provides better performance and reliability for database operations. + +2. **Transaction Management**: Introduced robust database transaction support that encapsulates all database changes. The new Transaction trait implements a rollback operation on Drop unless explicitly committed, ensuring data integrity. + +3. **Race Condition Prevention**: Added READ-and-lock operations to securely read and lock records from the database for exclusive access, preventing race conditions in concurrent operations. + +### ⚠️ Important Migration Note for redb Users +If you are currently running a mint with redb, you must migrate to SQLite before upgrading to v0.11. Follow these steps: + +1. Stop your current mint +2. Back up your database +3. Use the migration script available at: https://github.com/cashubtc/cdk/blob/main/misc/convert_redb_to_sqlite.sh +4. Update your config file to target the SQLite database engine +5. Start your mint with v0.11 + + +### Added +- cdk-lnbits: Support lnbits v1 and pre-v1 [PR](https://github.com/cashubtc/cdk/pull/802) ([thesimplekid]). +- Support for Keyset v2 [PR](https://github.com/cashubtc/cdk/pull/702) ([lollerfirst]). +- Add option to limit the token size of a send [PR](https://github.com/cashubtc/cdk/pull/855) ([davidcaseria]). +- Database transaction support [PR](https://github.com/cashubtc/cdk/pull/826) ([crodas]). +- Support for multsig refund [PR](https://github.com/cashubtc/cdk/pull/860) ([thesimplekid]). +- Convert unit helper fn [PR](https://github.com/cashubtc/cdk/pull/856) ([davidcaseria]). + +### Changed +- cdk-sqlite: remove sqlx in favor of rusqlite ([crodas]). +- cdk-lnd: use custom tonic gRPC instead of fedimint-tonic-grpc [PR](https://github.com/cashubtc/cdk/pull/831) ([thesimplekid]). +- cdk-cln: remove the us of mutex on cln client [PR](https://github.com/cashubtc/cdk/pull/832) ([thesimplekid]). + +### Fixed +- mint start up check was not checking unpaid quotes [PR](https://github.com/cashubtc/cdk/pull/844) ([gudnuf]). +- Naming of blinded_message column on blind_signatures was y [PR](https://github.com/cashubtc/cdk/pull/845) ([thesimplekid]). +- cdk-cli: Create wallets for non sat units if supported [PR](https://github.com/cashubtc/cdk/pull/841) ([thesimplekid]). + +### Removed +- cdk-redb support for the mint [PR](https://github.com/cashubtc/cdk/pull/787) ([thesimplekid]). +- cdk-sqlite remove unused melt_request table [PR](https://github.com/cashubtc/cdk/pull/819) ([crodas]) + + +## [0.10.1](https://github.com/cashubtc/cdk/releases/tag/v0.10.1) +### Fix +- Set mint version when mint rpc is enabled [PR](https://github.com/cashubtc/cdk/pull/803) ([thesimplekid]). +- `cdk-signatory` is optional for wallet [PR](https://github.com/cashubtc/cdk/pull/815) ([thesimplekid]). ## [0.10.0](https://github.com/cashubtc/cdk/releases/tag/v0.10.0) ### Added @@ -379,3 +671,8 @@ Additionally, this release introduces a Mint binary cdk-mintd that uses the cdk- [benthecarman]: https://github.com/benthecarman [Darrell]: https://github.com/Darrellbor [asmo]: https://github.com/asmogo +[gudnuf]: https://github.com/gudnuf +[codingpeanut157]: https://github.com/codingpeanut157 +[erik]: https://github.com/swedishfrenchpress +[SatsAndSports]: https://github.com/SatsAndSports +[stefanbitcr]: https://github.com/stefanbitcr diff --git a/Cargo.toml b/Cargo.toml index 3c30044a1..5c01a5d36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,6 @@ resolver = "2" unsafe_code = "forbid" unreachable_pub = "warn" missing_debug_implementations = "warn" -large_enum_variant = "warn" [workspace.lints.clippy] pedantic = "warn" @@ -22,6 +21,7 @@ redundant_else = "warn" redundant_closure_for_method_calls = "warn" unneeded_field_pattern = "warn" use_debug = "warn" +large_enum_variant = "warn" [workspace.lints.rustdoc] missing_docs = "warn" @@ -29,11 +29,11 @@ bare_urls = "warn" [workspace.package] edition = "2021" -rust-version = "1.75.0" +rust-version = "1.85.0" license = "MIT" homepage = "https://github.com/cashubtc/cdk" repository = "https://github.com/cashubtc/cdk.git" -version = "0.10.0" +version = "0.14.0" readme = "README.md" [workspace.dependencies] @@ -43,36 +43,48 @@ axum = { version = "0.8.1", features = ["ws"] } bitcoin = { version = "0.32.2", features = ["base64", "serde", "rand", "rand-std"] } bip39 = { version = "2.0", features = ["rand"] } jsonwebtoken = "9.2.0" -cashu = { path = "./crates/cashu", version = "=0.10.0" } -cdk = { path = "./crates/cdk", default-features = false, version = "=0.10.0" } -cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.10.0" } -cdk-axum = { path = "./crates/cdk-axum", default-features = false, version = "=0.10.0" } -cdk-cln = { path = "./crates/cdk-cln", version = "=0.10.0" } -cdk-lnbits = { path = "./crates/cdk-lnbits", version = "=0.10.0" } -cdk-lnd = { path = "./crates/cdk-lnd", version = "=0.10.0" } -cdk-fake-wallet = { path = "./crates/cdk-fake-wallet", version = "=0.10.0" } -cdk-payment-processor = { path = "./crates/cdk-payment-processor", default-features = true, version = "=0.10.0" } -cdk-mint-rpc = { path = "./crates/cdk-mint-rpc", version = "=0.10.0" } -cdk-redb = { path = "./crates/cdk-redb", default-features = true, version = "=0.10.0" } -cdk-sqlite = { path = "./crates/cdk-sqlite", default-features = true, version = "=0.10.0" } -cdk-signatory = { path = "./crates/cdk-signatory", version = "=0.10.0", default-features = false } +cashu = { path = "./crates/cashu", version = "=0.14.0" } +cdk = { path = "./crates/cdk", default-features = false, version = "=0.14.0" } +cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.14.0" } +cdk-axum = { path = "./crates/cdk-axum", default-features = false, version = "=0.14.0" } +cdk-cln = { path = "./crates/cdk-cln", version = "=0.14.0" } +cdk-lnbits = { path = "./crates/cdk-lnbits", version = "=0.14.0" } +cdk-lnd = { path = "./crates/cdk-lnd", version = "=0.14.0" } +cdk-ldk-node = { path = "./crates/cdk-ldk-node", version = "=0.14.0" } +cdk-fake-wallet = { path = "./crates/cdk-fake-wallet", version = "=0.14.0" } +cdk-ffi = { path = "./crates/cdk-ffi", version = "=0.14.0" } +cdk-payment-processor = { path = "./crates/cdk-payment-processor", default-features = true, version = "=0.14.0" } +cdk-mint-rpc = { path = "./crates/cdk-mint-rpc", version = "=0.14.0" } +cdk-redb = { path = "./crates/cdk-redb", default-features = true, version = "=0.14.0" } +cdk-sql-common = { path = "./crates/cdk-sql-common", default-features = true, version = "=0.14.0" } +cdk-sqlite = { path = "./crates/cdk-sqlite", default-features = true, version = "=0.14.0" } +cdk-postgres = { path = "./crates/cdk-postgres", default-features = true, version = "=0.14.0" } +cdk-signatory = { path = "./crates/cdk-signatory", version = "=0.14.0", default-features = false } +cdk-mintd = { path = "./crates/cdk-mintd", version = "=0.14.0", default-features = false } +cdk-prometheus = { path = "./crates/cdk-prometheus", version = "=0.14.0", default-features = false } clap = { version = "4.5.31", features = ["derive"] } ciborium = { version = "0.2.2", default-features = false, features = ["std"] } cbor-diag = "0.1.12" +config = { version = "0.15.11", features = ["toml"] } +criterion = "0.6.0" futures = { version = "0.3.28", default-features = false, features = ["async-await"] } lightning-invoice = { version = "0.33.0", features = ["serde", "std"] } -serde = { version = "1", features = ["derive"] } +lightning = { version = "0.1.2", default-features = false, features = ["std"]} +ldk-node = "0.6.2" +serde = { version = "1", features = ["derive", "rc"] } serde_json = "1" thiserror = { version = "2" } -tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util"] } +tokio = { version = "1", default-features = false, features = ["rt", "macros", "test-util", "sync"] } tokio-util = { version = "0.7.11", default-features = false } -tower-http = { version = "0.6.1", features = ["compression-full", "decompression-full", "cors", "trace"] } +tower = "0.5.2" +tower-http = { version = "0.6.1", features = ["compression-full", "decompression-full", "cors", "trace", "fs"] } tokio-tungstenite = { version = "0.26.0", default-features = false } tokio-stream = "0.1.15" tracing = { version = "0.1", default-features = false, features = ["attributes", "log"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } +tracing-appender = "0.2" url = "2.3" -uuid = { version = "=1.12.1", features = ["v4", "serde"] } +uuid = { version = "1.17", features = ["v4", "serde"] } utoipa = { version = "5.3.1", features = [ "preserve_order", "preserve_path_order", @@ -89,7 +101,7 @@ reqwest = { version = "0.12", default-features = false, features = [ "deflate", ]} once_cell = "1.20.2" -instant = { version = "0.1", default-features = false } +web-time = "1.1.0" rand = "0.9.1" regex = "1" home = "0.5.5" @@ -98,9 +110,17 @@ prost = "0.13.1" tonic-build = "0.13.1" strum = "0.27.1" strum_macros = "0.27.1" -rustls = { version = "0.23.28", default-features = false, features = ["ring"] } +rustls = { version = "0.23.27", default-features = false, features = ["ring"] } +prometheus = { version = "0.13.4", features = ["process"], default-features = false } +nostr-sdk = { version = "0.43.0", default-features = false, features = [ + "nip04", + "nip44", + "nip59" +]} +cdk-portal-wallet = { path = "./crates/cdk-portal-wallet", version = "=0.14.0" } + [workspace.metadata] authors = ["CDK Developers"] @@ -115,5 +135,13 @@ inherits = "dev" incremental = false lto = "off" +[profile.release-smaller] +inherits = "release" +opt-level = 'z' # Optimize for size. +lto = true # Enable Link Time Optimization +codegen-units = 1 # Reduce number of codegen units to increase optimizations. +panic = "abort" # Abort on panic +strip = "debuginfo" # Partially strip symbols from binary + [workspace.metadata.crane] name = "cdk-workspace" diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1306fbc91..0be892319 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -34,8 +34,8 @@ CDK uses [Nix](https://nixos.org/explore.html) for building, CI, and managing de Note: only `Nix` (the language & package manager) and not the NixOS (the Linux distribution) is needed. Nix can be installed on any Linux distribution and macOS. -While it is technically possible to not use Nix, it is highly recommended as -it ensures consistent and reproducible environment for all developers. +While Nix is preferred as it ensures a consistent and reproducible environment +for all developers, it is not strictly required to use Nix to build CDK. ### Install Nix @@ -63,12 +63,94 @@ experimental-features = nix-command flakes If the Nix installation is in multi-user mode, don’t forget to restart the nix-daemon. +## Alternative Setup Without Nix + +While Nix is preferred as it ensures a consistent and reproducible environment +for all developers, it is not strictly required to use Nix to build CDK. You can +also set up your environment manually. + +### Installing Rust via rustup + +To build CDK without Nix, you'll need to install Rust manually: + +1. Install rustup by following the instructions at [https://www.rust-lang.org/tools/install](https://www.rust-lang.org/tools/install) + +2. Once rustup is installed, you can install the required Rust version: +```bash +rustup install stable +rustup default stable +``` + +3. Install required tools: +```bash +# For building cdk-mintd, you'll need protobuf compiler +# On Ubuntu/Debian: +sudo apt install protobuf-compiler + +# On macOS with Homebrew: +brew install protobuf + +# On other systems, please refer to your package manager or +# https://grpc.io/docs/protoc-installation/ +``` + +### Building and Running CDK Components + +#### Building cdk-cli + +To build the CDK command-line interface: +```bash +cargo build --bin cdk-cli --release +``` + +To run cdk-cli directly without building: +```bash +cargo run --bin cdk-cli -- --help +``` + +#### Building cdk-mintd + +To build the CDK mint server: +```bash +cargo build --bin cdk-mintd --release +``` + +To run cdk-mintd directly without building: +```bash +cargo run --bin cdk-mintd +``` + +Note: For cdk-mintd, you need to have the protobuf compiler installed as it's required for some dependencies. + ## Use Nix Shell ```sh nix develop -c $SHELL ``` +## Regtest Environment + +For testing and development, CDK provides a complete regtest environment with Bitcoin, Lightning Network nodes, and CDK mints. + +### Quick Start +```bash +just regtest # Starts full environment with mprocs TUI +``` + +This provides: +- Bitcoin regtest node +- 4 Lightning Network nodes (2 CLN + 2 LND) +- 2 CDK mints (one connected to CLN, one to LND) +- Real-time log monitoring via mprocs +- Helper commands for testing Lightning payments and CDK operations + +### Comprehensive Guide +See [REGTEST_GUIDE.md](REGTEST_GUIDE.md) for complete documentation including: +- Detailed setup and usage instructions +- Development workflows and testing scenarios +- mprocs TUI interface guide +- Troubleshooting and advanced usage + ## Common Development Tasks ### Building the Project @@ -88,11 +170,72 @@ just itest REDB/SQLITE/MEMORY NOTE: if this command fails on macos change the nix channel to unstable (in the `flake.nix` file modify `nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";` to `nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";`) +### Running Mutation Tests + +Mutation testing validates test suite quality by introducing small code changes (mutations) and verifying tests catch them. + +```bash +# Run mutation tests on cashu crate (configured in .cargo/mutants.toml) +cargo mutants + +# Check specific files only +cargo mutants --file crates/cashu/src/amount.rs + +# Re-run previously caught mutations to verify fixes +cargo mutants --in-diff +``` + +**Understanding Results:** +- **Caught mutations**: Tests correctly detected the code change (good!) +- **Missed mutations**: Code change went undetected - indicates missing test coverage +- **Timeouts**: Mutation caused infinite loop - some are excluded in config to keep tests practical + +The `.cargo/mutants.toml` file excludes mutations that cause infinite loops during testing. These don't indicate bugs - they're just mutations that would make the test suite hang indefinitely. + +See [cargo-mutants documentation](https://mutants.rs/) for more options. + ### Running Format ```bash just format ``` +## Code Formatting + +CDK uses a flexible rustfmt policy to balance code quality with developer experience: + +### Formatting Requirements for PRs +Pull requests can be formatted with **either stable or nightly** rustfmt - both are accepted: + +- **Stable rustfmt:** Standard Rust formatting (less strict) +- **Nightly rustfmt:** More strict formatting with additional rules + +**Why both are accepted:** +- We prefer nightly rustfmt's stricter formatting +- We don't want to force contributors to install nightly Rust +- This reduces friction for developers using stable toolchains + +```bash +# Format with stable (default) +just format + +# Format with nightly (if you have it installed) +cargo +nightly fmt +``` + +The CI will check your PR with stable rustfmt, so as long as your code passes stable formatting, your PR will pass CI. + +### Automated Nightly Formatting +To keep the codebase consistently formatted with nightly rustfmt over time: + +- **Daily Check:** Every night at midnight UTC, a GitHub Action runs nightly rustfmt on the `main` branch +- **Automated PRs:** If nightly rustfmt produces formatting changes, a PR is automatically created with: + - Title: `Automated nightly rustfmt (YYYY-MM-DD)` + - Label: `rustfmt` + - Author: `Fmt Bot ` +- **Review Process:** These automated PRs are reviewed and merged to keep the codebase aligned with nightly formatting + +This approach ensures the codebase gradually adopts nightly formatting improvements without blocking contributors who use stable Rust. + ### Running Clippy ```bash @@ -144,6 +287,85 @@ just final-check 4. Submit a pull request 5. Wait for review and address feedback +## Backporting Changes + +CDK uses an automated backport bot to help maintain stable release branches. This section explains how the backport process works. + +### How the Backport Bot Works + +The backport bot creates pull requests to backport merged changes from `main` to stable release branches. **You control which branches to backport to by adding labels to your PR.** + +**Available Target Branches:** +- `v0.10.x` +- `v0.11.x` +- `v0.12.x` +- `v0.13.x` + +### Using Backport Labels + +To backport a PR to specific stable branches, add labels to your PR **before or after merging**: + +**Label Format:** +- `backport v0.13.x` - backports to v0.13.x branch +- `backport v0.12.x` - backports to v0.12.x branch +- Add multiple labels to backport to multiple branches + +**Example Workflow:** +1. Create and merge your PR to `main` +2. Add label `backport v0.13.x` to the PR +3. The bot automatically creates a backport PR for the v0.13.x branch +4. Review and merge the backport PR +5. Repeat for other branches as needed + +**When to Add Labels:** +- Add labels before merging - backport PRs are created automatically on merge +- Add labels after merging - backport PRs are created when you add the label +- You can add multiple backport labels at once + +### When Backports Fail + +Sometimes the backport bot cannot automatically create a backport PR due to merge conflicts or other issues. When this happens: + +1. The bot automatically creates a GitHub issue labeled with `backport` +2. The issue will contain details about the original PR and which branch(es) failed +3. You'll need to manually create the backport PR for the failed branch + +**Manual Backporting Process:** +```bash +# Checkout the target stable branch +git checkout v0.13.x +git pull origin v0.13.x + +# Create a new branch for the backport +git checkout -b backport-pr-NUMBER-to-v0.13.x + +# Cherry-pick the commits from the original PR +git cherry-pick COMMIT_HASH + +# Resolve any conflicts if they occur +# Then push and create a PR +git push origin backport-pr-NUMBER-to-v0.13.x +``` + +### Best Practices for Backporting + +1. **Label Appropriately:** Only add backport labels for changes that should be in stable branches +2. **Keep PRs Focused:** Smaller, focused PRs are easier to backport automatically +3. **Review Backport PRs:** Always review automatically created backport PRs to ensure they're appropriate +4. **Test Backports:** Run tests on backport PRs just like regular PRs +5. **Address Conflicts Promptly:** If a backport fails, address it promptly or close the issue with an explanation + +### When NOT to Backport + +Not all changes should be backported to stable branches. **Don't add backport labels** for: +- Breaking API changes +- New features that aren't needed in older versions +- Changes that don't apply to older version branches +- Large refactorings +- Experimental or unstable features + +If a backport isn't appropriate, simply don't add the backport label to the PR. + ## Additional Resources - [Nix Documentation](https://nixos.org/manual/nix/stable/) diff --git a/Dockerfile b/Dockerfile index 711d172da..ed06de19f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,10 +10,10 @@ COPY Cargo.toml ./Cargo.toml COPY crates ./crates # Start the Nix daemon and develop the environment -RUN nix develop --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features redis +RUN nix develop --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres --features prometheus # Create a runtime stage -FROM debian:bookworm-slim +FROM debian:trixie-slim # Set the working directory WORKDIR /usr/src/app diff --git a/Dockerfile.arm b/Dockerfile.arm index cdb19fd5f..f256cf73b 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -13,10 +13,10 @@ COPY crates ./crates RUN echo 'filter-syscalls = false' > /etc/nix/nix.conf # Start the Nix daemon and develop the environment -RUN nix develop --extra-platforms aarch64-linux --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features redis +RUN nix develop --extra-platforms aarch64-linux --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres # Create a runtime stage -FROM debian:bookworm-slim +FROM debian:trixie-slim # Set the working directory WORKDIR /usr/src/app diff --git a/Dockerfile.ldk-node b/Dockerfile.ldk-node new file mode 100644 index 000000000..dd825018e --- /dev/null +++ b/Dockerfile.ldk-node @@ -0,0 +1,40 @@ +# Use the official NixOS image as the base image +FROM nixos/nix:latest AS builder + +# Set the working directory +WORKDIR /usr/src/app + +# Copy workspace files and crates directory into the container +COPY flake.nix ./flake.nix +COPY Cargo.toml ./Cargo.toml +COPY crates ./crates + +# Start the Nix daemon and develop the environment +RUN nix develop --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features ldk-node --features prometheus --features postgres + +# Create a runtime stage +FROM debian:trixie-slim + +# Set the working directory +WORKDIR /usr/src/app + +# Install needed runtime dependencies (if any) +RUN apt-get update && \ + apt-get install -y --no-install-recommends patchelf && \ + rm -rf /var/lib/apt/lists/* + +# Copy the built application from the build stage +COPY --from=builder /usr/src/app/target/release/cdk-mintd /usr/local/bin/cdk-mintd + +# Detect the architecture and set the interpreter accordingly +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "aarch64" ]; then \ + patchelf --set-interpreter /lib/ld-linux-aarch64.so.1 /usr/local/bin/cdk-mintd; \ + elif [ "$ARCH" = "x86_64" ]; then \ + patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 /usr/local/bin/cdk-mintd; \ + else \ + echo "Unsupported architecture: $ARCH"; exit 1; \ + fi + +# Set the entry point for the container +CMD ["cdk-mintd"] diff --git a/Dockerfile.ldk-node.arm b/Dockerfile.ldk-node.arm new file mode 100644 index 000000000..2cb42be7a --- /dev/null +++ b/Dockerfile.ldk-node.arm @@ -0,0 +1,43 @@ +# Use the official NixOS image as the base image +FROM nixos/nix:latest AS builder + +# Set the working directory +WORKDIR /usr/src/app + +# Copy workspace files and crates directory into the container +COPY flake.nix ./flake.nix +COPY Cargo.toml ./Cargo.toml +COPY crates ./crates + +# Create a nix config file to disable syscall filtering +RUN echo 'filter-syscalls = false' > /etc/nix/nix.conf + +# Start the Nix daemon and develop the environment +RUN nix develop --extra-platforms aarch64-linux --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features ldk-node --features prometheus --features postgres + +# Create a runtime stage +FROM debian:trixie-slim + +# Set the working directory +WORKDIR /usr/src/app + +# Install needed runtime dependencies (if any) +RUN apt-get update && \ + apt-get install -y --no-install-recommends patchelf && \ + rm -rf /var/lib/apt/lists/* + +# Copy the built application from the build stage +COPY --from=builder /usr/src/app/target/release/cdk-mintd /usr/local/bin/cdk-mintd + +# Detect the architecture and set the interpreter accordingly +RUN ARCH=$(uname -m) && \ + if [ "$ARCH" = "aarch64" ]; then \ + patchelf --set-interpreter /lib/ld-linux-aarch64.so.1 /usr/local/bin/cdk-mintd; \ + elif [ "$ARCH" = "x86_64" ]; then \ + patchelf --set-interpreter /lib64/ld-linux-x86-64.so.2 /usr/local/bin/cdk-mintd; \ + else \ + echo "Unsupported architecture: $ARCH"; exit 1; \ + fi + +# Set the entry point for the container +CMD ["cdk-mintd"] diff --git a/README.md b/README.md index cffa1295b..3d41f77be 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -> **Warning** +> [!Warning] > This project is in early development, it does however work with real sats! Always use amounts you don't mind losing. -[![crates.io](https://img.shields.io/crates/v/cdk.svg)](https://crates.io/crates/cdk) [![Documentation](https://docs.rs/cdk/badge.svg)](https://docs.rs/cdk) +[![crates.io](https://img.shields.io/crates/v/cdk.svg)](https://crates.io/crates/cdk) [![Documentation](https://docs.rs/cdk/badge.svg)](https://docs.rs/cdk) [![License](https://img.shields.io/github/license/cashubtc/cdk)](https://github.com/cashubtc/cdk/blob/main/LICENSE) # Cashu Development Kit @@ -15,15 +15,24 @@ CDK is a collection of rust crates for [Cashu](https://github.com/cashubtc) wall The project is split up into several crates in the `crates/` directory: * Libraries: + * [**cashu**](./crates/cashu/): Core Cashu protocol implementation. * [**cdk**](./crates/cdk/): Rust implementation of Cashu protocol. * [**cdk-sqlite**](./crates/cdk-sqlite/): SQLite Storage backend. + * [**cdk-postgres**](./crates/cdk-postgres/): PostgreSQL Storage backend. * [**cdk-redb**](./crates/cdk-redb/): Redb Storage backend. - * [**cdk-rexie**](./crates/cdk-rexie/): Rexie Storage backend for browsers. * [**cdk-axum**](./crates/cdk-axum/): Axum webserver for mint. * [**cdk-cln**](./crates/cdk-cln/): CLN Lightning backend for mint. * [**cdk-lnd**](./crates/cdk-lnd/): Lnd Lightning backend for mint. - * [**cdk-lnbits**](./crates/cdk-lnbits/): [LNbits](https://lnbits.com/) Lightning backend for mint. + * [**cdk-lnbits**](./crates/cdk-lnbits/): [LNbits](https://lnbits.com/) Lightning backend for mint. **Note: Only LNBits v1 API is supported.** + * [**cdk-ldk-node**](./crates/cdk-ldk-node/): LDK Node Lightning backend for mint. * [**cdk-fake-wallet**](./crates/cdk-fake-wallet/): Fake Lightning backend for mint. To be used only for testing, quotes are automatically filled. + * [**cdk-common**](./crates/cdk-common/): Common utilities and shared code. + * [**cdk-sql-common**](./crates/cdk-sql-common/): Common SQL utilities for storage backends. + * [**cdk-signatory**](./crates/cdk-signatory/): Signing utilities and cryptographic operations. + * [**cdk-payment-processor**](./crates/cdk-payment-processor/): Payment processing functionality. + * [**cdk-prometheus**](./crates/cdk-prometheus/): Prometheus metrics integration. + * [**cdk-ffi**](./crates/cdk-ffi/): Foreign Function Interface bindings for other languages. + * [**cdk-integration-tests**](./crates/cdk-integration-tests/): Integration test suite. * [**cdk-mint-rpc**](./crates/cdk-mint-rpc/): Mint management gRPC server and cli. * Binaries: * [**cdk-cli**](./crates/cdk-cli/): Cashu wallet CLI. @@ -70,6 +79,7 @@ For a guide to settings up a development environment see [DEVELOPMENT.md](./DEVE | [21][21] | Clear Authentication | :heavy_check_mark: | | [22][22] | Blind Authentication | :heavy_check_mark: | | [23][23] | Payment Method: BOLT11 | :heavy_check_mark: | +| [25][25] | Payment Method: BOLT12 | :heavy_check_mark: | ## License @@ -109,3 +119,4 @@ Please see the [development guide](DEVELOPMENT.md). [21]: https://github.com/cashubtc/nuts/blob/main/21.md [22]: https://github.com/cashubtc/nuts/blob/main/22.md [23]: https://github.com/cashubtc/nuts/blob/main/23.md +[25]: https://github.com/cashubtc/nuts/blob/main/25.md diff --git a/REGTEST_GUIDE.md b/REGTEST_GUIDE.md new file mode 100644 index 000000000..91958648e --- /dev/null +++ b/REGTEST_GUIDE.md @@ -0,0 +1,267 @@ +# CDK Regtest Environment Guide + +A comprehensive guide for setting up and using the CDK regtest environment for development and testing. + +## Quick Start + +### Start the Environment +```bash +# Start regtest with SQLite database (default) +just regtest + +# Or with REDB database +just regtest redb +``` + +The script will: +1. Check for `mprocs` and offer to install it if missing +2. Build necessary binaries +3. Set up Bitcoin regtest + 4 Lightning nodes + 2 CDK mints +4. Launch `mprocs` TUI showing all component logs +5. Both mints start automatically + +### Stop the Environment +Press `q` in mprocs or `Ctrl+C` in the terminal. Everything cleans up automatically. + +## Network Components + +When running, you get a complete Lightning Network environment: + +### Bitcoin Network +- **Bitcoin RPC**: `127.0.0.1:18443` (user: `testuser`, pass: `testpass`) + +### Lightning Nodes +- **CLN Node 1**: `$CDK_ITESTS_DIR/cln/one/regtest/lightning-rpc` +- **CLN Node 2**: `$CDK_ITESTS_DIR/cln/two/regtest/lightning-rpc` +- **LND Node 1**: `https://localhost:10009` +- **LND Node 2**: `https://localhost:10010` + +### CDK Mints +- **CLN Mint**: `http://127.0.0.1:8085` (connected to CLN node 1) +- **LND Mint**: `http://127.0.0.1:8087` (connected to LND node 2) + +### Environment Variables +Available in all terminals automatically: +- `CDK_TEST_MINT_URL`: CLN mint URL +- `CDK_TEST_MINT_URL_2`: LND mint URL +- `CDK_ITESTS_DIR`: Temporary directory with all data + +## Using the Environment + +All commands work from any terminal - they automatically find the running environment. + +### Lightning Node Operations +```bash +# Get node information +just ln-cln1 getinfo +just ln-cln2 getinfo +just ln-lnd1 getinfo +just ln-lnd2 getinfo + +# Create and pay invoices +just ln-cln1 invoice 1000 label "Test payment" +just ln-lnd1 payinvoice + +# Check balances and channels +just ln-cln1 listfunds +just ln-lnd1 listchannels +``` + +### Bitcoin Operations +```bash +just btc getblockchaininfo # Blockchain status +just btc getbalance # Wallet balance +just btc-mine 5 # Mine 5 blocks +``` + +### CDK Mint Operations +```bash +just mint-info # Show both mints' info +just mint-test # Run integration tests +just restart-mints # Recompile and restart mints +just regtest-status # Check all components +just regtest-logs # Show recent logs +``` + +## mprocs TUI Interface + +The `mprocs` interface shows all component logs in real-time: + +### Controls +- **Arrow keys**: Navigate between processes +- **Enter**: Focus on a process to see its output +- **Tab**: Switch between process list and output view +- **s**: Start a process (if stopped) +- **k**: Kill a process +- **r**: Restart a process +- **PageUp/PageDown**: Scroll through logs +- **?**: Show help +- **q**: Quit and stop environment + +### Process List +- `cln-mint`: CDK mint connected to CLN (auto-started) +- `lnd-mint`: CDK mint connected to LND (auto-started) +- `bitcoind`: Bitcoin regtest node logs +- `cln-one`: CLN node 1 logs +- `cln-two`: CLN node 2 logs +- `lnd-one`: LND node 1 logs +- `lnd-two`: LND node 2 logs + +## Development Workflows + +### Testing Lightning Payment Flow +```bash +# Terminal 1: Start environment +just regtest + +# Terminal 2: Create invoice and pay +just ln-cln1 invoice 1000 test "Test payment" +just ln-lnd1 payinvoice +just ln-cln1 listinvoices +just ln-lnd1 listpayments +``` + +### Developing Mint Code +```bash +# Terminal 1: Keep regtest running +just regtest + +# Terminal 2: After making code changes +just restart-mints # Recompiles and restarts both mints +just mint-info # Test the changes +just mint-test # Run integration tests +``` + +### Using CDK CLI Tools +```bash +# Terminal 1: Start environment +just regtest + +# Terminal 2: Use environment variables +cargo run --bin cdk-cli -- --mint-url $CDK_TEST_MINT_URL mint-info +cargo run --bin cdk-cli -- --mint-url $CDK_TEST_MINT_URL_2 mint-info +``` + +### Direct API Testing +```bash +# Query mint info directly +curl $CDK_TEST_MINT_URL/v1/info | jq +curl $CDK_TEST_MINT_URL/v1/keysets | jq + +# Test both mints +curl http://127.0.0.1:8085/v1/info | jq +curl http://127.0.0.1:8087/v1/info | jq +``` + +## File Structure + +All components run in a temporary directory: + +``` +$CDK_ITESTS_DIR/ +├── bitcoin/ # Bitcoin regtest data +├── cln/ +│ ├── one/ # CLN node 1 data +│ └── two/ # CLN node 2 data +├── lnd/ +│ ├── one/ # LND node 1 data +│ │ ├── tls.cert +│ │ └── data/chain/bitcoin/regtest/admin.macaroon +│ └── two/ # LND node 2 data +├── cln_mint/ # CLN mint working directory +├── lnd_mint/ # LND mint working directory +├── start_cln_mint.sh # Mint startup scripts +├── start_lnd_mint.sh +└── mprocs.yaml # mprocs configuration +``` + +## Installation Requirements + +### mprocs (TUI Interface) +If not installed, the script will offer to install it: +```bash +# Automatic installation during regtest setup +just regtest + +# Manual installation +cargo install mprocs + +# Or via package manager +# Ubuntu/Debian: apt install mprocs +# macOS: brew install mprocs +``` + +### System Dependencies +Managed automatically via Nix development shell: +- Bitcoin Core +- Core Lightning (CLN) +- LND (Lightning Network Daemon) +- Rust toolchain + +## Advanced Usage + +### Manual mprocs Launch +```bash +# If you need to restart just the mprocs interface +source /tmp/cdk_regtest_env +just regtest-logs +``` + +### Environment State +The environment creates a state file at `/tmp/cdk_regtest_env` that: +- Shares environment variables between terminals +- Allows `just` commands to work from anywhere +- Automatically cleaned up when environment stops + +### Process Management +From within mprocs: +- Restart individual mints after code changes +- Monitor specific component logs +- Start/stop services for testing scenarios + +## Troubleshooting + +### Environment Not Starting +- Check that ports are available: 8085, 8087, 18443, 19846, 19847, 10009, 10010 +- Ensure the Nix development shell is active: `nix develop` +- Check individual component logs in mprocs + +### Helper Commands Not Working +- Ensure the regtest environment is running +- Check that `/tmp/cdk_regtest_env` file exists +- Verify environment variables are set: `echo $CDK_TEST_MINT_URL` + +### Connection Issues +- Use `just regtest-status` to check component health +- Check mint logs with `just regtest-logs` +- Verify Lightning node status with `just ln-cln1 getinfo` + +### mprocs Issues +- If mprocs crashes, processes continue running +- Use `Ctrl+C` in the original terminal to clean up +- Restart with `just regtest-logs` + +## Common Error Solutions + +### "Port already in use" +```bash +# Find and kill processes using ports +sudo lsof -ti:8085 | xargs kill -9 +sudo lsof -ti:8087 | xargs kill -9 +``` + +### "Environment not found" +```bash +# Clean up and restart +rm -f /tmp/cdk_regtest_env +just regtest +``` + +### "Binary not found" +```bash +# Rebuild binaries +just build +just regtest +``` + +This environment provides everything needed for CDK development and testing in a single, easy-to-use interface! 🎉 diff --git a/crates/cashu/Cargo.toml b/crates/cashu/Cargo.toml index afc4e9f12..830b39573 100644 --- a/crates/cashu/Cargo.toml +++ b/crates/cashu/Cargo.toml @@ -13,19 +13,20 @@ readme = "README.md" [features] default = ["mint", "wallet", "auth"] swagger = ["dep:utoipa"] -mint = ["dep:uuid"] +mint = [] wallet = [] auth = ["dep:strum", "dep:strum_macros", "dep:regex"] bench = [] [dependencies] -uuid = { workspace = true, optional = true } +uuid.workspace = true bitcoin.workspace = true cbor-diag.workspace = true ciborium.workspace = true once_cell.workspace = true serde.workspace = true lightning-invoice.workspace = true +lightning.workspace = true thiserror.workspace = true tracing.workspace = true url.workspace = true @@ -35,10 +36,11 @@ serde_with.workspace = true regex = { workspace = true, optional = true } strum = { workspace = true, optional = true } strum_macros = { workspace = true, optional = true } +zeroize = "1" +web-time.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] -instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] } +uuid = { workspace = true, features = ["js"], optional = true } [dev-dependencies] bip39.workspace = true -uuid.workspace = true diff --git a/crates/cashu/src/amount.rs b/crates/cashu/src/amount.rs index 9cf893a02..bd44c4ac0 100644 --- a/crates/cashu/src/amount.rs +++ b/crates/cashu/src/amount.rs @@ -3,13 +3,16 @@ //! Is any unit and will be treated as the unit of the wallet use std::cmp::Ordering; +use std::collections::HashMap; use std::fmt; use std::str::FromStr; +use lightning::offers::offer::Offer; use serde::{Deserialize, Serialize}; use thiserror::Error; use crate::nuts::CurrencyUnit; +use crate::Id; /// Amount Error #[derive(Debug, Error)] @@ -26,6 +29,12 @@ pub enum Error { /// Invalid amount #[error("Invalid Amount: {0}")] InvalidAmount(String), + /// Amount undefined + #[error("Amount undefined")] + AmountUndefined, + /// Utf8 parse error + #[error(transparent)] + Utf8ParseError(#[from] std::string::FromUtf8Error), } /// Amount can be any unit @@ -34,6 +43,40 @@ pub enum Error { #[serde(transparent)] pub struct Amount(u64); +/// Fees and and amount type, it can be casted just as a reference to the inner amounts, or a single +/// u64 which is the fee +#[derive(Debug, Clone)] +pub struct FeeAndAmounts { + fee: u64, + amounts: Vec, +} + +impl From<(u64, Vec)> for FeeAndAmounts { + fn from(value: (u64, Vec)) -> Self { + Self { + fee: value.0, + amounts: value.1, + } + } +} + +impl FeeAndAmounts { + /// Fees + #[inline(always)] + pub fn fee(&self) -> u64 { + self.fee + } + + /// Amounts + #[inline(always)] + pub fn amounts(&self) -> &[u64] { + &self.amounts + } +} + +/// Fees and Amounts for each Keyset +pub type KeysetFeeAndAmounts = HashMap; + impl FromStr for Amount { type Err = Error; @@ -53,31 +96,38 @@ impl Amount { pub const ONE: Amount = Amount(1); /// Split into parts that are powers of two - pub fn split(&self) -> Vec { - let sats = self.0; - (0_u64..64) + pub fn split(&self, fee_and_amounts: &FeeAndAmounts) -> Vec { + fee_and_amounts + .amounts + .iter() .rev() - .filter_map(|bit| { - let part = 1 << bit; - ((sats & part) == part).then_some(Self::from(part)) + .fold((Vec::new(), self.0), |(mut acc, total), &amount| { + if total >= amount { + acc.push(Self::from(amount)); + } + (acc, total % amount) }) - .collect() + .0 } /// Split into parts that are powers of two by target - pub fn split_targeted(&self, target: &SplitTarget) -> Result, Error> { + pub fn split_targeted( + &self, + target: &SplitTarget, + fee_and_amounts: &FeeAndAmounts, + ) -> Result, Error> { let mut parts = match target { - SplitTarget::None => self.split(), + SplitTarget::None => self.split(fee_and_amounts), SplitTarget::Value(amount) => { if self.le(amount) { - return Ok(self.split()); + return Ok(self.split(fee_and_amounts)); } let mut parts_total = Amount::ZERO; let mut parts = Vec::new(); // The powers of two that are need to create target value - let parts_of_value = amount.split(); + let parts_of_value = amount.split(fee_and_amounts); while parts_total.lt(self) { for part in parts_of_value.iter().copied() { @@ -85,7 +135,7 @@ impl Amount { parts.push(part); } else { let amount_left = *self - parts_total; - parts.extend(amount_left.split()); + parts.extend(amount_left.split(fee_and_amounts)); } parts_total = Amount::try_sum(parts.clone().iter().copied())?; @@ -108,7 +158,7 @@ impl Amount { } Ordering::Greater => { let extra = *self - values_total; - let mut extra_amount = extra.split(); + let mut extra_amount = extra.split(fee_and_amounts); let mut values = values.clone(); values.append(&mut extra_amount); @@ -123,14 +173,19 @@ impl Amount { } /// Splits amount into powers of two while accounting for the swap fee - pub fn split_with_fee(&self, fee_ppk: u64) -> Result, Error> { - let without_fee_amounts = self.split(); - let fee_ppk = fee_ppk * without_fee_amounts.len() as u64; - let fee = Amount::from(fee_ppk.div_ceil(1000)); + pub fn split_with_fee(&self, fee_and_amounts: &FeeAndAmounts) -> Result, Error> { + let without_fee_amounts = self.split(fee_and_amounts); + let total_fee_ppk = fee_and_amounts + .fee + .checked_mul(without_fee_amounts.len() as u64) + .ok_or(Error::AmountOverflow)?; + let fee = Amount::from(total_fee_ppk.div_ceil(1000)); let new_amount = self.checked_add(fee).ok_or(Error::AmountOverflow)?; - let split = new_amount.split(); - let split_fee_ppk = split.len() as u64 * fee_ppk; + let split = new_amount.split(fee_and_amounts); + let split_fee_ppk = (split.len() as u64) + .checked_mul(fee_and_amounts.fee) + .ok_or(Error::AmountOverflow)?; let split_fee = Amount::from(split_fee_ppk.div_ceil(1000)); if let Some(net_amount) = new_amount.checked_sub(split_fee) { @@ -140,7 +195,7 @@ impl Amount { } self.checked_add(Amount::ONE) .ok_or(Error::AmountOverflow)? - .split_with_fee(fee_ppk) + .split_with_fee(fee_and_amounts) } /// Checked addition for Amount. Returns None if overflow occurs. @@ -181,6 +236,29 @@ impl Amount { ) -> Result { to_unit(self.0, current_unit, target_unit) } + /// + /// Convert to u64 + pub fn to_u64(self) -> u64 { + self.0 + } + + /// Convert to i64 + pub fn to_i64(self) -> Option { + if self.0 <= i64::MAX as u64 { + Some(self.0 as i64) + } else { + None + } + } + + /// Create from i64, returning None if negative + pub fn from_i64(value: i64) -> Option { + if value >= 0 { + Some(Amount(value as u64)) + } else { + None + } + } } impl Default for Amount { @@ -233,13 +311,16 @@ impl std::ops::Add for Amount { type Output = Amount; fn add(self, rhs: Amount) -> Self::Output { - Amount(self.0.checked_add(rhs.0).expect("Addition error")) + self.checked_add(rhs) + .expect("Addition overflow: the sum of the amounts exceeds the maximum value") } } impl std::ops::AddAssign for Amount { fn add_assign(&mut self, rhs: Self) { - self.0 = self.0.checked_add(rhs.0).expect("Addition error"); + *self = self + .checked_add(rhs) + .expect("AddAssign overflow: the sum of the amounts exceeds the maximum value"); } } @@ -247,13 +328,16 @@ impl std::ops::Sub for Amount { type Output = Amount; fn sub(self, rhs: Amount) -> Self::Output { - Amount(self.0 - rhs.0) + self.checked_sub(rhs) + .expect("Subtraction underflow: cannot subtract a larger amount from a smaller amount") } } impl std::ops::SubAssign for Amount { fn sub_assign(&mut self, other: Self) { - self.0 -= other.0; + *self = self + .checked_sub(other) + .expect("SubAssign underflow: cannot subtract a larger amount from a smaller amount"); } } @@ -261,7 +345,8 @@ impl std::ops::Mul for Amount { type Output = Self; fn mul(self, other: Self) -> Self::Output { - Amount(self.0 * other.0) + self.checked_mul(other) + .expect("Multiplication overflow: the product of the amounts exceeds the maximum value") } } @@ -269,10 +354,32 @@ impl std::ops::Div for Amount { type Output = Self; fn div(self, other: Self) -> Self::Output { - Amount(self.0 / other.0) + self.checked_div(other) + .expect("Division error: cannot divide by zero or overflow occurred") } } +/// Convert offer to amount in unit +pub fn amount_for_offer(offer: &Offer, unit: &CurrencyUnit) -> Result { + let offer_amount = offer.amount().ok_or(Error::AmountUndefined)?; + + let (amount, currency) = match offer_amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => { + (amount_msats, CurrencyUnit::Msat) + } + lightning::offers::offer::Amount::Currency { + iso4217_code, + amount, + } => ( + amount, + CurrencyUnit::from_str(&String::from_utf8(iso4217_code.to_vec())?) + .map_err(|_| Error::CannotConvertUnits)?, + ), + }; + + to_unit(amount, ¤cy, unit).map_err(|_err| Error::CannotConvertUnits) +} + /// Kinds of targeting that are supported #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Default, Serialize, Deserialize)] pub enum SplitTarget { @@ -301,10 +408,20 @@ where match (current_unit, target_unit) { (CurrencyUnit::Sat, CurrencyUnit::Sat) => Ok(amount.into()), (CurrencyUnit::Msat, CurrencyUnit::Msat) => Ok(amount.into()), - (CurrencyUnit::Sat, CurrencyUnit::Msat) => Ok((amount * MSAT_IN_SAT).into()), + (CurrencyUnit::Sat, CurrencyUnit::Msat) => amount + .checked_mul(MSAT_IN_SAT) + .map(Amount::from) + .ok_or(Error::AmountOverflow), (CurrencyUnit::Msat, CurrencyUnit::Sat) => Ok((amount / MSAT_IN_SAT).into()), (CurrencyUnit::Usd, CurrencyUnit::Usd) => Ok(amount.into()), (CurrencyUnit::Eur, CurrencyUnit::Eur) => Ok(amount.into()), + (CurrencyUnit::Custom(from_unit), CurrencyUnit::Custom(to_unit)) => { + if from_unit == to_unit { + Ok(amount.into()) + } else { + Err(Error::CannotConvertUnits) + } + } _ => Err(Error::CannotConvertUnits), } } @@ -315,34 +432,43 @@ mod tests { #[test] fn test_split_amount() { - assert_eq!(Amount::from(1).split(), vec![Amount::from(1)]); - assert_eq!(Amount::from(2).split(), vec![Amount::from(2)]); + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + + assert_eq!( + Amount::from(1).split(&fee_and_amounts), + vec![Amount::from(1)] + ); assert_eq!( - Amount::from(3).split(), + Amount::from(2).split(&fee_and_amounts), + vec![Amount::from(2)] + ); + assert_eq!( + Amount::from(3).split(&fee_and_amounts), vec![Amount::from(2), Amount::from(1)] ); let amounts: Vec = [8, 2, 1].iter().map(|a| Amount::from(*a)).collect(); - assert_eq!(Amount::from(11).split(), amounts); + assert_eq!(Amount::from(11).split(&fee_and_amounts), amounts); let amounts: Vec = [128, 64, 32, 16, 8, 4, 2, 1] .iter() .map(|a| Amount::from(*a)) .collect(); - assert_eq!(Amount::from(255).split(), amounts); + assert_eq!(Amount::from(255).split(&fee_and_amounts), amounts); } #[test] fn test_split_target_amount() { + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); let amount = Amount(65); let split = amount - .split_targeted(&SplitTarget::Value(Amount(32))) + .split_targeted(&SplitTarget::Value(Amount(32)), &fee_and_amounts) .unwrap(); assert_eq!(vec![Amount(1), Amount(32), Amount(32)], split); let amount = Amount(150); let split = amount - .split_targeted(&SplitTarget::Value(Amount::from(50))) + .split_targeted(&SplitTarget::Value(Amount::from(50)), &fee_and_amounts) .unwrap(); assert_eq!( vec![ @@ -362,7 +488,7 @@ mod tests { let amount = Amount::from(63); let split = amount - .split_targeted(&SplitTarget::Value(Amount::from(32))) + .split_targeted(&SplitTarget::Value(Amount::from(32)), &fee_and_amounts) .unwrap(); assert_eq!( vec![ @@ -379,34 +505,169 @@ mod tests { #[test] fn test_split_with_fee() { + let fee_and_amounts = (1, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); let amount = Amount(2); - let fee_ppk = 1; - let split = amount.split_with_fee(fee_ppk).unwrap(); + let split = amount.split_with_fee(&fee_and_amounts).unwrap(); assert_eq!(split, vec![Amount(2), Amount(1)]); let amount = Amount(3); - let fee_ppk = 1; - let split = amount.split_with_fee(fee_ppk).unwrap(); + let split = amount.split_with_fee(&fee_and_amounts).unwrap(); assert_eq!(split, vec![Amount(4)]); let amount = Amount(3); - let fee_ppk = 1000; + let fee_and_amounts = (1000, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + + let split = amount.split_with_fee(&fee_and_amounts).unwrap(); + // With fee_ppk=1000 (100%), amount 3 requires proofs totaling at least 5 + // to cover both the amount (3) and fees (~2 for 2 proofs) + assert_eq!(split, vec![Amount(4), Amount(1)]); + } - let split = amount.split_with_fee(fee_ppk).unwrap(); - assert_eq!(split, vec![Amount(32)]); + #[test] + fn test_split_with_fee_reported_issue() { + let fee_and_amounts = (100, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + // Test the reported issue: mint 600, send 300 with fee_ppk=100 + let amount = Amount(300); + + let split = amount.split_with_fee(&fee_and_amounts).unwrap(); + + // Calculate the total fee for the split + let total_fee_ppk = (split.len() as u64) * fee_and_amounts.fee; + let total_fee = Amount::from(total_fee_ppk.div_ceil(1000)); + + // The split should cover the amount plus fees + let split_total = Amount::try_sum(split.iter().copied()).unwrap(); + assert!( + split_total >= amount + total_fee, + "Split total {} should be >= amount {} + fee {}", + split_total, + amount, + total_fee + ); + } + + #[test] + fn test_split_with_fee_edge_cases() { + // Test various amounts with fee_ppk=100 + let test_cases = vec![ + (Amount(1), 100), + (Amount(10), 100), + (Amount(50), 100), + (Amount(100), 100), + (Amount(200), 100), + (Amount(300), 100), + (Amount(500), 100), + (Amount(600), 100), + (Amount(1000), 100), + (Amount(1337), 100), + (Amount(5000), 100), + ]; + + for (amount, fee_ppk) in test_cases { + let fee_and_amounts = + (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + let result = amount.split_with_fee(&fee_and_amounts); + assert!( + result.is_ok(), + "split_with_fee failed for amount {} with fee_ppk {}: {:?}", + amount, + fee_ppk, + result.err() + ); + + let split = result.unwrap(); + + // Verify the split covers the required amount + let split_total = Amount::try_sum(split.iter().copied()).unwrap(); + let fee_for_split = (split.len() as u64) * fee_ppk; + let total_fee = Amount::from(fee_for_split.div_ceil(1000)); + + // The net amount after fees should be at least the original amount + let net_amount = split_total.checked_sub(total_fee); + assert!( + net_amount.is_some(), + "Net amount calculation failed for amount {} with fee_ppk {}", + amount, + fee_ppk + ); + assert!( + net_amount.unwrap() >= amount, + "Net amount {} is less than required {} for amount {} with fee_ppk {}", + net_amount.unwrap(), + amount, + amount, + fee_ppk + ); + } + } + + #[test] + fn test_split_with_fee_high_fees() { + // Test with very high fees + let test_cases = vec![ + (Amount(10), 500), // 50% fee + (Amount(10), 1000), // 100% fee + (Amount(10), 2000), // 200% fee + (Amount(100), 500), + (Amount(100), 1000), + (Amount(100), 2000), + ]; + + for (amount, fee_ppk) in test_cases { + let fee_and_amounts = + (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + let result = amount.split_with_fee(&fee_and_amounts); + assert!( + result.is_ok(), + "split_with_fee failed for amount {} with fee_ppk {}: {:?}", + amount, + fee_ppk, + result.err() + ); + + let split = result.unwrap(); + let split_total = Amount::try_sum(split.iter().copied()).unwrap(); + + // With high fees, we just need to ensure we can cover the amount + assert!( + split_total > amount, + "Split total {} should be greater than amount {} for fee_ppk {}", + split_total, + amount, + fee_ppk + ); + } + } + + #[test] + fn test_split_with_fee_recursion_limit() { + // Test that the recursion doesn't go infinite + // This tests the edge case where the method keeps adding Amount::ONE + let amount = Amount(1); + let fee_ppk = 10000; + let fee_and_amounts = (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + + let result = amount.split_with_fee(&fee_and_amounts); + assert!( + result.is_ok(), + "split_with_fee should handle extreme fees without infinite recursion" + ); } #[test] fn test_split_values() { + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); let amount = Amount(10); let target = vec![Amount(2), Amount(4), Amount(4)]; let split_target = SplitTarget::Values(target.clone()); - let values = amount.split_targeted(&split_target).unwrap(); + let values = amount + .split_targeted(&split_target, &fee_and_amounts) + .unwrap(); assert_eq!(target, values); @@ -414,13 +675,15 @@ mod tests { let split_target = SplitTarget::Values(vec![Amount(2), Amount(4)]); - let values = amount.split_targeted(&split_target).unwrap(); + let values = amount + .split_targeted(&split_target, &fee_and_amounts) + .unwrap(); assert_eq!(target, values); let split_target = SplitTarget::Values(vec![Amount(2), Amount(10)]); - let values = amount.split_targeted(&split_target); + let values = amount.split_targeted(&split_target, &fee_and_amounts); assert!(values.is_err()) } @@ -497,4 +760,433 @@ mod tests { assert!(converted.is_err()); } + + /// Tests that the subtraction operator correctly computes the difference between amounts. + /// + /// This test verifies that the `-` operator for Amount produces the expected result. + /// It's particularly important because the subtraction operation is used in critical + /// code paths like `split_targeted`, where incorrect subtraction could lead to + /// infinite loops or wrong calculations. + /// + /// Mutant testing: Catches mutations that replace the subtraction implementation + /// with `Default::default()` (returning Amount::ZERO), which would cause infinite + /// loops in `split_targeted` at line 138 where `*self - parts_total` is computed. + #[test] + fn test_amount_sub_operator() { + let amount1 = Amount::from(100); + let amount2 = Amount::from(30); + + let result = amount1 - amount2; + assert_eq!(result, Amount::from(70)); + + let amount1 = Amount::from(1000); + let amount2 = Amount::from(1); + + let result = amount1 - amount2; + assert_eq!(result, Amount::from(999)); + + let amount1 = Amount::from(255); + let amount2 = Amount::from(128); + + let result = amount1 - amount2; + assert_eq!(result, Amount::from(127)); + } + + /// Tests that the subtraction operator panics when attempting to subtract + /// a larger amount from a smaller amount (underflow). + /// + /// This test verifies the safety property that Amount subtraction will panic + /// rather than wrap around on underflow. This is critical for preventing + /// bugs where negative amounts could be interpreted as very large positive amounts. + /// + /// Mutant testing: Catches mutations that remove the panic behavior or return + /// default values instead of properly handling underflow. + #[test] + #[should_panic(expected = "Subtraction underflow")] + fn test_amount_sub_underflow() { + let amount1 = Amount::from(30); + let amount2 = Amount::from(100); + + let _result = amount1 - amount2; + } + + /// Tests that checked_add correctly computes the sum and returns the actual value. + /// + /// This is critical because checked_add is used in recursive functions like + /// split_with_fee. If it returns Some(Amount::ZERO) instead of the actual sum, + /// the recursion would never terminate. + /// + /// Mutant testing: Kills mutations that replace the implementation with + /// `Some(Default::default())`, which would cause infinite loops in split_with_fee + /// at line 198 where it recursively calls itself with incremented amounts. + #[test] + fn test_checked_add_returns_correct_value() { + let amount1 = Amount::from(100); + let amount2 = Amount::from(50); + + let result = amount1.checked_add(amount2); + assert_eq!(result, Some(Amount::from(150))); + + let amount1 = Amount::from(1); + let amount2 = Amount::from(1); + + let result = amount1.checked_add(amount2); + assert_eq!(result, Some(Amount::from(2))); + assert_ne!(result, Some(Amount::ZERO)); + + let amount1 = Amount::from(1000); + let amount2 = Amount::from(337); + + let result = amount1.checked_add(amount2); + assert_eq!(result, Some(Amount::from(1337))); + } + + /// Tests that checked_add returns None on overflow. + #[test] + fn test_checked_add_overflow() { + let amount1 = Amount::from(u64::MAX); + let amount2 = Amount::from(1); + + let result = amount1.checked_add(amount2); + assert!(result.is_none()); + } + + /// Tests that try_sum correctly computes the total sum of amounts. + /// + /// This is critical because try_sum is used in loops like split_targeted at line 130 + /// to track progress. If it returns Ok(Amount::ZERO) instead of the actual sum, + /// the loop condition `parts_total.eq(self)` would never be true, causing an infinite loop. + /// + /// Mutant testing: Kills mutations that replace the implementation with + /// `Ok(Default::default())`, which would cause infinite loops. + #[test] + fn test_try_sum_returns_correct_value() { + let amounts = vec![Amount::from(10), Amount::from(20), Amount::from(30)]; + let result = Amount::try_sum(amounts).unwrap(); + assert_eq!(result, Amount::from(60)); + assert_ne!(result, Amount::ZERO); + + let amounts = vec![Amount::from(1), Amount::from(1), Amount::from(1)]; + let result = Amount::try_sum(amounts).unwrap(); + assert_eq!(result, Amount::from(3)); + + let amounts = vec![Amount::from(100)]; + let result = Amount::try_sum(amounts).unwrap(); + assert_eq!(result, Amount::from(100)); + + let empty: Vec = vec![]; + let result = Amount::try_sum(empty).unwrap(); + assert_eq!(result, Amount::ZERO); + } + + /// Tests that try_sum returns error on overflow. + #[test] + fn test_try_sum_overflow() { + let amounts = vec![Amount::from(u64::MAX), Amount::from(1)]; + let result = Amount::try_sum(amounts); + assert!(result.is_err()); + } + + /// Tests that split returns a non-empty vec with actual values, not defaults. + /// + /// The split function is used in split_targeted's while loop (line 122). + /// If split returns an empty vec or vec with Amount::ZERO when it shouldn't, + /// the loop that extends parts with split results would never make progress, + /// causing an infinite loop. + /// + /// Mutant testing: Kills mutations that replace split with `vec![]` or + /// `vec![Default::default()]` which would cause infinite loops. + #[test] + fn test_split_returns_correct_values() { + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + + let amount = Amount::from(11); + let result = amount.split(&fee_and_amounts); + assert!(!result.is_empty()); + assert_eq!(Amount::try_sum(result.iter().copied()).unwrap(), amount); + + let amount = Amount::from(255); + let result = amount.split(&fee_and_amounts); + assert!(!result.is_empty()); + assert_eq!(Amount::try_sum(result.iter().copied()).unwrap(), amount); + + let amount = Amount::from(7); + let result = amount.split(&fee_and_amounts); + assert_eq!( + result, + vec![Amount::from(4), Amount::from(2), Amount::from(1)] + ); + for r in &result { + assert_ne!(*r, Amount::ZERO); + } + } + + /// Tests that the modulo operation in split works correctly. + /// + /// At line 108, split uses modulo (%) to compute the remainder. + /// If this is mutated to division (/), it would produce wrong results + /// that could cause infinite loops in code that depends on split. + /// + /// Mutant testing: Kills mutations that replace `%` with `/`. + #[test] + fn test_split_modulo_operation() { + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + + let amount = Amount::from(15); + let result = amount.split(&fee_and_amounts); + + assert_eq!( + result, + vec![ + Amount::from(8), + Amount::from(4), + Amount::from(2), + Amount::from(1) + ] + ); + + let total = Amount::try_sum(result.iter().copied()).unwrap(); + assert_eq!(total, amount); + } + + /// Tests that From correctly converts values to Amount. + /// + /// This conversion is used throughout the codebase including in loops and split operations. + /// If it returns Default::default() (Amount::ZERO) instead of the actual value, + /// it can cause infinite loops where amounts are being accumulated or compared. + /// + /// Mutant testing: Kills mutations that replace From with `Default::default()`. + #[test] + fn test_from_u64_returns_correct_value() { + let amount = Amount::from(100u64); + assert_eq!(amount, Amount(100)); + assert_ne!(amount, Amount::ZERO); + + let amount = Amount::from(1u64); + assert_eq!(amount, Amount(1)); + assert_eq!(amount, Amount::ONE); + + let amount = Amount::from(1337u64); + assert_eq!(amount.to_u64(), 1337); + } + + /// Tests that checked_mul returns the correct product value. + /// + /// This is critical for any multiplication operations. If it returns None + /// or Some(Amount::ZERO) instead of the actual product, calculations will be wrong. + /// + /// Mutant testing: Kills mutations that replace checked_mul with None or Some(Default::default()). + #[test] + fn test_checked_mul_returns_correct_value() { + let amount1 = Amount::from(10); + let amount2 = Amount::from(5); + let result = amount1.checked_mul(amount2); + assert_eq!(result, Some(Amount::from(50))); + assert_ne!(result, None); + assert_ne!(result, Some(Amount::ZERO)); + + let amount1 = Amount::from(100); + let amount2 = Amount::from(20); + let result = amount1.checked_mul(amount2); + assert_eq!(result, Some(Amount::from(2000))); + assert_ne!(result, Some(Amount::ZERO)); + + let amount1 = Amount::from(7); + let amount2 = Amount::from(13); + let result = amount1.checked_mul(amount2); + assert_eq!(result, Some(Amount::from(91))); + + // Test multiplication by zero + let amount1 = Amount::from(100); + let amount2 = Amount::ZERO; + let result = amount1.checked_mul(amount2); + assert_eq!(result, Some(Amount::ZERO)); + + // Test multiplication by one + let amount1 = Amount::from(42); + let amount2 = Amount::ONE; + let result = amount1.checked_mul(amount2); + assert_eq!(result, Some(Amount::from(42))); + + // Test overflow + let amount1 = Amount::from(u64::MAX); + let amount2 = Amount::from(2); + let result = amount1.checked_mul(amount2); + assert!(result.is_none()); + } + + /// Tests that checked_div returns the correct quotient value. + /// + /// This is critical for division operations. If it returns None or + /// Some(Amount::ZERO) instead of the actual quotient, calculations will be wrong. + /// + /// Mutant testing: Kills mutations that replace checked_div with None or Some(Default::default()). + #[test] + fn test_checked_div_returns_correct_value() { + let amount1 = Amount::from(100); + let amount2 = Amount::from(5); + let result = amount1.checked_div(amount2); + assert_eq!(result, Some(Amount::from(20))); + assert_ne!(result, None); + assert_ne!(result, Some(Amount::ZERO)); + + let amount1 = Amount::from(1000); + let amount2 = Amount::from(10); + let result = amount1.checked_div(amount2); + assert_eq!(result, Some(Amount::from(100))); + assert_ne!(result, Some(Amount::ZERO)); + + let amount1 = Amount::from(91); + let amount2 = Amount::from(7); + let result = amount1.checked_div(amount2); + assert_eq!(result, Some(Amount::from(13))); + + // Test division by one + let amount1 = Amount::from(42); + let amount2 = Amount::ONE; + let result = amount1.checked_div(amount2); + assert_eq!(result, Some(Amount::from(42))); + + // Test integer division (truncation) + let amount1 = Amount::from(10); + let amount2 = Amount::from(3); + let result = amount1.checked_div(amount2); + assert_eq!(result, Some(Amount::from(3))); + + // Test division by zero + let amount1 = Amount::from(100); + let amount2 = Amount::ZERO; + let result = amount1.checked_div(amount2); + assert!(result.is_none()); + } + + /// Tests that Amount::convert_unit returns the correct converted value. + /// + /// This is critical for unit conversions. If it returns Ok(Amount::ZERO) + /// instead of the actual converted value, all conversions will be wrong. + /// + /// Mutant testing: Kills mutations that replace convert_unit with Ok(Default::default()). + #[test] + fn test_convert_unit_returns_correct_value() { + let amount = Amount::from(1000); + let result = amount + .convert_unit(&CurrencyUnit::Sat, &CurrencyUnit::Msat) + .unwrap(); + assert_eq!(result, Amount::from(1_000_000)); + assert_ne!(result, Amount::ZERO); + + let amount = Amount::from(5000); + let result = amount + .convert_unit(&CurrencyUnit::Msat, &CurrencyUnit::Sat) + .unwrap(); + assert_eq!(result, Amount::from(5)); + assert_ne!(result, Amount::ZERO); + + let amount = Amount::from(123); + let result = amount + .convert_unit(&CurrencyUnit::Sat, &CurrencyUnit::Sat) + .unwrap(); + assert_eq!(result, Amount::from(123)); + + let amount = Amount::from(456); + let result = amount + .convert_unit(&CurrencyUnit::Usd, &CurrencyUnit::Usd) + .unwrap(); + assert_eq!(result, Amount::from(456)); + + let amount = Amount::from(789); + let result = amount + .convert_unit(&CurrencyUnit::Eur, &CurrencyUnit::Eur) + .unwrap(); + assert_eq!(result, Amount::from(789)); + + // Test invalid conversion + let amount = Amount::from(100); + let result = amount.convert_unit(&CurrencyUnit::Sat, &CurrencyUnit::Eur); + assert!(result.is_err()); + } + + /// Tests that Amount::to_i64() returns the correct value. + /// + /// Mutant testing: Kills mutations that replace the return value with: + /// - None + /// - Some(0) + /// - Some(1) + /// - Some(-1) + /// Also catches mutation that replaces <= with > in the comparison. + #[test] + fn test_amount_to_i64_returns_correct_value() { + // Test with value 100 (catches None, Some(0), Some(1), Some(-1) mutations) + let amount = Amount::from(100); + let result = amount.to_i64(); + assert_eq!(result, Some(100)); + assert!(result.is_some()); + assert_ne!(result, Some(0)); + assert_ne!(result, Some(1)); + assert_ne!(result, Some(-1)); + + // Test with value 1000 (catches all constant mutations) + let amount = Amount::from(1000); + let result = amount.to_i64(); + assert_eq!(result, Some(1000)); + assert_ne!(result, None); + assert_ne!(result, Some(0)); + assert_ne!(result, Some(1)); + assert_ne!(result, Some(-1)); + + // Test with value 2 (specifically catches Some(1) mutation) + let amount = Amount::from(2); + let result = amount.to_i64(); + assert_eq!(result, Some(2)); + assert_ne!(result, Some(1)); + + // Test with i64::MAX (should return Some(i64::MAX)) + // This catches the <= vs > mutation: if <= becomes >, this would return None + let amount = Amount::from(i64::MAX as u64); + let result = amount.to_i64(); + assert_eq!(result, Some(i64::MAX)); + assert!(result.is_some()); + + // Test with i64::MAX + 1 (should return None) + // This is the boundary case for the <= comparison + let amount = Amount::from(i64::MAX as u64 + 1); + let result = amount.to_i64(); + assert!(result.is_none()); + + // Test with u64::MAX (should return None) + let amount = Amount::from(u64::MAX); + let result = amount.to_i64(); + assert!(result.is_none()); + + // Edge case: 0 should return Some(0) + let amount = Amount::from(0); + let result = amount.to_i64(); + assert_eq!(result, Some(0)); + + // Edge case: 1 should return Some(1) + let amount = Amount::from(1); + let result = amount.to_i64(); + assert_eq!(result, Some(1)); + } + + /// Tests the boundary condition for Amount::to_i64() at i64::MAX. + /// + /// This specifically tests the <= vs > mutation in the condition + /// `if self.0 <= i64::MAX as u64`. + #[test] + fn test_amount_to_i64_boundary() { + // Exactly at i64::MAX - should succeed + let at_max = Amount::from(i64::MAX as u64); + assert!(at_max.to_i64().is_some()); + assert_eq!(at_max.to_i64().unwrap(), i64::MAX); + + // One above i64::MAX - should fail + let above_max = Amount::from(i64::MAX as u64 + 1); + assert!(above_max.to_i64().is_none()); + + // One below i64::MAX - should succeed + let below_max = Amount::from(i64::MAX as u64 - 1); + assert!(below_max.to_i64().is_some()); + assert_eq!(below_max.to_i64().unwrap(), i64::MAX - 1); + } } diff --git a/crates/cashu/src/dhke.rs b/crates/cashu/src/dhke.rs index 4e3680492..52bd74fde 100644 --- a/crates/cashu/src/dhke.rs +++ b/crates/cashu/src/dhke.rs @@ -388,4 +388,168 @@ mod tests { assert!(verify_message(&bob_sec, unblinded, &message).is_ok()); } + + /// Tests that `verify_message` correctly rejects verification when using an incorrect key. + /// + /// This test ensures that the verification process fails when attempting to verify + /// a signature with a different key than the one used to create it. This is critical + /// for security - if this check didn't exist, tokens could be forged by anyone. + /// + /// Mutant testing: Catches mutations that remove or weaken the key comparison logic + /// in `verify_message`, such as always returning Ok or ignoring the key parameter. + #[test] + fn test_verify_message_wrong_key() { + // Test that verify_message fails with wrong key + let message = b"test message"; + let correct_key = + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + let wrong_key = + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002") + .unwrap(); + + let (blinded, r) = blind_message(message, None).unwrap(); + let signed = sign_message(&correct_key, &blinded).unwrap(); + let unblinded = unblind_message(&signed, &r, &correct_key.public_key()).unwrap(); + + // Should fail with wrong key + assert!(verify_message(&wrong_key, unblinded, message).is_err()); + } + + /// Tests that `verify_message` correctly rejects verification when the message doesn't match. + /// + /// This test ensures that attempting to verify a signature against a different message + /// than the one originally signed results in an error. This prevents message substitution + /// attacks where an attacker might try to claim a signature for one message is valid + /// for a different message. + /// + /// Mutant testing: Catches mutations that remove or weaken the message comparison logic, + /// such as skipping the hash_to_curve step or ignoring the message parameter entirely. + #[test] + fn test_verify_message_wrong_message() { + // Test that verify_message fails with wrong message + let message = b"test message"; + let wrong_message = b"wrong message"; + let key = + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + + let (blinded, r) = blind_message(message, None).unwrap(); + let signed = sign_message(&key, &blinded).unwrap(); + let unblinded = unblind_message(&signed, &r, &key.public_key()).unwrap(); + + // Should fail with wrong message + assert!(verify_message(&key, unblinded, wrong_message).is_err()); + } + + /// Tests that `construct_proofs` returns an error when input vectors have mismatched lengths. + /// + /// This test verifies that the function properly validates that the `promises`, `rs`, and + /// `secrets` vectors all have the same length before processing. This is essential for + /// correctness - each proof requires exactly one promise, one blinding factor (r), and + /// one secret. Mismatched lengths would indicate a programming error or corrupted data. + /// + /// Mutant testing: Catches mutations that remove or weaken the length validation check + /// at the beginning of `construct_proofs`, such as changing `!=` to `==` or removing + /// the validation entirely, which could lead to panics or incorrect proof construction. + #[test] + fn test_construct_proofs_length_mismatch() { + use std::collections::BTreeMap; + + use crate::nuts::nut02::Id; + use crate::Amount; + + // Test that construct_proofs fails when lengths don't match + let mut keys_map = BTreeMap::new(); + keys_map.insert(Amount::from(1), SecretKey::generate().public_key()); + let keys = Keys::new(keys_map); + + // Mismatched promises and rs lengths + let promise = BlindSignature { + amount: Amount::from(1), + c: SecretKey::generate().public_key(), + keyset_id: Id::from_str("00deadbeef123456").unwrap(), + dleq: None, + }; + let promises = vec![promise]; + let rs = vec![SecretKey::generate(), SecretKey::generate()]; // Different length + let secrets = vec![Secret::from_str("test").unwrap()]; + + let result = construct_proofs(promises, rs, secrets, &keys); + assert!(result.is_err()); + } + + /// Tests that `construct_proofs` returns the correct number of proof objects. + /// + /// This test verifies that when given N valid inputs (promises, blinding factors, secrets), + /// the function returns exactly N proofs, not zero or any other count. This ensures that + /// the loop in `construct_proofs` actually processes all inputs and accumulates results + /// correctly. + /// + /// Mutant testing: Specifically designed to catch mutations that replace the function body + /// with `Ok(Default::default())` or similar shortcuts that would return an empty vector + /// instead of processing the inputs. This is a common mutation that could pass tests that + /// only check for success without verifying the actual results. + #[test] + fn test_construct_proofs_returns_correct_count() { + use std::collections::BTreeMap; + + use crate::nuts::nut02::Id; + use crate::Amount; + + // Test that construct_proofs returns the correct number of proofs + let secret_key = SecretKey::generate(); + let mut keys_map = BTreeMap::new(); + keys_map.insert(Amount::from(1), secret_key.public_key()); + let keys = Keys::new(keys_map); + + let secret = Secret::from_str("test").unwrap(); + let (blinded_message, r) = blind_message(secret.as_bytes(), None).unwrap(); + let signature = sign_message(&secret_key, &blinded_message).unwrap(); + + let promise = BlindSignature { + amount: Amount::from(1), + c: signature, + keyset_id: Id::from_str("00deadbeef123456").unwrap(), + dleq: None, + }; + + let promises = vec![promise.clone(), promise.clone()]; + let rs = vec![r.clone(), r]; + let secrets = vec![secret.clone(), secret]; + + let proofs = construct_proofs(promises, rs, secrets, &keys).unwrap(); + + // Should return 2 proofs, not 0 (kills the Ok(Default::default()) mutant) + assert_eq!(proofs.len(), 2); + } + + /// Tests that hash_to_curve properly increments the counter and terminates. + /// + /// The hash_to_curve function uses a counter that increments in a loop at line 61. + /// If the counter increment is mutated (e.g., to `counter *= 1`), the loop would + /// never progress and would run until the timeout. + /// + /// This test uses a message that requires multiple iterations to find a valid point, + /// ensuring the counter increment logic is working correctly. + /// + /// Mutant testing: Kills mutations that replace `counter += 1` with `counter *= 1` + /// or other operations that don't advance the counter. + #[test] + fn test_hash_to_curve_counter_increments() { + // This specific message is documented in test_hash_to_curve as taking + // "a few iterations of the loop before finding a valid point" + let secret = "0000000000000000000000000000000000000000000000000000000000000002"; + let sec_hex = hex::decode(secret).unwrap(); + + let result = hash_to_curve(&sec_hex); + assert!(result.is_ok(), "hash_to_curve should find a valid point"); + + let y = result.unwrap(); + let expected_y = PublicKey::from_hex( + "026cdbe15362df59cd1dd3c9c11de8aedac2106eca69236ecd9fbe117af897be4f", + ) + .unwrap(); + assert_eq!(y, expected_y); + } } diff --git a/crates/cashu/src/lib.rs b/crates/cashu/src/lib.rs index fb722669b..07733e523 100644 --- a/crates/cashu/src/lib.rs +++ b/crates/cashu/src/lib.rs @@ -16,6 +16,8 @@ pub use self::mint_url::MintUrl; pub use self::nuts::*; pub use self::util::SECP256K1; +pub mod quote_id; + #[doc(hidden)] #[macro_export] macro_rules! ensure_cdk { diff --git a/crates/cashu/src/nuts/auth/nut21.rs b/crates/cashu/src/nuts/auth/nut21.rs index f99a02fcd..7ecc798b6 100644 --- a/crates/cashu/src/nuts/auth/nut21.rs +++ b/crates/cashu/src/nuts/auth/nut21.rs @@ -149,6 +149,22 @@ pub enum RoutePath { /// Mint Blind Auth #[serde(rename = "/v1/auth/blind/mint")] MintBlindAuth, + /// Bolt12 Mint Quote + #[serde(rename = "/v1/mint/quote/bolt12")] + MintQuoteBolt12, + /// Bolt12 Mint + #[serde(rename = "/v1/mint/bolt12")] + MintBolt12, + /// Bolt12 Melt Quote + #[serde(rename = "/v1/melt/quote/bolt12")] + MeltQuoteBolt12, + /// Bolt12 Quote + #[serde(rename = "/v1/melt/bolt12")] + MeltBolt12, + + /// WebSocket + #[serde(rename = "/v1/ws")] + Ws, } /// Returns [`RoutePath`]s that match regex @@ -195,6 +211,8 @@ mod tests { assert!(paths.contains(&RoutePath::Checkstate)); assert!(paths.contains(&RoutePath::Restore)); assert!(paths.contains(&RoutePath::MintBlindAuth)); + assert!(paths.contains(&RoutePath::MintQuoteBolt12)); + assert!(paths.contains(&RoutePath::MintBolt12)); } #[test] @@ -203,13 +221,17 @@ mod tests { let paths = matching_route_paths("^/v1/mint/.*").unwrap(); // Should match only mint paths - assert_eq!(paths.len(), 2); + assert_eq!(paths.len(), 4); assert!(paths.contains(&RoutePath::MintQuoteBolt11)); assert!(paths.contains(&RoutePath::MintBolt11)); + assert!(paths.contains(&RoutePath::MintQuoteBolt12)); + assert!(paths.contains(&RoutePath::MintBolt12)); // Should not match other paths assert!(!paths.contains(&RoutePath::MeltQuoteBolt11)); assert!(!paths.contains(&RoutePath::MeltBolt11)); + assert!(!paths.contains(&RoutePath::MeltQuoteBolt12)); + assert!(!paths.contains(&RoutePath::MeltBolt12)); assert!(!paths.contains(&RoutePath::Swap)); } @@ -219,9 +241,11 @@ mod tests { let paths = matching_route_paths(".*/quote/.*").unwrap(); // Should match only quote paths - assert_eq!(paths.len(), 2); + assert_eq!(paths.len(), 4); assert!(paths.contains(&RoutePath::MintQuoteBolt11)); assert!(paths.contains(&RoutePath::MeltQuoteBolt11)); + assert!(paths.contains(&RoutePath::MintQuoteBolt12)); + assert!(paths.contains(&RoutePath::MeltQuoteBolt12)); // Should not match non-quote paths assert!(!paths.contains(&RoutePath::MintBolt11)); @@ -336,12 +360,14 @@ mod tests { "https://example.com/.well-known/openid-configuration" ); assert_eq!(settings.client_id, "client123"); - assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path + assert_eq!(settings.protected_endpoints.len(), 5); // 3 mint paths + 1 swap path let expected_protected: HashSet = HashSet::from_iter(vec![ ProtectedEndpoint::new(Method::Post, RoutePath::Swap), ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11), ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11), + ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt12), + ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt12), ]); let deserlized_protected = settings.protected_endpoints.into_iter().collect(); diff --git a/crates/cashu/src/nuts/auth/nut22.rs b/crates/cashu/src/nuts/auth/nut22.rs index b92cb8737..81990ea31 100644 --- a/crates/cashu/src/nuts/auth/nut22.rs +++ b/crates/cashu/src/nuts/auth/nut22.rs @@ -330,12 +330,14 @@ mod tests { let settings: Settings = serde_json::from_str(json).unwrap(); assert_eq!(settings.bat_max_mint, 5); - assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path + assert_eq!(settings.protected_endpoints.len(), 5); // 4 mint paths + 1 swap path let expected_protected: HashSet = HashSet::from_iter(vec![ ProtectedEndpoint::new(Method::Post, RoutePath::Swap), ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11), ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11), + ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt12), + ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt12), ]); let deserialized_protected = settings.protected_endpoints.into_iter().collect(); diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index d9e31218a..92ad1588d 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -24,6 +24,7 @@ pub mod nut18; pub mod nut19; pub mod nut20; pub mod nut23; +pub mod nut25; #[cfg(feature = "auth")] mod auth; @@ -53,7 +54,7 @@ pub use nut05::{ pub use nut06::{ContactInfo, MintInfo, MintVersion, Nuts}; pub use nut07::{CheckStateRequest, CheckStateResponse, ProofState, State}; pub use nut09::{RestoreRequest, RestoreResponse}; -pub use nut10::{Kind, Secret as Nut10Secret, SecretData}; +pub use nut10::{Kind, Secret as Nut10Secret, SecretData, SpendingConditionVerification}; pub use nut11::{Conditions, P2PKWitness, SigFlag, SpendingConditions}; pub use nut12::{BlindSignatureDleq, ProofDleq}; pub use nut14::HTLCWitness; @@ -67,3 +68,4 @@ pub use nut23::{ MeltOptions, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request, MintQuoteBolt11Response, QuoteState as MintQuoteState, }; +pub use nut25::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; diff --git a/crates/cashu/src/nuts/nut00/mod.rs b/crates/cashu/src/nuts/nut00/mod.rs index 85f33a97b..c44a4bc91 100644 --- a/crates/cashu/src/nuts/nut00/mod.rs +++ b/crates/cashu/src/nuts/nut00/mod.rs @@ -18,6 +18,8 @@ use super::nut10; #[cfg(feature = "wallet")] use super::nut11::SpendingConditions; #[cfg(feature = "wallet")] +use crate::amount::FeeAndAmounts; +#[cfg(feature = "wallet")] use crate::amount::SplitTarget; #[cfg(feature = "wallet")] use crate::dhke::blind_message; @@ -279,12 +281,12 @@ impl PartialOrd for BlindSignature { #[serde(untagged)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum Witness { - /// P2PK Witness - #[serde(with = "serde_p2pk_witness")] - P2PKWitness(P2PKWitness), /// HTLC Witness #[serde(with = "serde_htlc_witness")] HTLCWitness(HTLCWitness), + /// P2PK Witness + #[serde(with = "serde_p2pk_witness")] + P2PKWitness(P2PKWitness), } impl From for Witness { @@ -304,13 +306,10 @@ impl Witness { pub fn add_signatures(&mut self, signatues: Vec) { match self { Self::P2PKWitness(p2pk_witness) => p2pk_witness.signatures.extend(signatues), - Self::HTLCWitness(htlc_witness) => { - htlc_witness.signatures = htlc_witness.signatures.clone().map(|sigs| { - let mut sigs = sigs; - sigs.extend(signatues); - sigs - }); - } + Self::HTLCWitness(htlc_witness) => match &mut htlc_witness.signatures { + Some(sigs) => sigs.extend(signatues), + None => htlc_witness.signatures = Some(signatues), + }, } } @@ -583,6 +582,14 @@ impl CurrencyUnit { Self::Usd => Some(2), Self::Eur => Some(3), Self::Auth => Some(4), + Self::Custom(v) => { + use std::hash::DefaultHasher; + + let mut hasher = DefaultHasher::new(); + v.hash(&mut hasher); + let h = hasher.finish(); + Some((h as u32) & 0x7FFFFFFF) + } _ => None, } } @@ -591,14 +598,14 @@ impl CurrencyUnit { impl FromStr for CurrencyUnit { type Err = Error; fn from_str(value: &str) -> Result { - let value = &value.to_uppercase(); - match value.as_str() { + let upper_value = value.to_uppercase(); + match upper_value.as_str() { "SAT" => Ok(Self::Sat), "MSAT" => Ok(Self::Msat), "USD" => Ok(Self::Usd), "EUR" => Ok(Self::Eur), "AUTH" => Ok(Self::Auth), - c => Ok(Self::Custom(c.to_string())), + _ => Ok(Self::Custom(value.to_string())), } } } @@ -641,13 +648,14 @@ impl<'de> Deserialize<'de> for CurrencyUnit { } /// Payment Method -#[non_exhaustive] #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum PaymentMethod { /// Bolt11 payment type #[default] Bolt11, + /// Bolt12 + Bolt12, /// Custom Custom(String), } @@ -657,6 +665,7 @@ impl FromStr for PaymentMethod { fn from_str(value: &str) -> Result { match value.to_lowercase().as_str() { "bolt11" => Ok(Self::Bolt11), + "bolt12" => Ok(Self::Bolt12), c => Ok(Self::Custom(c.to_string())), } } @@ -666,6 +675,7 @@ impl fmt::Display for PaymentMethod { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { PaymentMethod::Bolt11 => write!(f, "bolt11"), + PaymentMethod::Bolt12 => write!(f, "bolt12"), PaymentMethod::Custom(p) => write!(f, "{p}"), } } @@ -743,8 +753,9 @@ impl PreMintSecrets { keyset_id: Id, amount: Amount, amount_split_target: &SplitTarget, + fee_and_amounts: &FeeAndAmounts, ) -> Result { - let amount_split = amount.split_targeted(amount_split_target)?; + let amount_split = amount.split_targeted(amount_split_target, fee_and_amounts)?; let mut output = Vec::with_capacity(amount_split.len()); @@ -827,8 +838,9 @@ impl PreMintSecrets { amount: Amount, amount_split_target: &SplitTarget, conditions: &SpendingConditions, + fee_and_amounts: &FeeAndAmounts, ) -> Result { - let amount_split = amount.split_targeted(amount_split_target)?; + let amount_split = amount.split_targeted(amount_split_target, fee_and_amounts)?; let mut output = Vec::with_capacity(amount_split.len()); @@ -923,7 +935,10 @@ impl Iterator for PreMintSecrets { fn next(&mut self) -> Option { // Use the iterator of the vector - self.secrets.pop() + if self.secrets.is_empty() { + return None; + } + Some(self.secrets.remove(0)) } } @@ -974,4 +989,89 @@ mod tests { .unwrap(); assert_eq!(b.len(), 1); } + + #[test] + fn custom_unit_ser_der() { + let unit = CurrencyUnit::Custom(String::from("test")); + let serialized = serde_json::to_string(&unit).unwrap(); + let deserialized: CurrencyUnit = serde_json::from_str(&serialized).unwrap(); + assert_eq!(unit, deserialized) + } + + #[test] + fn test_payment_method_parsing() { + // Test standard variants + assert_eq!( + PaymentMethod::from_str("bolt11").unwrap(), + PaymentMethod::Bolt11 + ); + assert_eq!( + PaymentMethod::from_str("BOLT11").unwrap(), + PaymentMethod::Bolt11 + ); + assert_eq!( + PaymentMethod::from_str("Bolt11").unwrap(), + PaymentMethod::Bolt11 + ); + + assert_eq!( + PaymentMethod::from_str("bolt12").unwrap(), + PaymentMethod::Bolt12 + ); + assert_eq!( + PaymentMethod::from_str("BOLT12").unwrap(), + PaymentMethod::Bolt12 + ); + assert_eq!( + PaymentMethod::from_str("Bolt12").unwrap(), + PaymentMethod::Bolt12 + ); + + // Test custom variants + assert_eq!( + PaymentMethod::from_str("custom").unwrap(), + PaymentMethod::Custom("custom".to_string()) + ); + assert_eq!( + PaymentMethod::from_str("CUSTOM").unwrap(), + PaymentMethod::Custom("custom".to_string()) + ); + + // Test serialization/deserialization consistency + let methods = vec![ + PaymentMethod::Bolt11, + PaymentMethod::Bolt12, + PaymentMethod::Custom("test".to_string()), + ]; + + for method in methods { + let serialized = serde_json::to_string(&method).unwrap(); + let deserialized: PaymentMethod = serde_json::from_str(&serialized).unwrap(); + assert_eq!(method, deserialized); + } + } + + #[test] + fn test_witness_serialization() { + let htlc_witness = HTLCWitness { + preimage: "preimage".to_string(), + signatures: Some(vec!["sig1".to_string()]), + }; + let witness = Witness::HTLCWitness(htlc_witness); + + let serialized = serde_json::to_string(&witness).unwrap(); + let deserialized: Witness = serde_json::from_str(&serialized).unwrap(); + + assert!(matches!(deserialized, Witness::HTLCWitness(_))); + + let p2pk_witness = P2PKWitness { + signatures: vec!["sig1".to_string(), "sig2".to_string()], + }; + let witness = Witness::P2PKWitness(p2pk_witness); + + let serialized = serde_json::to_string(&witness).unwrap(); + let deserialized: Witness = serde_json::from_str(&serialized).unwrap(); + + assert!(matches!(deserialized, Witness::P2PKWitness(_))); + } } diff --git a/crates/cashu/src/nuts/nut00/token.rs b/crates/cashu/src/nuts/nut00/token.rs index f054875f7..a9d4bc40b 100644 --- a/crates/cashu/src/nuts/nut00/token.rs +++ b/crates/cashu/src/nuts/nut00/token.rs @@ -2,18 +2,20 @@ //! //! -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::fmt; use std::str::FromStr; use bitcoin::base64::engine::{general_purpose, GeneralPurpose}; use bitcoin::base64::{alphabet, Engine as _}; +use bitcoin::hashes::sha256; use serde::{Deserialize, Serialize}; use super::{Error, Proof, ProofV3, ProofV4, Proofs}; use crate::mint_url::MintUrl; use crate::nut02::ShortKeysetId; -use crate::nuts::{CurrencyUnit, Id}; +use crate::nuts::nut11::SpendingConditions; +use crate::nuts::{CurrencyUnit, Id, Kind, PublicKey}; use crate::{ensure_cdk, Amount, KeySetInfo}; /// Token Enum @@ -128,6 +130,90 @@ impl Token { Self::TokenV4(token) => token.to_raw_bytes(), } } + + /// Return all proof secrets in this token without keyset-id mapping, across V3/V4 + /// This is intended for spending-condition inspection where only the secret matters. + pub fn token_secrets(&self) -> Vec<&crate::secret::Secret> { + match self { + Token::TokenV3(t) => t + .token + .iter() + .flat_map(|kt| kt.proofs.iter().map(|p| &p.secret)) + .collect(), + Token::TokenV4(t) => t + .token + .iter() + .flat_map(|kt| kt.proofs.iter().map(|p| &p.secret)) + .collect(), + } + } + + /// Extract unique spending conditions across all proofs + pub fn spending_conditions(&self) -> Result, Error> { + let mut set = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + set.insert(cond); + } + } + Ok(set) + } + + /// Collect pubkeys for P2PK-locked ecash + pub fn p2pk_pubkeys(&self) -> Result, Error> { + let mut keys: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if cond.kind() == Kind::P2PK { + if let Some(ps) = cond.pubkeys() { + keys.extend(ps); + } + } + } + } + Ok(keys) + } + + /// Collect refund pubkeys from P2PK conditions + pub fn p2pk_refund_pubkeys(&self) -> Result, Error> { + let mut keys: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if cond.kind() == Kind::P2PK { + if let Some(ps) = cond.refund_keys() { + keys.extend(ps); + } + } + } + } + Ok(keys) + } + + /// Collect HTLC hashes + pub fn htlc_hashes(&self) -> Result, Error> { + let mut hashes: HashSet = HashSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(SpendingConditions::HTLCConditions { data, .. }) = + SpendingConditions::try_from(secret) + { + hashes.insert(data); + } + } + Ok(hashes) + } + + /// Collect unique locktimes from spending conditions + pub fn locktimes(&self) -> Result, Error> { + let mut set: BTreeSet = BTreeSet::new(); + for secret in self.token_secrets().into_iter() { + if let Ok(cond) = SpendingConditions::try_from(secret) { + if let Some(lt) = cond.locktime() { + set.insert(lt); + } + } + } + Ok(set) + } } impl FromStr for Token { @@ -535,10 +621,13 @@ mod tests { use std::str::FromStr; use bip39::rand::{self, RngCore}; + use bitcoin::hashes::sha256::Hash as Sha256Hash; + use bitcoin::hashes::Hash; use super::*; use crate::dhke::hash_to_curve; use crate::mint_url::MintUrl; + use crate::nuts::nut11::{Conditions, SigFlag, SpendingConditions}; use crate::secret::Secret; use crate::util::hex; @@ -826,4 +915,155 @@ mod tests { let proofs1 = token1.unwrap().proofs(&keysets_info); assert!(proofs1.is_err()); } + #[test] + fn test_token_spending_condition_helpers_p2pk_htlc_v4() { + let mint_url = MintUrl::from_str("https://example.com").unwrap(); + let keyset_id = Id::from_str("009a1f293253e41e").unwrap(); + + // P2PK: base pubkey plus an extra pubkey via tags, refund key, and locktime + let sk1 = crate::nuts::SecretKey::generate(); + let pk1 = sk1.public_key(); + let sk2 = crate::nuts::SecretKey::generate(); + let pk2 = sk2.public_key(); + let refund_sk = crate::nuts::SecretKey::generate(); + let refund_pk = refund_sk.public_key(); + + let cond_p2pk = Conditions { + locktime: Some(1_700_000_000), + pubkeys: Some(vec![pk2]), + refund_keys: Some(vec![refund_pk]), + num_sigs: Some(1), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }; + + let nut10_p2pk = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::P2PK, + pk1.to_string(), + Some(cond_p2pk.clone()), + ); + let secret_p2pk: Secret = nut10_p2pk.try_into().unwrap(); + + // HTLC: use a known preimage hash and its own locktime + let preimage = b"cdk-test-preimage"; + let htlc_hash = Sha256Hash::hash(preimage); + let cond_htlc = Conditions { + locktime: Some(1_800_000_000), + ..Default::default() + }; + let nut10_htlc = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::HTLC, + htlc_hash.to_string(), + Some(cond_htlc.clone()), + ); + let secret_htlc: Secret = nut10_htlc.try_into().unwrap(); + + // Build two proofs (one P2PK, one HTLC) + let proof_p2pk = Proof::new(Amount::from(1), keyset_id, secret_p2pk.clone(), pk1); + let proof_htlc = Proof::new(Amount::from(2), keyset_id, secret_htlc.clone(), pk2); + let token = Token::new( + mint_url, + vec![proof_p2pk, proof_htlc].into_iter().collect(), + None, + CurrencyUnit::Sat, + ); + + // token_secrets should see both + assert_eq!(token.token_secrets().len(), 2); + + // spending_conditions should contain both kinds with their conditions + let sc = token.spending_conditions().unwrap(); + assert!(sc.contains(&SpendingConditions::P2PKConditions { + data: pk1, + conditions: Some(cond_p2pk.clone()) + })); + assert!(sc.contains(&SpendingConditions::HTLCConditions { + data: htlc_hash, + conditions: Some(cond_htlc.clone()) + })); + + // p2pk_pubkeys should include base pk1 and extra pk2 from tags (deduped) + let pks = token.p2pk_pubkeys().unwrap(); + assert!(pks.contains(&pk1)); + assert!(pks.contains(&pk2)); + assert_eq!(pks.len(), 2); + + // p2pk_refund_pubkeys should include refund_pk only + let refund = token.p2pk_refund_pubkeys().unwrap(); + assert!(refund.contains(&refund_pk)); + assert_eq!(refund.len(), 1); + + // htlc_hashes should include exactly our hash + let hashes = token.htlc_hashes().unwrap(); + assert!(hashes.contains(&htlc_hash)); + assert_eq!(hashes.len(), 1); + + // locktimes should include both unique locktimes + let lts = token.locktimes().unwrap(); + assert!(lts.contains(&1_700_000_000)); + assert!(lts.contains(&1_800_000_000)); + assert_eq!(lts.len(), 2); + } + + #[test] + fn test_token_spending_condition_helpers_dedup_and_v3() { + let mint_url = MintUrl::from_str("https://example.org").unwrap(); + let id = Id::from_str("00ad268c4d1f5826").unwrap(); + + // Same P2PK conditions duplicated across two proofs + let sk = crate::nuts::SecretKey::generate(); + let pk = sk.public_key(); + + let cond = Conditions { + locktime: Some(1_650_000_000), + pubkeys: Some(vec![pk]), // include itself to test dedup inside pubkeys() + refund_keys: Some(vec![pk]), // deliberate duplicate + num_sigs: Some(1), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }; + + let nut10 = crate::nuts::Nut10Secret::new( + crate::nuts::Kind::P2PK, + pk.to_string(), + Some(cond.clone()), + ); + let secret: Secret = nut10.try_into().unwrap(); + + let p1 = Proof::new(Amount::from(1), id, secret.clone(), pk); + let p2 = Proof::new(Amount::from(2), id, secret.clone(), pk); + + // Build a V3 token explicitly and wrap into Token::TokenV3 + let token_v3 = TokenV3::new( + mint_url, + vec![p1, p2].into_iter().collect(), + None, + Some(CurrencyUnit::Sat), + ) + .unwrap(); + let token = Token::TokenV3(token_v3); + + // Helpers should dedup + let sc = token.spending_conditions().unwrap(); + assert_eq!(sc.len(), 1); // identical conditions across proofs + + let pks = token.p2pk_pubkeys().unwrap(); + assert!(pks.contains(&pk)); + assert_eq!(pks.len(), 1); // duplicates removed + + let refunds = token.p2pk_refund_pubkeys().unwrap(); + assert!(refunds.contains(&pk)); + assert_eq!(refunds.len(), 1); + + let lts = token.locktimes().unwrap(); + assert!(lts.contains(&1_650_000_000)); + assert_eq!(lts.len(), 1); + + // No HTLC here + let hashes = token.htlc_hashes().unwrap(); + assert!(hashes.is_empty()); + + // token_secrets length equals number of proofs even if conditions identical + assert_eq!(token.token_secrets().len(), 2); + } } diff --git a/crates/cashu/src/nuts/nut01/public_key.rs b/crates/cashu/src/nuts/nut01/public_key.rs index ab6b862e4..7e0023d85 100644 --- a/crates/cashu/src/nuts/nut01/public_key.rs +++ b/crates/cashu/src/nuts/nut01/public_key.rs @@ -142,19 +142,17 @@ mod tests { #[test] pub fn test_public_key_from_hex() { // Compressed - assert!( - (PublicKey::from_hex( - "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" - ) - .is_ok()) - ); + assert!(PublicKey::from_hex( + "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" + ) + .is_ok()); } #[test] pub fn test_invalid_public_key_from_hex() { // Uncompressed (is valid but is cashu must be compressed?) - assert!((PublicKey::from_hex("04fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de3625246cb2c27dac965cb7200a5986467eee92eb7d496bbf1453b074e223e481") - .is_err())) + assert!(PublicKey::from_hex("04fd4ce5a16b65576145949e6f99f445f8249fee17c606b688b504a849cdc452de3625246cb2c27dac965cb7200a5986467eee92eb7d496bbf1453b074e223e481") + .is_err()) } } diff --git a/crates/cashu/src/nuts/nut02.rs b/crates/cashu/src/nuts/nut02.rs index bfb3f07e2..7835de878 100644 --- a/crates/cashu/src/nuts/nut02.rs +++ b/crates/cashu/src/nuts/nut02.rs @@ -445,18 +445,17 @@ pub struct KeySet { impl KeySet { /// Verify the keyset id matches keys pub fn verify_id(&self) -> Result<(), Error> { - match self.id.version { - KeySetVersion::Version00 => { - let keys_id: Id = Id::v1_from_keys(&self.keys); + let keys_id = match self.id.version { + KeySetVersion::Version00 => Id::v1_from_keys(&self.keys), + KeySetVersion::Version01 => Id::v2_from_data(&self.keys, &self.unit, self.final_expiry), + }; - ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId); - } - KeySetVersion::Version01 => { - let keys_id: Id = Id::v2_from_data(&self.keys, &self.unit, self.final_expiry); + ensure_cdk!( + u32::from(keys_id) == u32::from(self.id), + Error::IncorrectKeysetId + ); - ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId); - } - } + ensure_cdk!(keys_id == self.id, Error::IncorrectKeysetId); Ok(()) } @@ -497,6 +496,28 @@ pub struct KeySetInfo { pub final_expiry: Option, } +/// List of [KeySetInfo] +pub type KeySetInfos = Vec; + +/// Utility methods for [KeySetInfos] +pub trait KeySetInfosMethods { + /// Filter for active keysets + fn active(&self) -> impl Iterator + '_; + + /// Filter keysets for specific unit + fn unit(&self, unit: CurrencyUnit) -> impl Iterator + '_; +} + +impl KeySetInfosMethods for KeySetInfos { + fn active(&self) -> impl Iterator + '_ { + self.iter().filter(|k| k.active) + } + + fn unit(&self, unit: CurrencyUnit) -> impl Iterator + '_ { + self.iter().filter(move |k| k.unit == unit) + } +} + fn deserialize_input_fee_ppk<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, @@ -532,13 +553,12 @@ impl MintKeySet { secp: &Secp256k1, xpriv: Xpriv, unit: CurrencyUnit, - max_order: u8, + amounts: &[u64], final_expiry: Option, version: KeySetVersion, ) -> Self { let mut map = BTreeMap::new(); - for i in 0..max_order { - let amount = Amount::from(2_u64.pow(i as u32)); + for (i, amount) in amounts.iter().enumerate() { let secret_key = xpriv .derive_priv( secp, @@ -548,7 +568,7 @@ impl MintKeySet { .private_key; let public_key = secret_key.public_key(secp); map.insert( - amount, + amount.into(), MintKeyPair { secret_key: secret_key.into(), public_key: public_key.into(), @@ -573,7 +593,7 @@ impl MintKeySet { pub fn generate_from_seed( secp: &Secp256k1, seed: &[u8], - max_order: u8, + amounts: &[u64], currency_unit: CurrencyUnit, derivation_path: DerivationPath, final_expiry: Option, @@ -586,7 +606,7 @@ impl MintKeySet { .derive_priv(secp, &derivation_path) .expect("RNG busted"), currency_unit, - max_order, + amounts, final_expiry, version, ) @@ -596,7 +616,7 @@ impl MintKeySet { pub fn generate_from_xpriv( secp: &Secp256k1, xpriv: Xpriv, - max_order: u8, + amounts: &[u64], currency_unit: CurrencyUnit, derivation_path: DerivationPath, final_expiry: Option, @@ -608,7 +628,7 @@ impl MintKeySet { .derive_priv(secp, &derivation_path) .expect("RNG busted"), currency_unit, - max_order, + amounts, final_expiry, version, ) diff --git a/crates/cashu/src/nuts/nut03.rs b/crates/cashu/src/nuts/nut03.rs index 50b18e983..7c6fe44ea 100644 --- a/crates/cashu/src/nuts/nut03.rs +++ b/crates/cashu/src/nuts/nut03.rs @@ -61,6 +61,11 @@ impl SwapRequest { &self.inputs } + /// Get mutable inputs (proofs) + pub fn inputs_mut(&mut self) -> &mut Proofs { + &mut self.inputs + } + /// Get outputs (blinded messages) pub fn outputs(&self) -> &Vec { &self.outputs @@ -86,6 +91,32 @@ impl SwapRequest { } } +impl super::nut10::SpendingConditionVerification for SwapRequest { + fn inputs(&self) -> &Proofs { + &self.inputs + } + + fn sig_all_msg_to_sign(&self) -> String { + let mut msg = String::new(); + + // Add all input secrets and C values in order + // msg = secret_0 || C_0 || ... || secret_n || C_n + for proof in &self.inputs { + msg.push_str(&proof.secret.to_string()); + msg.push_str(&proof.c.to_hex()); + } + + // Add all output amounts and B_ values in order + // msg = ... || amount_0 || B_0 || ... || amount_m || B_m + for output in &self.outputs { + msg.push_str(&output.amount.to_string()); + msg.push_str(&output.blinded_secret.to_hex()); + } + + msg + } +} + /// Split Response [NUT-06] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] diff --git a/crates/cashu/src/nuts/nut04.rs b/crates/cashu/src/nuts/nut04.rs index 68fbcc979..78ed50224 100644 --- a/crates/cashu/src/nuts/nut04.rs +++ b/crates/cashu/src/nuts/nut04.rs @@ -10,10 +10,12 @@ use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, Visitor}; use serde::ser::{SerializeStruct, Serializer}; use serde::{Deserialize, Serialize}; use thiserror::Error; -#[cfg(feature = "mint")] -use uuid::Uuid; use super::nut00::{BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod}; +#[cfg(feature = "mint")] +use crate::quote_id::QuoteId; +#[cfg(feature = "mint")] +use crate::quote_id::QuoteIdError; use crate::Amount; /// NUT04 Error @@ -44,12 +46,12 @@ pub struct MintRequest { } #[cfg(feature = "mint")] -impl TryFrom> for MintRequest { - type Error = uuid::Error; +impl TryFrom> for MintRequest { + type Error = QuoteIdError; fn try_from(value: MintRequest) -> Result { Ok(Self { - quote: Uuid::from_str(&value.quote)?, + quote: QuoteId::from_str(&value.quote)?, outputs: value.outputs, signature: value.signature, }) @@ -291,6 +293,16 @@ impl Settings { .position(|settings| &settings.method == method && &settings.unit == unit) .map(|index| self.methods.remove(index)) } + + /// Supported nut04 methods + pub fn supported_methods(&self) -> Vec<&PaymentMethod> { + self.methods.iter().map(|a| &a.method).collect() + } + + /// Supported nut04 units + pub fn supported_units(&self) -> Vec<&CurrencyUnit> { + self.methods.iter().map(|s| &s.unit).collect() + } } #[cfg(test)] @@ -321,7 +333,7 @@ mod tests { match settings.options { Some(MintMethodOptions::Bolt11 { description }) => { - assert_eq!(description, true); + assert!(description); } _ => panic!("Expected Bolt11 options with description = true"), } @@ -353,10 +365,7 @@ mod tests { match settings.options { Some(MintMethodOptions::Bolt11 { description }) => { - assert_eq!( - description, true, - "Top-level description should take precedence" - ); + assert!(description, "Top-level description should take precedence"); } _ => panic!("Expected Bolt11 options with description = true"), } diff --git a/crates/cashu/src/nuts/nut05.rs b/crates/cashu/src/nuts/nut05.rs index cd9652749..e2b5b8d27 100644 --- a/crates/cashu/src/nuts/nut05.rs +++ b/crates/cashu/src/nuts/nut05.rs @@ -9,11 +9,11 @@ use serde::de::{self, DeserializeOwned, Deserializer, MapAccess, Visitor}; use serde::ser::{SerializeStruct, Serializer}; use serde::{Deserialize, Serialize}; use thiserror::Error; -#[cfg(feature = "mint")] -use uuid::Uuid; use super::nut00::{BlindedMessage, CurrencyUnit, PaymentMethod, Proofs}; use super::ProofsMethods; +#[cfg(feature = "mint")] +use crate::quote_id::QuoteId; use crate::Amount; /// NUT05 Error @@ -28,6 +28,9 @@ pub enum Error { /// Unsupported unit #[error("Unsupported unit")] UnsupportedUnit, + /// Invalid quote id + #[error("Invalid quote id")] + InvalidQuote, } /// Possible states of a quote @@ -91,12 +94,12 @@ pub struct MeltRequest { } #[cfg(feature = "mint")] -impl TryFrom> for MeltRequest { - type Error = uuid::Error; +impl TryFrom> for MeltRequest { + type Error = Error; fn try_from(value: MeltRequest) -> Result { Ok(Self { - quote: Uuid::from_str(&value.quote)?, + quote: QuoteId::from_str(&value.quote).map_err(|_e| Error::InvalidQuote)?, inputs: value.inputs, outputs: value.outputs, }) @@ -105,18 +108,31 @@ impl TryFrom> for MeltRequest { // Basic implementation without trait bounds impl MeltRequest { + /// Quote Id + pub fn quote_id(&self) -> &Q { + &self.quote + } + /// Get inputs (proofs) pub fn inputs(&self) -> &Proofs { &self.inputs } + /// Get mutable inputs (proofs) + pub fn inputs_mut(&mut self) -> &mut Proofs { + &mut self.inputs + } + /// Get outputs (blinded messages for change) pub fn outputs(&self) -> &Option> { &self.outputs } } -impl MeltRequest { +impl MeltRequest +where + Q: Serialize + DeserializeOwned, +{ /// Create new [`MeltRequest`] pub fn new(quote: Q, inputs: Proofs, outputs: Option>) -> Self { Self { @@ -132,12 +148,47 @@ impl MeltRequest { } /// Total [`Amount`] of [`Proofs`] - pub fn proofs_amount(&self) -> Result { + pub fn inputs_amount(&self) -> Result { Amount::try_sum(self.inputs.iter().map(|proof| proof.amount)) .map_err(|_| Error::AmountOverflow) } } +impl super::nut10::SpendingConditionVerification for MeltRequest +where + Q: std::fmt::Display, +{ + fn inputs(&self) -> &Proofs { + &self.inputs + } + + fn sig_all_msg_to_sign(&self) -> String { + let mut msg = String::new(); + + // Add all input secrets and C values in order + // msg = secret_0 || C_0 || ... || secret_n || C_n + for proof in &self.inputs { + msg.push_str(&proof.secret.to_string()); + msg.push_str(&proof.c.to_hex()); + } + + // Add all output amounts and B_ values in order (if any) + // msg = ... || amount_0 || B_0 || ... || amount_m || B_m + if let Some(outputs) = &self.outputs { + for output in outputs { + msg.push_str(&output.amount.to_string()); + msg.push_str(&output.blinded_secret.to_hex()); + } + } + + // Add quote ID + // msg = ... || quote_id + msg.push_str(&self.quote.to_string()); + + msg + } +} + /// Melt Method Settings #[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] @@ -355,6 +406,18 @@ pub struct Settings { pub disabled: bool, } +impl Settings { + /// Supported nut05 methods + pub fn supported_methods(&self) -> Vec<&PaymentMethod> { + self.methods.iter().map(|a| &a.method).collect() + } + + /// Supported nut05 units + pub fn supported_units(&self) -> Vec<&CurrencyUnit> { + self.methods.iter().map(|s| &s.unit).collect() + } +} + #[cfg(test)] mod tests { use serde_json::{from_str, json, to_string}; @@ -383,7 +446,7 @@ mod tests { match settings.options { Some(MeltMethodOptions::Bolt11 { amountless }) => { - assert_eq!(amountless, true); + assert!(amountless); } _ => panic!("Expected Bolt11 options with amountless = true"), } @@ -415,10 +478,7 @@ mod tests { match settings.options { Some(MeltMethodOptions::Bolt11 { amountless }) => { - assert_eq!( - amountless, true, - "Top-level amountless should take precedence" - ); + assert!(amountless, "Top-level amountless should take precedence"); } _ => panic!("Expected Bolt11 options with amountless = true"), } diff --git a/crates/cashu/src/nuts/nut06.rs b/crates/cashu/src/nuts/nut06.rs index 2ec993aa8..51e04d77f 100644 --- a/crates/cashu/src/nuts/nut06.rs +++ b/crates/cashu/src/nuts/nut06.rs @@ -313,6 +313,7 @@ pub struct Nuts { /// NUT15 Settings #[serde(default)] #[serde(rename = "15")] + #[serde(skip_serializing_if = "nut15::Settings::is_empty")] pub nut15: nut15::Settings, /// NUT17 Settings #[serde(default)] @@ -676,4 +677,38 @@ mod tests { assert_eq!(info, mint_info); } + + #[test] + fn test_nut15_not_serialized_when_empty() { + // Test with default (empty) NUT15 + let mint_info = MintInfo { + name: Some("Test Mint".to_string()), + nuts: Nuts::default(), + ..Default::default() + }; + + let json = serde_json::to_string(&mint_info).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + // NUT15 should not be present in the nuts object when methods is empty + assert!(parsed["nuts"]["15"].is_null()); + + // Test with non-empty NUT15 + let mint_info_with_nut15 = MintInfo { + name: Some("Test Mint".to_string()), + nuts: Nuts::default().nut15(vec![MppMethodSettings { + method: crate::PaymentMethod::Bolt11, + unit: crate::CurrencyUnit::Sat, + }]), + ..Default::default() + }; + + let json = serde_json::to_string(&mint_info_with_nut15).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + + // NUT15 should be present when methods is not empty + assert!(!parsed["nuts"]["15"].is_null()); + assert!(parsed["nuts"]["15"]["methods"].is_array()); + assert_eq!(parsed["nuts"]["15"]["methods"].as_array().unwrap().len(), 1); + } } diff --git a/crates/cashu/src/nuts/nut10.rs b/crates/cashu/src/nuts/nut10.rs index e6de748e3..df3739620 100644 --- a/crates/cashu/src/nuts/nut10.rs +++ b/crates/cashu/src/nuts/nut10.rs @@ -10,6 +10,23 @@ use serde::ser::SerializeTuple; use serde::{Deserialize, Serialize, Serializer}; use thiserror::Error; +use super::nut01::PublicKey; +use super::Conditions; + +/// Spending requirements for P2PK or HTLC verification +/// +/// Returned by `get_pubkeys_and_required_sigs` to indicate what conditions +/// must be met to spend a proof. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SpendingRequirements { + /// Whether a preimage is required (HTLC only, before locktime) + pub preimage_needed: bool, + /// Public keys that can provide valid signatures + pub pubkeys: Vec, + /// Minimum number of signatures required from the pubkeys + pub required_sigs: u64, +} + /// NUT13 Error #[derive(Debug, Error)] pub enum Error { @@ -30,7 +47,7 @@ pub enum Kind { HTLC, } -/// Secert Date +/// Secret Date #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct SecretData { /// Unique random string @@ -105,6 +122,462 @@ impl Secret { } } +/// Get the relevant public keys and required signature count for P2PK or HTLC verification +/// This is for NUT-11(P2PK) and NUT-14(HTLC) +/// +/// Takes into account locktime - if locktime has passed, returns refund keys, +/// otherwise returns primary pubkeys/hash path. +/// From NUT-11: "If the tag locktime is the unix time and the mint's local clock is greater than +/// locktime, the Proof becomes spendable by anyone, except [... if refund keys are specified]" +/// +/// Returns `SpendingRequirements` containing: +/// - `preimage_needed`: For P2PK, always false. For HTLC, true before locktime. +/// - `pubkeys`: The public keys that can provide valid signatures +/// - `required_sigs`: The minimum number of signatures required +/// +/// From NUT-14: "if the current system time is later than Secret.tag.locktime, the Proof can +/// be spent if Proof.witness includes a signature from the key in Secret.tags.refund." +pub(crate) fn get_pubkeys_and_required_sigs( + secret: &Secret, + current_time: u64, +) -> Result { + debug_assert!( + secret.kind() == Kind::P2PK || secret.kind() == Kind::HTLC, + "get_pubkeys_and_required_sigs called with invalid kind - this is a bug" + ); + + let conditions: Conditions = secret + .secret_data() + .tags() + .cloned() + .unwrap_or_default() + .try_into()?; + + // Check if locktime has passed + let locktime_passed = conditions + .locktime + .map(|locktime| locktime < current_time) + .unwrap_or(false); + + // Determine which keys and signature count to use + if locktime_passed { + // After locktime: use refund path (no preimage needed) + if let Some(refund_keys) = &conditions.refund_keys { + // Locktime has passed and refund keys exist - use refund keys + let refund_sigs = conditions.num_sigs_refund.unwrap_or(1); + Ok(SpendingRequirements { + preimage_needed: false, + pubkeys: refund_keys.clone(), + required_sigs: refund_sigs, + }) + } else { + // Locktime has passed with no refund keys - anyone can spend + Ok(SpendingRequirements { + preimage_needed: false, + pubkeys: vec![], + required_sigs: 0, + }) + } + } else { + // Before locktime: logic differs between P2PK and HTLC + match secret.kind() { + Kind::P2PK => { + // P2PK: never needs preimage, use primary pubkeys + let mut primary_keys = vec![]; + + // Add the pubkey from secret.data + let data_pubkey = PublicKey::from_str(secret.secret_data().data())?; + primary_keys.push(data_pubkey); + + // Add any additional pubkeys from conditions + if let Some(additional_keys) = &conditions.pubkeys { + primary_keys.extend(additional_keys.clone()); + } + + let primary_num_sigs_required = conditions.num_sigs.unwrap_or(1); + Ok(SpendingRequirements { + preimage_needed: false, + pubkeys: primary_keys, + required_sigs: primary_num_sigs_required, + }) + } + Kind::HTLC => { + // HTLC: needs preimage before locktime, pubkeys from conditions + // (data contains hash, not pubkey) + let pubkeys = conditions.pubkeys.clone().unwrap_or_default(); + // If no pubkeys are specified, require 0 signatures (only preimage needed) + // Otherwise, default to requiring 1 signature + let required_sigs = if pubkeys.is_empty() { + 0 + } else { + conditions.num_sigs.unwrap_or(1) + }; + Ok(SpendingRequirements { + preimage_needed: true, + pubkeys, + required_sigs, + }) + } + } + } +} + +use super::Proofs; + +/// Verify that a preimage matches the hash in the secret data +/// +/// The preimage should be a 64-character hex string representing 32 bytes. +/// We decode it from hex, hash it with SHA256, and compare to the hash in secret.data +pub fn verify_htlc_preimage( + witness: &super::nut14::HTLCWitness, + secret: &Secret, +) -> Result<(), super::nut14::Error> { + use bitcoin::hashes::sha256::Hash as Sha256Hash; + use bitcoin::hashes::Hash; + + // Get the hash lock from the secret data + let hash_lock = Sha256Hash::from_str(secret.secret_data().data()) + .map_err(|_| super::nut14::Error::InvalidHash)?; + + // Decode and validate the preimage (returns [u8; 32]) + let preimage_bytes = witness.preimage_data()?; + + // Hash the 32-byte preimage + let preimage_hash = Sha256Hash::hash(&preimage_bytes); + + // Compare with the hash lock + if hash_lock.ne(&preimage_hash) { + return Err(super::nut14::Error::Preimage); + } + + Ok(()) +} + +/// Trait for requests that spend proofs (SwapRequest, MeltRequest) +pub trait SpendingConditionVerification { + /// Get the input proofs + fn inputs(&self) -> &Proofs; + + /// Construct the message to sign for SIG_ALL verification + /// + /// This concatenates all relevant transaction data that must be signed. + /// For swap: input secrets + output blinded messages + /// For melt: input secrets + quote/payment request + fn sig_all_msg_to_sign(&self) -> String; + + /// Check if at least one proof in the set has SIG_ALL flag set + /// + /// SIG_ALL requires all proofs in the transaction to be signed. + /// If any proof has this flag, we need to verify signatures on all proofs. + fn has_at_least_one_sig_all(&self) -> Result { + for proof in self.inputs() { + // Try to extract spending conditions from the proof's secret + if let Ok(spending_conditions) = super::SpendingConditions::try_from(&proof.secret) { + // Check for SIG_ALL flag in either P2PK or HTLC conditions + let has_sig_all = match spending_conditions { + super::SpendingConditions::P2PKConditions { conditions, .. } => conditions + .map(|c| c.sig_flag == super::SigFlag::SigAll) + .unwrap_or(false), + super::SpendingConditions::HTLCConditions { conditions, .. } => conditions + .map(|c| c.sig_flag == super::SigFlag::SigAll) + .unwrap_or(false), + }; + + if has_sig_all { + return Ok(true); + } + } + } + + Ok(false) + } + + /// Verify all inputs meet SIG_ALL requirements per NUT-11 + /// + /// When any input has SIG_ALL, all inputs must have: + /// 1. Same kind (P2PK or HTLC) + /// 2. SIG_ALL flag set + /// 3. Same Secret.data + /// 4. Same Secret.tags + fn verify_all_inputs_match_for_sig_all(&self) -> Result<(), super::nut11::Error> { + let inputs = self.inputs(); + + if inputs.is_empty() { + return Err(super::nut11::Error::SpendConditionsNotMet); + } + + // Get first input's properties + let first_input = inputs.first().unwrap(); + let first_secret = Secret::try_from(&first_input.secret) + .map_err(|_| super::nut11::Error::IncorrectSecretKind)?; + let first_kind = first_secret.kind(); + let first_data = first_secret.secret_data().data(); + let first_tags = first_secret.secret_data().tags(); + + // Get first input's conditions to check SIG_ALL flag + let first_conditions = + super::Conditions::try_from(first_tags.cloned().unwrap_or_default())?; + + // Verify first input has SIG_ALL (it should, since we only call this function when SIG_ALL is detected) + if first_conditions.sig_flag != super::SigFlag::SigAll { + return Err(super::nut11::Error::SpendConditionsNotMet); + } + + // Verify all remaining inputs match + for proof in inputs.iter().skip(1) { + let secret = Secret::try_from(&proof.secret) + .map_err(|_| super::nut11::Error::IncorrectSecretKind)?; + + // Check kind matches + if secret.kind() != first_kind { + return Err(super::nut11::Error::SpendConditionsNotMet); + } + + // Check data matches + if secret.secret_data().data() != first_data { + return Err(super::nut11::Error::SpendConditionsNotMet); + } + + // Check tags match (this also ensures SIG_ALL flag matches, since sig_flag is part of tags) + if secret.secret_data().tags() != first_tags { + return Err(super::nut11::Error::SpendConditionsNotMet); + } + } + + Ok(()) + } + + /// Verify spending conditions for this transaction + /// + /// This is the main entry point for spending condition verification. + /// It checks if any input has SIG_ALL and dispatches to the appropriate verification path. + fn verify_spending_conditions(&self) -> Result<(), super::nut11::Error> { + // Check if any input has SIG_ALL flag + if self.has_at_least_one_sig_all()? { + // at least one input has SIG_ALL + self.verify_full_sig_all_check() + } else { + // none of the inputs are SIG_ALL, so we can simply check + // each independently and verify any spending conditions + // that may - or may not - be there. + self.verify_inputs_individually().map_err(|e| match e { + super::nut14::Error::NUT11(nut11_err) => nut11_err, + _ => super::nut11::Error::SpendConditionsNotMet, + }) + } + } + + /// Verify spending conditions when SIG_ALL is present + /// + /// When SIG_ALL is set, all proofs in the transaction must be signed together. + fn verify_full_sig_all_check(&self) -> Result<(), super::nut11::Error> { + debug_assert!( + self.has_at_least_one_sig_all()?, + "verify_full_sig_all_check() called on proofs without SIG_ALL. This shouldn't happen" + ); + // Verify all inputs meet SIG_ALL requirements per NUT-11: + // All inputs must have: (1) same kind, (2) SIG_ALL flag, (3) same data, (4) same tags + self.verify_all_inputs_match_for_sig_all()?; + + // Get the first input to determine the kind + let first_input = self + .inputs() + .first() + .ok_or(super::nut11::Error::SpendConditionsNotMet)?; + let first_secret = Secret::try_from(&first_input.secret) + .map_err(|_| super::nut11::Error::IncorrectSecretKind)?; + + // Dispatch based on secret kind + match first_secret.kind() { + Kind::P2PK => { + self.verify_sig_all_p2pk()?; + } + Kind::HTLC => { + self.verify_sig_all_htlc()?; + } + } + + Ok(()) + } + + /// Verify spending conditions for each input individually + /// + /// Handles SIG_INPUTS mode, non-NUT-10 secrets, and any other case where inputs + /// are verified independently rather than as a group. + /// This function will NOT be called if any input has SIG_ALL. + fn verify_inputs_individually(&self) -> Result<(), super::nut14::Error> { + debug_assert!( + !(self.has_at_least_one_sig_all()?), + "verify_inputs_individually() called on SIG_ALL. This shouldn't happen" + ); + for proof in self.inputs() { + // Check if secret is a nut10 secret with conditions + if let Ok(secret) = Secret::try_from(&proof.secret) { + // Verify this function isn't being called with SIG_ALL proofs (development check) + if let Ok(conditions) = super::Conditions::try_from( + secret.secret_data().tags().cloned().unwrap_or_default(), + ) { + debug_assert!( + conditions.sig_flag != super::SigFlag::SigAll, + "verify_inputs_individually called with SIG_ALL proof - this is a bug" + ); + } + + match secret.kind() { + Kind::P2PK => { + proof.verify_p2pk()?; + } + Kind::HTLC => { + proof.verify_htlc()?; + } + } + } + // If not a nut10 secret, skip verification (plain secret) + } + Ok(()) + } + + /// Verify P2PK SIG_ALL signatures + /// + /// Do NOT call this directly. This is called only from 'verify_full_sig_all_check', + /// which has already done many important SIG_ALL checks. This performs the final + /// signature verification for SIG_ALL+P2PK transactions. + fn verify_sig_all_p2pk(&self) -> Result<(), super::nut11::Error> { + // Get the first input, as it's the one with the signatures + let first_input = self + .inputs() + .first() + .ok_or(super::nut11::Error::SpendConditionsNotMet)?; + let first_secret = Secret::try_from(&first_input.secret) + .map_err(|_| super::nut11::Error::IncorrectSecretKind)?; + + // Record current time for locktime evaluation + let current_time = crate::util::unix_time(); + + // Get the relevant public keys and required signature count based on locktime + let requirements = get_pubkeys_and_required_sigs(&first_secret, current_time)?; + + debug_assert!( + !requirements.preimage_needed, + "P2PK should never require preimage" + ); + + // Handle "anyone can spend" case (locktime passed with no refund keys) + if requirements.required_sigs == 0 { + return Ok(()); + } + + // Construct the message that should be signed + let msg_to_sign = self.sig_all_msg_to_sign(); + + // Extract signatures from the first input's witness + let first_witness = first_input + .witness + .as_ref() + .ok_or(super::nut11::Error::SignaturesNotProvided)?; + + let witness_sigs = first_witness + .signatures() + .ok_or(super::nut11::Error::SignaturesNotProvided)?; + + // Convert witness strings to Signature objects + use std::str::FromStr; + let signatures: Vec = witness_sigs + .iter() + .map(|s| bitcoin::secp256k1::schnorr::Signature::from_str(s)) + .collect::, _>>() + .map_err(|_| super::nut11::Error::InvalidSignature)?; + + // Verify signatures using the existing valid_signatures function + let valid_sig_count = super::nut11::valid_signatures( + msg_to_sign.as_bytes(), + &requirements.pubkeys, + &signatures, + )?; + + // Check if we have enough valid signatures + if valid_sig_count < requirements.required_sigs { + return Err(super::nut11::Error::SpendConditionsNotMet); + } + + Ok(()) + } + + /// Verify HTLC SIG_ALL signatures + /// + /// Do NOT call this directly. This is called only from 'verify_full_sig_all_check', + /// which has already done many important SIG_ALL checks. This performs the final + /// signature verification for SIG_ALL+HTLC transactions. + fn verify_sig_all_htlc(&self) -> Result<(), super::nut11::Error> { + // Get the first input, as it's the one with the signatures + let first_input = self + .inputs() + .first() + .ok_or(super::nut11::Error::SpendConditionsNotMet)?; + let first_secret = Secret::try_from(&first_input.secret) + .map_err(|_| super::nut11::Error::IncorrectSecretKind)?; + + // Record current time for locktime evaluation + let current_time = crate::util::unix_time(); + + // Get the relevant public keys, required signature count, and whether preimage is needed + let requirements = get_pubkeys_and_required_sigs(&first_secret, current_time)?; + + // If preimage is needed (before locktime), verify it + if requirements.preimage_needed { + // Extract HTLC witness + let htlc_witness = match first_input.witness.as_ref() { + Some(super::Witness::HTLCWitness(witness)) => witness, + _ => return Err(super::nut11::Error::SignaturesNotProvided), + }; + + // Verify the preimage matches the hash in the secret + verify_htlc_preimage(htlc_witness, &first_secret) + .map_err(|_| super::nut11::Error::SpendConditionsNotMet)?; + } + + // Handle "anyone can spend" case (locktime passed with no refund keys) + if requirements.required_sigs == 0 { + return Ok(()); + } + + // Construct the message that should be signed + let msg_to_sign = self.sig_all_msg_to_sign(); + + // Extract signatures from the first input's witness + let first_witness = first_input + .witness + .as_ref() + .ok_or(super::nut11::Error::SignaturesNotProvided)?; + + let witness_sigs = first_witness + .signatures() + .ok_or(super::nut11::Error::SignaturesNotProvided)?; + + // Convert witness strings to Signature objects + use std::str::FromStr; + let signatures: Vec = witness_sigs + .iter() + .map(|s| bitcoin::secp256k1::schnorr::Signature::from_str(s)) + .collect::, _>>() + .map_err(|_| super::nut11::Error::InvalidSignature)?; + + // Verify signatures using the existing valid_signatures function + let valid_sig_count = super::nut11::valid_signatures( + msg_to_sign.as_bytes(), + &requirements.pubkeys, + &signatures, + )?; + + // Check if we have enough valid signatures + if valid_sig_count < requirements.required_sigs { + return Err(super::nut11::Error::SpendConditionsNotMet); + } + + Ok(()) + } +} + impl Serialize for Secret { fn serialize(&self, serializer: S) -> Result where diff --git a/crates/cashu/src/nuts/nut11/mod.rs b/crates/cashu/src/nuts/nut11/mod.rs index 076aac030..4a1d3c963 100644 --- a/crates/cashu/src/nuts/nut11/mod.rs +++ b/crates/cashu/src/nuts/nut11/mod.rs @@ -9,18 +9,20 @@ use std::{fmt, vec}; use bitcoin::hashes::sha256::Hash as Sha256Hash; use bitcoin::hashes::Hash; use bitcoin::secp256k1::schnorr::Signature; -use serde::de::Error as DeserializerError; +use serde::de::{DeserializeOwned, Error as DeserializerError}; use serde::ser::SerializeSeq; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; use super::nut00::Witness; use super::nut01::PublicKey; +use super::nut05::MeltRequest; +use super::nut10::SpendingConditionVerification; use super::{Kind, Nut10Secret, Proof, Proofs, SecretKey}; -use crate::ensure_cdk; use crate::nuts::nut00::BlindedMessage; use crate::secret::Secret; use crate::util::{hex, unix_time}; +use crate::{ensure_cdk, SwapRequest}; pub mod serde_p2pk_witness; @@ -57,12 +59,21 @@ 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, /// Duplicate signature from same pubkey #[error("Duplicate signature from the same pubkey detected")] DuplicateSignature, + /// Preimage not supported in P2PK + #[error("P2PK does not support preimage requirements")] + PreimageNotSupportedInP2PK, + /// SIG_ALL not supported in this context + #[error("SIG_ALL proofs must be verified using a different method")] + SigAllNotSupportedHere, /// Parse Url Error #[error(transparent)] UrlParseError(#[from] url::ParseError), @@ -133,76 +144,52 @@ impl Proof { .cloned() .unwrap_or_default() .try_into()?; - let msg: &[u8] = self.secret.as_bytes(); - - let mut verified_pubkeys = HashSet::new(); - - let witness_signatures = match &self.witness { - Some(witness) => witness.signatures(), - None => None, - }; - - let witness_signatures = witness_signatures.ok_or(Error::SignaturesNotProvided)?; - let mut pubkeys = spending_conditions.pubkeys.clone().unwrap_or_default(); + if spending_conditions.sig_flag == SigFlag::SigAll { + return Err(Error::SigAllNotSupportedHere); + } - if secret.kind().eq(&Kind::P2PK) { - pubkeys.push(PublicKey::from_str(secret.secret_data().data())?); + if secret.kind() != Kind::P2PK { + return Err(Error::IncorrectSecretKind); } - for signature in witness_signatures.iter() { - for v in &pubkeys { - let sig = Signature::from_str(signature)?; + // Based on the current time, we must identify the relevant keys + let now = unix_time(); + let requirements = super::nut10::get_pubkeys_and_required_sigs(&secret, now)?; - if v.verify(msg, &sig).is_ok() { - // If the pubkey is already verified, return a duplicate signature error - if !verified_pubkeys.insert(*v) { - return Err(Error::DuplicateSignature); - } - } else { - tracing::debug!( - "Could not verify signature: {sig} on message: {}", - self.secret.to_string() - ) - } - } + if requirements.preimage_needed { + return Err(Error::PreimageNotSupportedInP2PK); } - let valid_sigs = verified_pubkeys.len() as u64; - - if valid_sigs >= spending_conditions.num_sigs.unwrap_or(1) { + // Handle "anyone can spend" case (locktime passed with no refund keys) + if requirements.required_sigs == 0 { return Ok(()); } - if let (Some(locktime), Some(refund_keys)) = ( - spending_conditions.locktime, - spending_conditions.refund_keys, - ) { - let needed_refund_sigs = spending_conditions.num_sigs_refund.unwrap_or(1) as usize; - - let mut valid_pubkeys = HashSet::new(); - - // If lock time has passed check if refund witness signature is valid - if locktime.lt(&unix_time()) { - for s in witness_signatures.iter() { - for v in &refund_keys { - let sig = Signature::from_str(s).map_err(|_| Error::InvalidSignature)?; + // Extract witness signatures + let witness_signatures = match &self.witness { + Some(witness) => witness.signatures(), + None => None, + }; + let witness_signatures = witness_signatures.ok_or(Error::SignaturesNotProvided)?; - if v.verify(msg, &sig).is_ok() { - if !valid_pubkeys.insert(v) { - return Err(Error::DuplicateSignature); - } + // Count valid signatures using relevant_pubkeys + let msg: &[u8] = self.secret.as_bytes(); + let valid_sig_count = valid_signatures( + msg, + &requirements.pubkeys, + &witness_signatures + .iter() + .map(|s| Signature::from_str(s)) + .collect::, _>>()?, + )?; - if valid_pubkeys.len() >= needed_refund_sigs { - return Ok(()); - } - } - } - } - } + // Check if we have enough valid signatures + if valid_sig_count >= requirements.required_sigs { + Ok(()) + } else { + Err(Error::SpendConditionsNotMet) } - - Err(Error::SpendConditionsNotMet) } } @@ -317,7 +304,15 @@ pub enum SpendingConditions { impl SpendingConditions { /// New HTLC [SpendingConditions] pub fn new_htlc(preimage: String, conditions: Option) -> Result { - 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, @@ -367,8 +362,9 @@ impl SpendingConditions { if let Some(conditions) = conditions { pubkeys.extend(conditions.pubkeys.clone().unwrap_or_default()); } - - Some(pubkeys) + // Remove duplicates + let unique_pubkeys: HashSet<_> = pubkeys.into_iter().collect(); + Some(unique_pubkeys.into_iter().collect()) } Self::HTLCConditions { conditions, .. } => conditions.clone().and_then(|c| c.pubkeys), } @@ -500,7 +496,7 @@ impl From for Vec> { refund_keys, num_sigs, sig_flag, - num_sigs_refund: _, + num_sigs_refund, } = conditions; let mut tags = Vec::new(); @@ -520,6 +516,11 @@ impl From for Vec> { if let Some(refund_keys) = refund_keys { tags.push(Tag::Refund(refund_keys).as_vec()) } + + if let Some(num_sigs_refund) = num_sigs_refund { + tags.push(Tag::NSigsRefund(num_sigs_refund).as_vec()) + } + tags.push(Tag::SigFlag(sig_flag).as_vec()); tags } @@ -575,13 +576,22 @@ impl TryFrom>> for Conditions { None }; + let num_sigs_refund = if let Some(tag) = tags.get(&TagKind::NSigsRefund) { + match tag { + Tag::NSigsRefund(num_sigs) => Some(*num_sigs), + _ => None, + } + } else { + None + }; + Ok(Conditions { locktime, pubkeys, refund_keys, num_sigs, sig_flag, - num_sigs_refund: None, + num_sigs_refund, }) } } @@ -617,7 +627,7 @@ impl fmt::Display for TagKind { Self::Refund => write!(f, "refund"), Self::Pubkeys => write!(f, "pubkeys"), Self::NSigsRefund => write!(f, "n_sigs_refund"), - Self::Custom(kind) => write!(f, "{kind}"), + Self::Custom(c) => write!(f, "{c}"), } } } @@ -633,6 +643,7 @@ where "locktime" => Self::Locktime, "refund" => Self::Refund, "pubkeys" => Self::Pubkeys, + "n_sigs_refund" => Self::NSigsRefund, t => Self::Custom(t.to_owned()), } } @@ -739,6 +750,10 @@ pub enum Tag { Refund(Vec), /// Pubkeys [`Tag`] PubKeys(Vec), + /// Number of Sigs refund [`Tag`] + NSigsRefund(u64), + /// Custom tag + Custom(String, Vec), } impl Tag { @@ -750,6 +765,8 @@ impl Tag { Self::LockTime(_) => TagKind::Locktime, Self::Refund(_) => TagKind::Refund, Self::PubKeys(_) => TagKind::Pubkeys, + Self::NSigsRefund(_) => TagKind::NSigsRefund, + Self::Custom(tag, _) => TagKind::Custom(tag.to_string()), } } @@ -790,7 +807,16 @@ where Ok(Self::PubKeys(pubkeys)) } - _ => Err(Error::UnknownTag), + TagKind::NSigsRefund => Ok(Tag::NSigsRefund(tag[1].as_ref().parse()?)), + TagKind::Custom(name) => { + let tags = tag + .iter() + .skip(1) + .map(|p| p.as_ref().to_string()) + .collect::>(); + + Ok(Self::Custom(name, tags)) + } } } } @@ -816,10 +842,81 @@ impl From for Vec { } tag } + Tag::NSigsRefund(num_sigs) => { + vec![TagKind::NSigsRefund.to_string(), num_sigs.to_string()] + } + Tag::Custom(name, c) => { + let mut tag = vec![name]; + + for t in c { + tag.push(t); + } + + tag + } } } } +impl SwapRequest { + /// Sign swap request with SIG_ALL + pub fn sign_sig_all(&mut self, secret_key: SecretKey) -> Result<(), Error> { + // Get message to sign + let msg = self.sig_all_msg_to_sign(); + let signature = secret_key.sign(msg.as_bytes())?; + + // Add signature to first input witness + let first_input = self + .inputs_mut() + .first_mut() + .ok_or(Error::IncorrectSecretKind)?; + + match first_input.witness.as_mut() { + Some(witness) => { + witness.add_signatures(vec![signature.to_string()]); + } + None => { + let mut p2pk_witness = Witness::P2PKWitness(P2PKWitness::default()); + p2pk_witness.add_signatures(vec![signature.to_string()]); + first_input.witness = Some(p2pk_witness); + } + }; + + Ok(()) + } +} + +impl MeltRequest +where + Q: std::fmt::Display + Serialize + DeserializeOwned, +{ + /// Sign melt request with SIG_ALL + pub fn sign_sig_all(&mut self, secret_key: SecretKey) -> Result<(), Error> { + // Get message to sign + let msg = self.sig_all_msg_to_sign(); + let signature = secret_key.sign(msg.as_bytes())?; + + // Add signature to first input witness + let first_input = self + .inputs_mut() + .first_mut() + .ok_or(Error::SpendConditionsNotMet)?; + + match first_input.witness.as_mut() { + Some(witness) => { + witness.add_signatures(vec![signature.to_string()]); + } + None => { + let mut p2pk_witness = Witness::P2PKWitness(P2PKWitness::default()); + p2pk_witness.add_signatures(vec![signature.to_string()]); + first_input.witness = Some(p2pk_witness); + } + }; + + Ok(()) + } +} + impl Serialize for Tag { fn serialize(&self, serializer: S) -> Result where @@ -845,14 +942,18 @@ impl<'de> Deserialize<'de> for Tag { } } +#[cfg(feature = "mint")] #[cfg(test)] mod tests { use std::str::FromStr; + use uuid::Uuid; + use super::*; use crate::nuts::Id; + use crate::quote_id::QuoteId; use crate::secret::Secret; - use crate::Amount; + use crate::{Amount, BlindedMessage}; #[test] fn test_secret_ser() { @@ -995,11 +1096,1171 @@ mod tests { } #[test] - fn test_duplicate_signatures_counting() { - let proof: Proof = serde_json::from_str( - r#"{"amount":1,"id":"009a1f293253e41e","secret":"[\"P2PK\",{\"nonce\":\"e434a9efbc5f65d144a620e368c9a6dc12c719d0ebc57e0c74f7341864dc449a\",\"data\":\"02a60c27104cf6023581e790970fc33994a320abe36e7ceed16771b0f8d76f0666\",\"tags\":[[\"pubkeys\",\"039c6a20a6ba354b7bb92eb9750716c1098063006362a1fa2afca7421f262d45c5\",\"0203eb2f7cd72a4f725d3327216365d2df18bb4bbc810522fd973c9af987e9b05b\"],[\"locktime\",\"1744876528\"],[\"n_sigs\",\"2\"],[\"sigflag\",\"SIG_INPUTS\"]]}]","C":"02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904","witness":"{\"signatures\":[\"3e9ff9e55c9eccb9e5aa0b6c62d54500b40d0eebadb06efcc8e76f3ce38e0923f956ec1bccb9080db96a17c1e98a1b857abfd1a56bb25670037cea3db1f73d81\",\"c5e29c38e60c4db720cf3f78e590358cf1291a06b9eadf77c1108ae84d533520c2707ffda224eb6a63fddaee9abd5ecf8f2cd263d2556950550e3061a5511f65\"]}"}"#, - ).unwrap(); + fn sig_with_non_refund_keys_after_locktime() { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + + let signing_key_two = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + + let signing_key_three = + SecretKey::from_str("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f") + .unwrap(); + let v_key: PublicKey = secret_key.public_key(); + let v_key_two: PublicKey = signing_key_two.public_key(); + let v_key_three: PublicKey = signing_key_three.public_key(); + + let conditions = Conditions { + locktime: Some(21), + pubkeys: Some(vec![v_key_three]), + refund_keys: Some(vec![v_key, v_key_two]), + num_sigs: None, + sig_flag: SigFlag::SigInputs, + num_sigs_refund: Some(2), + }; + + let secret: Secret = Nut10Secret::new(Kind::P2PK, v_key.to_string(), Some(conditions)) + .try_into() + .unwrap(); + + let mut proof = Proof { + keyset_id: Id::from_str("009a1f293253e41e").unwrap(), + amount: Amount::ZERO, + secret, + c: PublicKey::from_str( + "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + ) + .unwrap(), + witness: Some(Witness::P2PKWitness(P2PKWitness { signatures: vec![] })), + dleq: None, + }; + + proof.sign_p2pk(signing_key_three.clone()).unwrap(); assert!(proof.verify_p2pk().is_err()); + + proof.witness = None; + + proof.sign_p2pk(secret_key).unwrap(); + assert!(proof.verify_p2pk().is_err()); + proof.sign_p2pk(signing_key_two).unwrap(); + + assert!(proof.verify_p2pk().is_ok()); + } + + // Helper functions for melt request tests + fn create_test_proof(secret: Secret, pubkey: PublicKey, id: &str) -> Proof { + Proof { + keyset_id: Id::from_str(id).unwrap(), + amount: Amount::ZERO, + secret, + c: pubkey, + witness: None, + dleq: None, + } + } + + fn create_test_secret(pubkey: PublicKey, conditions: Conditions) -> Secret { + Nut10Secret::new(Kind::P2PK, pubkey.to_string(), Some(conditions)) + .try_into() + .unwrap() + } + + fn create_test_blinded_msg(pubkey: PublicKey) -> BlindedMessage { + BlindedMessage { + amount: Amount::ZERO, + blinded_secret: pubkey, + keyset_id: Id::from_str("009a1f293253e41e").unwrap(), + witness: None, + } + } + + #[test] + fn test_melt_sig_all_basic_signing() { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + let pubkey = secret_key.public_key(); + + // Create conditions with SIG_ALL + let conditions = Conditions { + sig_flag: SigFlag::SigAll, + ..Default::default() + }; + + let secret = create_test_secret(pubkey, conditions); + let proof = create_test_proof(secret, pubkey, "009a1f293253e41e"); + let blinded_msg = create_test_blinded_msg(pubkey); + + // Create melt request + let mut melt = MeltRequest::new( + QuoteId::UUID(Uuid::new_v4()), + vec![proof], + Some(vec![blinded_msg]), + ); + + // Before signing, should fail verification + assert!( + melt.verify_spending_conditions().is_err(), + "Unsigned melt request should fail verification" + ); + + // Sign the request + assert!( + melt.sign_sig_all(secret_key).is_ok(), + "Signing should succeed" + ); + + // After signing, should pass verification + assert!( + melt.verify_spending_conditions().is_ok(), + "Signed melt request should pass verification" + ); + } + + #[test] + fn test_melt_sig_all_unauthorized_key() { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + let pubkey = secret_key.public_key(); + + // Create conditions with explicit authorized pubkey + let conditions = Conditions { + sig_flag: SigFlag::SigAll, + pubkeys: Some(vec![pubkey]), + ..Default::default() + }; + + let secret = create_test_secret(pubkey, conditions); + let proof = create_test_proof(secret, pubkey, "009a1f293253e41e"); + + let mut melt = MeltRequest::new(Uuid::new_v4(), vec![proof], None); + + // Sign with unauthorized key + let unauthorized_key = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + melt.sign_sig_all(unauthorized_key).unwrap(); + + // Verification should fail (unauthorized signature) + assert!( + melt.verify_spending_conditions().is_err(), + "Verification should fail with unauthorized key signature" + ); + } + + #[test] + fn test_melt_sig_all_wrong_flag() { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + let pubkey = secret_key.public_key(); + + // Create conditions with SIG_INPUTS instead of SIG_ALL + let conditions = Conditions { + sig_flag: SigFlag::SigInputs, + pubkeys: Some(vec![pubkey]), + ..Default::default() + }; + + let secret = create_test_secret(pubkey, conditions); + let proof = create_test_proof(secret, pubkey, "009a1f293253e41e"); + + let mut melt = MeltRequest::new(Uuid::new_v4(), vec![proof], None); + + // Signing + melt.sign_sig_all(secret_key).unwrap(); + + // Verification should fail (wrong flag - expected SIG_ALL) + assert!( + melt.verify_spending_conditions().is_err(), + "Verification should fail with SIG_INPUTS flag when expecting SIG_ALL" + ); + } + + #[test] + fn test_melt_sig_all_multiple_inputs() { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + let pubkey = secret_key.public_key(); + + // Create conditions + let conditions = Conditions { + sig_flag: SigFlag::SigAll, + ..Default::default() + }; + + let secret = create_test_secret(pubkey, conditions); + + // Create two proofs with same secret + let proof1 = create_test_proof(secret.clone(), pubkey, "009a1f293253e41e"); + let proof2 = create_test_proof(secret, pubkey, "009a1f293253e41f"); + + let mut melt = MeltRequest::new(Uuid::new_v4(), vec![proof1, proof2], None); + + // Signing should work with multiple matching inputs + assert!( + melt.sign_sig_all(secret_key).is_ok(), + "Signing with multiple matching inputs should succeed" + ); + assert!( + melt.verify_spending_conditions().is_ok(), + "Verification should succeed with multiple matching inputs" + ); + } + + #[test] + fn test_melt_sig_all_mismatched_inputs() { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + let pubkey = secret_key.public_key(); + + // Create first secret and proof + let conditions1 = Conditions { + sig_flag: SigFlag::SigAll, + ..Default::default() + }; + let secret1 = create_test_secret(pubkey, conditions1.clone()); + let proof1 = create_test_proof(secret1, pubkey, "009a1f293253e41e"); + + // Create second secret with different data + let conditions2 = conditions1.clone(); + let secret2 = Nut10Secret::new( + Kind::P2PK, + "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + Some(conditions2), + ) + .try_into() + .unwrap(); + let proof2 = create_test_proof(secret2, pubkey, "009a1f293253e41f"); + + let mut melt = MeltRequest::new(Uuid::new_v4(), vec![proof1, proof2], None); + + // Signing should succeed (no validation during signing) + melt.sign_sig_all(secret_key).unwrap(); + + // Verification should fail (catches mismatched inputs) + assert!( + melt.verify_spending_conditions().is_err(), + "Verification should fail with mismatched input secrets" + ); + } + + #[test] + fn test_melt_sig_all_multiple_signatures() { + let secret_key1 = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + let pubkey1 = secret_key1.public_key(); + + let secret_key2 = + SecretKey::from_str("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f") + .unwrap(); + let pubkey2 = secret_key2.public_key(); + + // Create conditions requiring 2 signatures + let conditions = Conditions { + sig_flag: SigFlag::SigAll, + num_sigs: Some(2), + pubkeys: Some(vec![pubkey2]), + ..Default::default() + }; + + let secret = create_test_secret(pubkey1, conditions); + let proof = create_test_proof(secret, pubkey1, "009a1f293253e41e"); + + let mut melt = MeltRequest::new( + Uuid::new_v4(), + vec![proof], + Some(vec![create_test_blinded_msg( + SecretKey::generate().public_key(), + )]), + ); + + // First signature + assert!( + melt.sign_sig_all(secret_key1).is_ok(), + "First signature should succeed" + ); + assert!( + melt.verify_spending_conditions().is_err(), + "Single signature should not verify when two required" + ); + + // Second signature + assert!( + melt.sign_sig_all(secret_key2).is_ok(), + "Second signature should succeed" + ); + + assert!( + melt.verify_spending_conditions().is_ok(), + "Both signatures should verify successfully" + ); + } + + #[test] + fn test_melt_sig_all_message_components() { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + let pubkey = secret_key.public_key(); + + let conditions = Conditions { + sig_flag: SigFlag::SigAll, + pubkeys: Some(vec![pubkey]), + ..Default::default() + }; + + let secret = create_test_secret(pubkey, conditions); + let proof = create_test_proof(secret.clone(), pubkey, "009a1f293253e41e"); + let blinded_msg = create_test_blinded_msg(pubkey); + let quote_id = Uuid::new_v4(); + + let melt = MeltRequest::new(quote_id, vec![proof], Some(vec![blinded_msg.clone()])); + + // Get message to sign + let msg = melt.sig_all_msg_to_sign(); + + // Verify all components are present in the message + assert!( + msg.contains(&secret.to_string()), + "Message should contain secret" + ); + assert!( + msg.contains(&blinded_msg.blinded_secret.to_hex()), + "Message should contain blinded message in hex format" + ); + assert!( + msg.contains("e_id.to_string()), + "Message should contain quote ID" + ); + } + + // "SIG_ALL Test Vectors", starting with swaps : https://github.com/cashubtc/nuts/blob/5b050c7960607cca0481c28517cab5cc091b5d2e/tests/11-test.md#sig_all-test-vectors + #[test] + fn test_sig_all_swap_single_sig() { + // Valid SwapRequest with SIG_ALL signature + let valid_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"c7f280eb55c1e8564e03db06973e94bc9b666d9e1ca42ad278408fe625950303\",\"data\":\"030d8acedfe072c9fa449a1efe0817157403fbec460d8e79f957966056e5dd76c1\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02c97ee3d1db41cf0a3ddb601724be8711a032950811bf326f8219c50c4808d3cd", + "witness": "{\"signatures\":[\"ce017ca25b1b97df2f72e4b49f69ac26a240ce14b3690a8fe619d41ccc42d3c1282e073f85acd36dc50011638906f35b56615f24e4d03e8effe8257f6a808538\"]}" + } + ], + "outputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_swap: SwapRequest = serde_json::from_str(valid_swap).unwrap(); + + // Verify the message format + let msg_to_sign = valid_swap.sig_all_msg_to_sign(); + assert_eq!( + msg_to_sign, + "[\"P2PK\",{\"nonce\":\"c7f280eb55c1e8564e03db06973e94bc9b666d9e1ca42ad278408fe625950303\",\"data\":\"030d8acedfe072c9fa449a1efe0817157403fbec460d8e79f957966056e5dd76c1\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]02c97ee3d1db41cf0a3ddb601724be8711a032950811bf326f8219c50c4808d3cd2038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + ); + + // Verify the SHA256 hash of the message + use bitcoin::hashes::{sha256, Hash}; + let msg_hash = sha256::Hash::hash(msg_to_sign.as_bytes()); + assert_eq!( + msg_hash.to_string(), + "de7f9e3ca0fcc5ed3258fcf83dbf1be7fa78a5ed6da7bf2aa60d61e9dc6eb09a" + ); + + assert!( + valid_swap.verify_spending_conditions().is_ok(), + "Valid SIG_ALL swap request should verify" + ); + } + + #[test] + fn test_sig_all_swap_single_sig_2() { + // The following is a SwapRequest with a valid sig_all signature. + let valid_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"c7f280eb55c1e8564e03db06973e94bc9b666d9e1ca42ad278408fe625950303\",\"data\":\"030d8acedfe072c9fa449a1efe0817157403fbec460d8e79f957966056e5dd76c1\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02c97ee3d1db41cf0a3ddb601724be8711a032950811bf326f8219c50c4808d3cd", + "witness": "{\"signatures\":[\"ce017ca25b1b97df2f72e4b49f69ac26a240ce14b3690a8fe619d41ccc42d3c1282e073f85acd36dc50011638906f35b56615f24e4d03e8effe8257f6a808538\"]}" + } + ], + "outputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_swap: SwapRequest = serde_json::from_str(valid_swap).unwrap(); + + assert!( + valid_swap.verify_spending_conditions().is_ok(), + "Valid SIG_ALL swap request should verify" + ); + } + + #[test] + fn test_sig_all_multiple_secrets() { + // The following is a SwapRequest that is invalid as there are multiple secrets. + let invalid_swap = r#"{ + "inputs": [ + { + "amount": 1, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"fa6dd3fac9086c153878dec90b9e37163d38ff2ecf8b37db6470e9d185abbbae\",\"data\":\"033b42b04e659fed13b669f8b16cdaffc3ee5738608810cf97a7631d09bd01399d\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "024d232312bab25af2e73f41d56864d378edca9109ae8f76e1030e02e585847786", + "witness": "{\"signatures\":[\"27b4d260a1186e3b62a26c0d14ffeab3b9f7c3889e78707b8fd3836b473a00601afbd53a2288ad20a624a8bbe3344453215ea075fc0ce479dd8666fd3d9162cc\"]}" + }, + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"4007b21fc5f5b1d4920bc0a08b158d98fd0fb2b0b0262b57ff53c6c5d6c2ae8c\",\"data\":\"033b42b04e659fed13b669f8b16cdaffc3ee5738608810cf97a7631d09bd01399d\",\"tags\":[[\"locktime\",\"122222222222222\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02417400f2af09772219c831501afcbab4efb3b2e75175635d5474069608deb641" + } + ], + "outputs": [ + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + }, + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "03afe7c87e32d436f0957f1d70a2bca025822a84a8623e3a33aed0a167016e0ca5" + }, + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "02c0d4fce02a7a0f09e3f1bca952db910b17e81a7ebcbce62cd8dcfb127d21e37b" + } + ] +}"#; + + let invalid_swap: SwapRequest = serde_json::from_str(invalid_swap).unwrap(); + + assert!( + invalid_swap.verify_spending_conditions().is_err(), + "Invalid swap with multiple secrets shouldn't be accepted" + ); + } + + #[test] + fn test_sig_all_multiple_signatures_provided() { + // The following is a SwapRequest multiple valid signatures are provided and required. + let valid_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"04bfd885fc982d553711092d037fdceb7320fd8f96b0d4fd6d31a65b83b94272\",\"data\":\"0275e78025b558dbe6cb8fdd032a2e7613ca14fda5c1f4c4e3427f5077a7bd90e4\",\"tags\":[[\"pubkeys\",\"035163650bbd5ed4be7693f40f340346ba548b941074e9138b67ef6c42755f3449\",\"02817d22a8edc44c4141e192995a7976647c335092199f9e076a170c7336e2f5cc\"],[\"n_sigs\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "03866a09946562482c576ca989d06371e412b221890804c7da8887d321380755be", + "witness": "{\"signatures\":[\"be1d72c5ca16a93c5a34f25ec63ce632ddc3176787dac363321af3fd0f55d1927e07451bc451ffe5c682d76688ea9925d7977dffbb15bd79763b527f474734b0\",\"669d6d10d7ed35395009f222f6c7bdc28a378a1ebb72ee43117be5754648501da3bedf2fd6ff0c7849ac92683538c60af0af504102e40f2d8daca8e08b1ca16b\"]}" + } + ], + "outputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_swap: SwapRequest = serde_json::from_str(valid_swap).unwrap(); + + assert!( + valid_swap.verify_spending_conditions().is_ok(), + "Valid swap with multiple signatures should be accepted" + ); + } + + #[test] + fn test_sig_all_mixed_pubkeys_and_refund() { + // The following is an invalid SwapRequest with pubkeys and refund mixed. + let invalid_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"3e9253419a11f0a541dd6baeddecf8356fc864b5d061f12f05632bc3aee6b5c4\",\"data\":\"0343cca0e48ce9e3fdcddba4637ff8cdbf6f5ed9cfdf1873e63827e760f0ed4db5\",\"tags\":[[\"pubkeys\",\"0235e0a719f8b046cee90f55a59b1cdd6ca75ce23e49cbcd82c9e5b7310e21ebcd\",\"020443f98b356e021bae82bdfc05ff433cab21e27fca9ab7b0995aedb2e7aabc43\"],[\"locktime\",\"100\"],[\"n_sigs\",\"2\"],[\"refund\",\"026b432e62b041bf9cdae534203739c73fa506c9a2d6aa58a52bc601a1dec421e1\",\"02e3494a2e07e7f6e7d4567e0da7a563592bff1e121df2383667f15b83e9168a9e\"],[\"n_sigs_refund\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "026c12ee3bffa5c617debcf823bf1af6a9b47145b699f2737bba3394f0893eb869", + "witness": "{\"signatures\":[\"bfe884145ce6512331324321c3946dfd812428a53656b108b59d26559a186ba2ab45e5be9ce94e2dff0d09078e25ccb82d06a8b3a63cd3dc67065b8f77292776\",\"236e5cc9c30f85a893a29a4302e41e6f2015caef4229f28fa65e2f5c9d55515cc9a1852093a81a5095055d85fd55bf4da124e55354b56e0a39e83b58b0afc197\"]}" + } + ], + "outputs": [ + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + }, + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "03afe7c87e32d436f0957f1d70a2bca025822a84a8623e3a33aed0a167016e0ca5" + } + ] +}"#; + + let invalid_swap: SwapRequest = serde_json::from_str(invalid_swap).unwrap(); + + assert!( + invalid_swap.verify_spending_conditions().is_err(), + "Invalid swap with mixed refunds and pubkeys shouldn't be accepted" + ); + } + + #[test] + fn test_sig_all_locktime_passed_with_valid_refund_key_sigs() { + // The following is a SwapRequest with locktime passed and refund keys signatures are valid + let valid_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"9ea35553beb18d553d0a53120d0175a0991ca6109370338406eed007b26eacd1\",\"data\":\"02af21e09300af92e7b48c48afdb12e22933738cfb9bba67b27c00c679aae3ec25\",\"tags\":[[\"locktime\",\"1\"],[\"refund\",\"02637c19143c58b2c58bd378400a7b82bdc91d6dedaeb803b28640ef7d28a887ac\",\"0345c7fdf7ec7c8e746cca264bf27509eb4edb9ac421f8fbfab1dec64945a4d797\"],[\"n_sigs_refund\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "03dd83536fbbcbb74ccb3c87147df26753fd499cc2c095f74367fff0fb459c312e", + "witness": "{\"signatures\":[\"23b58ef28cd22f3dff421121240ddd621deee83a3bc229fd67019c2e338d91e2c61577e081e1375dbab369307bba265e887857110ca3b4bd949211a0a298805f\",\"7e75948ef1513564fdcecfcbd389deac67c730f7004f8631ba90c0844d3e8c0cf470b656306877df5141f65fd3b7e85445a8452c3323ab273e6d0d44843817ed\"]}" + } + ], + "outputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_swap: SwapRequest = serde_json::from_str(valid_swap).unwrap(); + + assert!( + valid_swap.verify_spending_conditions().is_ok(), + "Valid post-locktime swap with refund keys should be accepted" + ); + } + + #[test] + fn test_sig_all_htlc_and_pubkey() { + // The following is a valid `SwapRequest` with an HTLC also locked to a public key + let valid_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"HTLC\",{\"nonce\":\"d730dd70cd7ec6e687829857de8e70aab2b970712f4dbe288343eca20e63c28c\",\"data\":\"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5\",\"tags\":[[\"pubkeys\",\"0350cda8a1d5257dbd6ba8401a9a27384b9ab699e636e986101172167799469b14\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "03ff6567e2e6c31db5cb7189dab2b5121930086791c93899e4eff3dda61cb57273", + "witness": "{\"preimage\":\"0000000000000000000000000000000000000000000000000000000000000001\",\"signatures\":[\"a4c00a9ad07f9936e404494fda99a9b935c82d7c053173b304b8663124c81d4b00f64a225f5acf41043ca52b06382722bd04ded0fbeb0fcc404eed3b24778b88\"]}" + } + ], + "outputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_swap: SwapRequest = serde_json::from_str(valid_swap).unwrap(); + + assert!( + valid_swap.verify_spending_conditions().is_ok(), + "Valid swap with htlc and pubkey should be accepted" + ); + } + + #[test] + fn test_sig_all_enforce_locktime_with_only_refund_signed() { + // The following is an invalid SwapRequest with an HTLC also locked to a public key, locktime and refund key. locktime is not expired but proof is signed with refund key. + let invalid_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"HTLC\",{\"nonce\":\"512c4045f12fdfd6f55059669c189e040c37c1ce2f8be104ed6aec296acce4e9\",\"data\":\"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5\",\"tags\":[[\"pubkeys\",\"03ba83defd31c63f8841d188f0d41b5bb3af1bb3c08d0ba46f8f1d26a4d45e8cad\"],[\"locktime\",\"4854185133\"],[\"refund\",\"032f1008a79c722e93a1b4b853f85f38283f9ef74ee4c5c91293eb1cc3c5e46e34\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02207abeff828146f1fc3909c74613d5605bd057f16791994b3c91f045b39a6939", + "witness": "{\"preimage\":\"0000000000000000000000000000000000000000000000000000000000000001\",\"signatures\":[\"7816d57871bde5be2e4281065dbe5b15f641d8f1ed9437a3ae556464d6f9b8a0a2e6660337a915f2c26dce1453a416daf682b8fb593b67a0750fce071e0759b9\"]}" + } + ], + "outputs": [ + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + }, + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "03afe7c87e32d436f0957f1d70a2bca025822a84a8623e3a33aed0a167016e0ca5" + } + ] +}"#; + + let invalid_swap: SwapRequest = serde_json::from_str(invalid_swap).unwrap(); + + assert!( + invalid_swap.verify_spending_conditions().is_err(), + "Invalid swap with pre-locktime conditions not met shouldn't be accepted" + ); + } + + #[test] + fn test_sig_all_htlc_post_locktime() { + // The following is a valid SwapRequest with a multisig HTLC also locked to locktime and refund keys. + let valid_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"HTLC\",{\"nonce\":\"c9b0fabb8007c0db4bef64d5d128cdcf3c79e8bb780c3294adf4c88e96c32647\",\"data\":\"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5\",\"tags\":[[\"pubkeys\",\"039e6ec7e922abb4162235b3a42965eb11510b07b7461f6b1a17478b1c9c64d100\"],[\"locktime\",\"1\"],[\"refund\",\"02ce1bbd2c9a4be8029c9a6435ad601c45677f5cde81f8a7f0ed535e0039d0eb6c\",\"03c43c00ff57f63cfa9e732f0520c342123e21331d0121139f1b636921eeec095f\"],[\"n_sigs_refund\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "0344b6f1471cf18a8cbae0e624018c816be5e3a9b04dcb7689f64173c1ae90a3a5", + "witness": "{\"preimage\":\"0000000000000000000000000000000000000000000000000000000000000001\",\"signatures\":[\"98e21672d409cc782c720f203d8284f0af0c8713f18167499f9f101b7050c3e657fb0e57478ebd8bd561c31aa6c30f4cd20ec38c73f5755b7b4ddee693bca5a5\",\"693f40129dbf905ed9c8008081c694f72a36de354f9f4fa7a61b389cf781f62a0ae0586612fb2eb504faaf897fefb6742309186117f4743bcebcb8e350e975e2\"]}" + } + ], + "outputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_swap: SwapRequest = serde_json::from_str(valid_swap).unwrap(); + + assert!( + valid_swap.verify_spending_conditions().is_ok(), + "Valid post-locktime swap with htlc should be accepted" + ); + } + + #[test] + fn test_sig_all_swap_mismatched_inputs() { + // Invalid SwapRequest - mismatched inputs with SIG_ALL + let invalid_swap = r#"{ + "inputs": [ + { + "amount": 1, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"e2a221fe361f19d95c5c3312ccff3ffa075b4fe37beec99de85a6ee70568385b\",\"data\":\"03dad7f9c588f4cbb55c2e1b7b802fa2bbc63a614d9e9ecdf56a8e7ee8ca65be86\",\"tags\":[[\"pubkeys\",\"025f2af63fd65ca97c3bde4070549683e72769d28def2f1cd3d63576cd9c2ffa6c\"],[\"n_sigs\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02a79c09b0605f4e7a21976b511cc7be01cdaeac54b29645258c84f2e74bff13f6", + "witness": "{\"signatures\":[\"b42c7af7e98ca4e3bba8b73702120970286196340b340c21299676dbc7b10cafaa7baeb243affc01afce3218616cf8b3f6b4baaf4414fedb31b0c6653912f769\",\"17781910e2d806cae464f8a692929ee31124c0cd7eaf1e0d94292c6cbc122da09076b649080b8de9201f87d83b99fe04e33d701817eb287d1cdd9c4d0410e625\"]}" + }, + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"973c78b5e84c0986209dc14ba57682baf38fa4c1ea60c4c5f6834779a1a13e6d\",\"data\":\"02685df03c777837bc7155bd2d0d8e98eede7e956a4cd8a9edac84532584e68e0f\",\"tags\":[[\"pubkeys\",\"025f2af63fd65ca97c3bde4070549683e72769d28def2f1cd3d63576cd9c2ffa6c\"],[\"n_sigs\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02be48c564cf6a7b4d09fbaf3a78a153a79f687ac4623e48ce1788effc3fb1e024" + } + ], + "outputs": [ + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + }, + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "03afe7c87e32d436f0957f1d70a2bca025822a84a8623e3a33aed0a167016e0ca5" + }, + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "02c0d4fce02a7a0f09e3f1bca952db910b17e81a7ebcbce62cd8dcfb127d21e37b" + } + ] + }"#; + + let invalid_swap: SwapRequest = serde_json::from_str(invalid_swap).unwrap(); + assert!( + invalid_swap.verify_spending_conditions().is_err(), + "Invalid SIG_ALL swap request should fail verification" + ); + } + + #[test] + fn test_sig_all_mixed_pubkeys_and_refund_pubkeys() { + // SwapRequest with mixed up signatures from pubkey and refund_pubkeys + let invalidsig_all_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"cc93775c74df53d7c97eb37f72018d166a45ce4f4c65f11c4014b19acd02bd2f\",\"data\":\"02f515ab63e973e0dadfc284bf2ef330b01aa99c3ff775d88272f9c17afa25568c\",\"tags\":[[\"pubkeys\",\"026925e5bb547a3ec6b2d9b8934e23b882f54f89b2a9f45300bf81fd1b311d9c97\"],[\"n_sigs\",\"2\"],[\"refund\",\"03c8cd46b7e6592c41df38bc54dce2555586e7adbb15cc80a02d1a05829677286d\"],[\"n_sigs_refund\",\"1\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "03f6d40d0ab11f4082ee7e977534a6fcd151394d647cde4ab122157e6d755410fd", + "witness": "{\"signatures\":[\"a9f61c2b7161a50839bf7f3e2e1cb9bd7bdacd2ce62c0d458a5969db44646dad409a282241b412e8b191cc7432bcfebf16ad72339a9fb966ca71c8bd971662cc\",\"aa778ec15fe9408e1989c712c823e833f33d45780b9a25555ea76004b05d495e99fd326914484f92e7e91f919ee575e79add26e9d4bbe4349d7333d7e0021af7\"]}" + } + ], + "outputs": [ + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + }, + { + "amount": 1, + "id": "00bfa73302d12ffd", + "B_": "03afe7c87e32d436f0957f1d70a2bca025822a84a8623e3a33aed0a167016e0ca5" + } + ] +}"#; + + let invalid_swap: SwapRequest = serde_json::from_str(invalidsig_all_swap).unwrap(); + assert!( + invalid_swap.verify_spending_conditions().is_err(), + "Invalid SIG_ALL swap request should fail verification" + ); + } + + #[test] + fn test_sig_all_htlc_unexpired_timelock_refund_signature() { + // SwapRequest signed with refund_pubkey without expiration + let invalidsig_all_swap = r#"{ + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"HTLC\",{\"nonce\":\"b6f0c59ea4084369d4196e1318477121c2451d59ae767060e083cb6846e6bbe0\",\"data\":\"ec4916dd28fc4c10d78e287ca5d9cc51ee1ae73cbfde08c6b37324cbfaac8bc5\",\"tags\":[[\"pubkeys\",\"0329fdfde4becf9ff871129653ff6464bb2c922fbcba442e6166a8b5849599604f\"],[\"locktime\",\"4854185133\"],[\"refund\",\"035fcf4a5393e4bdef0567aa0b8a9555edba36e5fcb283f3bbce52d86a687817d3\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "024fbbee3f3cc306a48841ba327435b64de20b8b172b98296a3e573c673d52562b", + "witness": "{\"preimage\":\"0000000000000000000000000000000000000000000000000000000000000001\",\"signatures\":[\"7526819070a291f731e77acfbe9da71ddc0f748fd2a3e6c2510bc83c61daaa656df345afa3832fe7cb94352c8835a4794ad499760729c0be29417387d1fc3cd1\"]}" + } + ], + "outputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let invalid_swap: SwapRequest = serde_json::from_str(invalidsig_all_swap).unwrap(); + assert!( + invalid_swap.verify_spending_conditions().is_err(), + "Invalid SIG_ALL swap request should fail verification" + ); + } + + // Now the Melt examples at the end of 11-test.md + #[test] + fn test_sig_all_melt() { + // Valid MeltRequest with SIG_ALL signature + // Example MeltRequest: + let valid_melt = r#"{ + "quote": "cF8911fzT88aEi1d-6boZZkq5lYxbUSVs-HbJxK0", + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"bbf9edf441d17097e39f5095a3313ba24d3055ab8a32f758ff41c10d45c4f3de\",\"data\":\"029116d32e7da635c8feeb9f1f4559eb3d9b42d400f9d22a64834d89cde0eb6835\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02a9d461ff36448469dccf828fa143833ae71c689886ac51b62c8d61ddaa10028b", + "witness": "{\"signatures\":[\"478224fbe715e34f78cb33451db6fcf8ab948afb8bd04ff1a952c92e562ac0f7c1cb5e61809410635be0aa94d0448f7f7959bd5762cc3802b0a00ff58b2da747\"]}" + } + ], + "outputs": [ + { + "amount": 0, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_melt: MeltRequest = serde_json::from_str(valid_melt).unwrap(); + + // Verify the message format + let msg_to_sign = valid_melt.sig_all_msg_to_sign(); + assert_eq!( + msg_to_sign, + r#"["P2PK",{"nonce":"bbf9edf441d17097e39f5095a3313ba24d3055ab8a32f758ff41c10d45c4f3de","data":"029116d32e7da635c8feeb9f1f4559eb3d9b42d400f9d22a64834d89cde0eb6835","tags":[["sigflag","SIG_ALL"]]}]02a9d461ff36448469dccf828fa143833ae71c689886ac51b62c8d61ddaa10028b0038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39cF8911fzT88aEi1d-6boZZkq5lYxbUSVs-HbJxK0"# + ); + + // Verify the SHA256 hash of the message + use bitcoin::hashes::{sha256, Hash}; + let msg_hash = sha256::Hash::hash(msg_to_sign.as_bytes()); + assert_eq!( + msg_hash.to_string(), + "9efa1067cc7dc870f4074f695115829c3cd817a6866c3b84e9814adf3c3cf262" + ); + + assert!( + valid_melt.verify_spending_conditions().is_ok(), + "Valid SIG_ALL melt request should verify" + ); + } + + #[test] + fn test_sig_all_valid_melt() { + // The following is a valid SIG_ALL MeltRequest. + let valid_melt = r#"{ + "quote": "cF8911fzT88aEi1d-6boZZkq5lYxbUSVs-HbJxK0", + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"bbf9edf441d17097e39f5095a3313ba24d3055ab8a32f758ff41c10d45c4f3de\",\"data\":\"029116d32e7da635c8feeb9f1f4559eb3d9b42d400f9d22a64834d89cde0eb6835\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02a9d461ff36448469dccf828fa143833ae71c689886ac51b62c8d61ddaa10028b", + "witness": "{\"signatures\":[\"478224fbe715e34f78cb33451db6fcf8ab948afb8bd04ff1a952c92e562ac0f7c1cb5e61809410635be0aa94d0448f7f7959bd5762cc3802b0a00ff58b2da747\"]}" + } + ], + "outputs": [ + { + "amount": 0, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_melt: MeltRequest = serde_json::from_str(valid_melt).unwrap(); + + assert!( + valid_melt.verify_spending_conditions().is_ok(), + "Valid SIG_ALL melt request should verify" + ); + } + + #[test] + fn test_sig_all_valid_multisig_melt() { + // The following is a valid multi-sig SIG_ALL MeltRequest. + let valid_melt = r#"{ + "quote": "Db3qEMVwFN2tf_1JxbZp29aL5cVXpSMIwpYfyOVF", + "inputs": [ + { + "amount": 2, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"68d7822538740e4f9c9ebf5183ef6c4501c7a9bca4e509ce2e41e1d62e7b8a99\",\"data\":\"0394e841bd59aeadce16380df6174cb29c9fea83b0b65b226575e6d73cc5a1bd59\",\"tags\":[[\"pubkeys\",\"033d892d7ad2a7d53708b7a5a2af101cbcef69522bd368eacf55fcb4f1b0494058\"],[\"n_sigs\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "03a70c42ec9d7192422c7f7a3ad017deda309fb4a2453fcf9357795ea706cc87a9", + "witness": "{\"signatures\":[\"ed739970d003f703da2f101a51767b63858f4894468cc334be04aa3befab1617a81e3eef093441afb499974152d279e59d9582a31dc68adbc17ffc22a2516086\",\"f9efe1c70eb61e7ad8bd615c50ff850410a4135ea73ba5fd8e12a734743ad045e575e9e76ea5c52c8e7908d3ad5c0eaae93337e5c11109e52848dc328d6757a2\"]}" + } + ], + "outputs": [ + { + "amount": 0, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let valid_melt: MeltRequest = serde_json::from_str(valid_melt).unwrap(); + + assert!( + valid_melt.verify_spending_conditions().is_ok(), + "Valid SIG_ALL melt request should verify" + ); + } + + #[test] + fn test_sig_all_melt_wrong_sig() { + // Invalid MeltRequest - wrong signature for SIG_ALL + let invalid_melt = r#"{ + "inputs": [{ + "amount": 1, + "secret": "[\"P2PK\",{\"nonce\":\"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f\",\"data\":\"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\",\"tags\":[[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + "id": "009a1f293253e41e", + "witness": "{\"signatures\":[\"3426df9730d365a9d18d79bed2f3e78e9172d7107c55306ac5ddd1b2d065893366cfa24ff3c874ebf1fc22360ba5888ddf6ff5dbcb9e5f2f5a1368f7afc64f15\"]}" + }], + "quote": "test_quote_123", + "outputs": null + }"#; + + let invalid_melt: MeltRequest = serde_json::from_str(invalid_melt).unwrap(); + assert!( + invalid_melt.verify_spending_conditions().is_err(), + "Invalid SIG_ALL melt request should fail verification" + ); + } + + #[test] + #[ignore] + fn test_sig_all_melt_msg_to_sign() { + let multisig_melt = r#"{ + "quote": "uHwJ-f6HFAC-lU2dMw0KOu6gd5S571FXQQHioYMD", + "inputs": [ + { + "amount": 4, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"f5c26c928fb4433131780105eac330338bb9c0af2b2fd29fad9e4f18c4a96d84\",\"data\":\"03c4840e19277822bfeecf104dcd3f38d95b33249983ac6fed755869f23484fb2a\",\"tags\":[[\"pubkeys\",\"0256dcc53d9330e0bc6e9b3d47c26287695aba9fe55cafdde6f46ef56e09582bfb\"],[\"n_sigs\",\"1\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02174667f98114abeb741f4964bdc88a3b86efde0afa38f791094c1e07e5df3beb", + "witness": "{\"signatures\":[\"abeeceba92bc7d1c514844ddb354d1e88a9776dfb55d3cdc5c289240386e401c3d983b68371ce5530e86c8fc4ff90195982a262f83fa8a5335b43e75af5f5fc7\"]}" + } + ], + "outputs": [ + { + "amount": 0, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let multisig_melt: MeltRequest = serde_json::from_str(multisig_melt).unwrap(); + + assert!( + multisig_melt.verify_spending_conditions().is_ok(), + "melt request with SIG_ALL should succeed" + ); + + let msg_to_sign = multisig_melt.sig_all_msg_to_sign(); + + assert_eq!( + msg_to_sign, + r#"["P2PK",{"nonce":"f5c26c928fb4433131780105eac330338bb9c0af2b2fd29fad9e4f18c4a96d84","data":"03c4840e19277822bfeecf104dcd3f38d95b33249983ac6fed755869f23484fb2a","tags":[["pubkeys","0256dcc53d9330e0bc6e9b3d47c26287695aba9fe55cafdde6f46ef56e09582bfb"],["n_sigs","1"],["sigflag","SIG_ALL"]]}]02174667f98114abeb741f4964bdc88a3b86efde0afa38f791094c1e07e5df3beb000bfa73302d12ffd038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39uHwJ-f6HFAC-lU2dMw0KOu6gd5S571FXQQHioYMD"# + ); + } + + #[test] + #[ignore] + fn test_sig_all_melt_multi_sig() { + // MeltRequest with multi-sig SIG_ALL requiring 2 signatures + let multisig_melt = r#"{ + "quote": "wYHbJm5S1GTL28tDHoUAwcvb-31vu5kfDhnLxV9D", + "inputs": [ + { + "amount": 4, + "id": "00bfa73302d12ffd", + "secret": "[\"P2PK\",{\"nonce\":\"1705e988054354b703bc9103472cc5646ec76ed557517410186fa827c19c444d\",\"data\":\"024c8b5ec0e560f1fc77d7872ab75dd10a00af73a8ba715b81093b800849cb21fb\",\"tags\":[[\"pubkeys\",\"028d32bc906b3724724244812c450f688c548020f5d5a8c1d6cd1075650933d1a3\"],[\"n_sigs\",\"2\"],[\"sigflag\",\"SIG_ALL\"]]}]", + "C": "02f2a0ff12c4dd95f2476662f1df49e5126f09a5ea1f3ce13b985db57661953072", + "witness": "{\"signatures\":[\"a98a2616716d7813394a54ddc82234e5c47f0ddbddb98ccd1cad25236758fa235c8ae64d9fccd15efbe0ad5eba52a3df8433e9f1c05bc50defcb9161a5bd4bc4\",\"dd418cbbb23276dab8d72632ee77de730b932a3c6e8e15bc8802cef13db0b346915fe6e04e7fae03c3b5af026e25f71a24dc05b28135f0a9b69bc6c7289b6b8d\"]}" + } + ], + "outputs": [ + { + "amount": 0, + "id": "00bfa73302d12ffd", + "B_": "038ec853d65ae1b79b5cdbc2774150b2cb288d6d26e12958a16fb33c32d9a86c39" + } + ] +}"#; + + let multisig_melt: MeltRequest = serde_json::from_str(multisig_melt).unwrap(); + assert!( + multisig_melt.verify_spending_conditions().is_ok(), + "Multi-sig SIG_ALL melt request should verify with both signatures" + ); + + // MeltRequest with insufficient signatures for multi-sig SIG_ALL + let insufficient_sigs_melt = r#"{ + "inputs": [{ + "amount": 1, + "secret": "[\"P2PK\",{\"nonce\":\"859d4935c4907062a6297cf4e663e2835d90d97ecdd510745d32f6816323a41f\",\"data\":\"0249098aa8b9d2fbec49ff8598feb17b592b986e62319a4fa488a3dc36387157a7\",\"tags\":[[\"sigflag\",\"SIG_ALL\"],[\"pubkeys\",\"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798\",\"02142715675faf8da1ecc4d51e0b9e539fa0d52fdd96ed60dbe99adb15d6b05ad9\"],[\"n_sigs\",\"2\"]]}]", + "C": "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + "id": "009a1f293253e41e", + "witness": "{\"signatures\":[\"83564aca48c668f50d022a426ce0ed19d3a9bdcffeeaee0dc1e7ea7e98e9eff1840fcc821724f623468c94f72a8b0a7280fa9ef5a54a1b130ef3055217f467b3\"]}" + }], + "quote": "test_quote_123", + "outputs": null + }"#; + + let insufficient_sigs_melt: MeltRequest = + serde_json::from_str(insufficient_sigs_melt).unwrap(); + assert!( + insufficient_sigs_melt.verify_spending_conditions().is_err(), + "Multi-sig SIG_ALL melt request should fail with insufficient signatures" + ); + } + + // Helper functions for tests + fn create_test_keys() -> (SecretKey, PublicKey) { + let secret_key = + SecretKey::from_str("99590802251e78ee1051648439eedb003dc539093a48a44e7b8f2642c909ea37") + .unwrap(); + let pubkey = secret_key.public_key(); + (secret_key, pubkey) + } + + #[test] + fn test_sig_all_basic_signing_verification() { + let (secret_key, pubkey) = create_test_keys(); + + // Create basic SIG_ALL conditions + let conditions = Conditions { + sig_flag: SigFlag::SigAll, + ..Default::default() + }; + + let secret = create_test_secret(pubkey, conditions); + let proof1 = create_test_proof(secret.clone(), pubkey, "009a1f293253e41e"); + let proof2 = create_test_proof(secret, pubkey, "009a1f293253e41f"); + let blinded_msg = create_test_blinded_msg(pubkey); + + // Test basic signing flow + let mut swap = SwapRequest::new(vec![proof1, proof2], vec![blinded_msg]); + assert!( + swap.verify_spending_conditions().is_err(), + "Unsigned swap should fail verification" + ); + + assert!( + swap.sign_sig_all(secret_key).is_ok(), + "Signing should succeed" + ); + + println!("{}", serde_json::to_string(&swap).unwrap()); + + assert!( + swap.verify_spending_conditions().is_ok(), + "Signed swap should pass verification" + ); + } + + #[test] + fn test_sig_all_unauthorized_key() { + let (_secret_key, pubkey) = create_test_keys(); + + let conditions = Conditions { + sig_flag: SigFlag::SigAll, + ..Default::default() + }; + + let secret = create_test_secret(pubkey, conditions); + let proof = create_test_proof(secret, pubkey, "009a1f293253e41e"); + let blinded_msg = create_test_blinded_msg(pubkey); + + // Create unauthorized key + let unauthorized_key = + SecretKey::from_str("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + + let mut swap = SwapRequest::new(vec![proof], vec![blinded_msg]); + + // Signing should succeed (no validation) + swap.sign_sig_all(unauthorized_key).unwrap(); + + // Verification should fail (unauthorized signature) + assert!( + swap.verify_spending_conditions().is_err(), + "Verification should fail with unauthorized key signature" + ); + } + + #[test] + fn test_sig_all_mismatched_secrets() { + let (secret_key, pubkey) = create_test_keys(); + + let conditions = Conditions { + sig_flag: SigFlag::SigAll, + ..Default::default() + }; + + // Create first proof with original secret + let secret1 = create_test_secret(pubkey, conditions.clone()); + + // Create second proof with different secret data + let different_secret = Nut10Secret::new( + Kind::P2PK, + "02698c4e2b5f9534cd0687d87513c759790cf829aa5739184a3e3735471fbda904", + Some(conditions), + ) + .try_into() + .unwrap(); + + let proof1 = create_test_proof(secret1, pubkey, "009a1f293253e41e"); + let proof2 = create_test_proof(different_secret, pubkey, "009a1f293253e41f"); + let blinded_msg = create_test_blinded_msg(pubkey); + + let mut swap = SwapRequest::new(vec![proof1, proof2], vec![blinded_msg]); + + // Signing should succeed (no validation) + swap.sign_sig_all(secret_key).unwrap(); + + // Verification should fail (mismatched secrets) + assert!( + swap.verify_spending_conditions().is_err(), + "Verification should fail with mismatched secrets" + ); + } + + #[test] + fn test_sig_all_wrong_flag() { + let (secret_key, pubkey) = create_test_keys(); + + // Create conditions with SIG_INPUTS instead of SIG_ALL + let sig_inputs_conditions = Conditions { + sig_flag: SigFlag::SigInputs, + ..Default::default() + }; + + let secret = create_test_secret(pubkey, sig_inputs_conditions); + let proof = create_test_proof(secret, pubkey, "009a1f293253e41e"); + let blinded_msg = create_test_blinded_msg(pubkey); + + let mut swap = SwapRequest::new(vec![proof], vec![blinded_msg]); + + // Signing should succeed (no validation) + swap.sign_sig_all(secret_key).unwrap(); + + // Verification should fail (wrong flag - has SIG_INPUTS but sign_sig_all expects SIG_ALL) + assert!( + swap.verify_spending_conditions().is_err(), + "Verification should fail with SIG_INPUTS flag when sign_sig_all was used" + ); + } + + #[test] + fn test_sig_all_multiple_signatures() { + let (secret_key1, pubkey1) = create_test_keys(); + let secret_key2 = + SecretKey::from_str("7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f") + .unwrap(); + let pubkey2 = secret_key2.public_key(); + + // Create conditions requiring 2 signatures + let conditions = Conditions { + num_sigs: Some(2), + sig_flag: SigFlag::SigAll, + pubkeys: Some(vec![pubkey2]), + ..Default::default() + }; + + let secret = create_test_secret(pubkey1, conditions); + let proof = create_test_proof(secret, pubkey1, "009a1f293253e41e"); + let blinded_msg = create_test_blinded_msg(pubkey1); + + let mut swap = SwapRequest::new(vec![proof], vec![blinded_msg]); + + // Sign with first key + assert!( + swap.sign_sig_all(secret_key1).is_ok(), + "First signature should succeed" + ); + assert!( + swap.verify_spending_conditions().is_err(), + "Single signature should not verify when two required" + ); + + // Sign with second key + assert!( + swap.sign_sig_all(secret_key2).is_ok(), + "Second signature should succeed" + ); + + assert!( + swap.verify_spending_conditions().is_ok(), + "Both signatures should verify" + ); } } diff --git a/crates/cashu/src/nuts/nut12.rs b/crates/cashu/src/nuts/nut12.rs index 232368b04..6e4dfec8e 100644 --- a/crates/cashu/src/nuts/nut12.rs +++ b/crates/cashu/src/nuts/nut12.rs @@ -265,4 +265,157 @@ mod tests { assert!(proof.verify_dleq(a).is_ok()); } + + /// Tests that verify_dleq correctly rejects verification with a wrong mint key. + /// + /// This test is critical for security - if the verification function doesn't properly + /// check the mint key, an attacker could forge proofs using any key. + /// + /// Mutant testing: Catches mutations that replace verify_dleq with Ok(()) or remove + /// the verification logic. + #[test] + fn test_proof_dleq_wrong_mint_key() { + let proof = r#"{"amount": 1,"id": "00882760bfa2eb41","secret": "daf4dd00a2b68a0858a80450f52c8a7d2ccf87d375e43e216e0c571f089f63e9","C": "024369d2d22a80ecf78f3937da9d5f30c1b9f74f0c32684d583cca0fa6a61cdcfc","dleq": {"e": "b31e58ac6527f34975ffab13e70a48b6d2b0d35abc4b03f0151f09ee1a9763d4","s": "8fbae004c59e754d71df67e392b6ae4e29293113ddc2ec86592a0431d16306d8","r": "a6d13fcd7a18442e6076f5e1e7c887ad5de40a019824bdfa9fe740d302e8d861"}}"#; + + let proof: Proof = serde_json::from_str(proof).unwrap(); + + // Wrong mint key - different from the one used to create the proof + let wrong_key: PublicKey = PublicKey::from_str( + "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5", + ) + .unwrap(); + + // Verification should fail with wrong key + assert!(proof.verify_dleq(wrong_key).is_err()); + } + + /// Tests that verify_dleq correctly rejects proofs with missing DLEQ data. + /// + /// This test ensures that proofs without DLEQ data are rejected when DLEQ + /// verification is required. + /// + /// Mutant testing: Catches mutations that replace verify_dleq with Ok(()) or + /// remove the None check. + #[test] + fn test_proof_dleq_missing() { + let proof = r#"{"amount": 1,"id": "00882760bfa2eb41","secret": "daf4dd00a2b68a0858a80450f52c8a7d2ccf87d375e43e216e0c571f089f63e9","C": "024369d2d22a80ecf78f3937da9d5f30c1b9f74f0c32684d583cca0fa6a61cdcfc"}"#; + + let proof: Proof = serde_json::from_str(proof).unwrap(); + + let a: PublicKey = PublicKey::from_str( + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + ) + .unwrap(); + + // Verification should fail when DLEQ is missing + let result = proof.verify_dleq(a); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::MissingDleqProof)); + } + + /// Tests that BlindSignature::verify_dleq correctly rejects verification with wrong mint key. + /// + /// This test ensures that blind signature DLEQ verification properly validates the mint key. + /// + /// Mutant testing: Catches mutations that replace BlindSignature::verify_dleq with Ok(()) + /// or remove the verification logic. + #[test] + fn test_blind_signature_dleq_wrong_key() { + let blinded_sig = r#"{"amount":8,"id":"00882760bfa2eb41","C_":"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2","dleq":{"e":"9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73d9","s":"9818e061ee51d5c8edc3342369a554998ff7b4381c8652d724cdf46429be73da"}}"#; + + let blinded: BlindSignature = serde_json::from_str(blinded_sig).unwrap(); + + // Wrong secret key - different from the one used to create the signature + let wrong_key = + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002") + .unwrap(); + + let blinded_secret = PublicKey::from_str( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(); + + // Verification should fail with wrong key + assert!(blinded + .verify_dleq(wrong_key.public_key(), blinded_secret) + .is_err()); + } + + /// Tests that BlindSignature::verify_dleq correctly rejects verification with tampered DLEQ data. + /// + /// This test ensures that tampering with the 'e' or 's' values in the DLEQ proof + /// causes verification to fail. + /// + /// Mutant testing: Catches mutations that replace verify_dleq with Ok(()) or + /// weaken the cryptographic checks. + #[test] + fn test_blind_signature_dleq_tampered() { + // Tampered DLEQ data - 'e' and 's' values have been modified to wrong (but valid) values + let tampered_sig = r#"{"amount":8,"id":"00882760bfa2eb41","C_":"02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2","dleq":{"e":"0000000000000000000000000000000000000000000000000000000000000001","s":"0000000000000000000000000000000000000000000000000000000000000002"}}"#; + + let blinded: BlindSignature = serde_json::from_str(tampered_sig).unwrap(); + + let secret_key = + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + + let blinded_secret = PublicKey::from_str( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(); + + // Verification should fail with tampered data + assert!(blinded + .verify_dleq(secret_key.public_key(), blinded_secret) + .is_err()); + } + + /// Tests that BlindSignature::add_dleq_proof properly generates DLEQ data. + /// + /// This test ensures that add_dleq_proof actually adds the DLEQ proof and doesn't + /// just return Ok(()) without doing anything. + /// + /// Mutant testing: Catches mutations that replace add_dleq_proof with Ok(()) + /// without actually adding the proof. + #[test] + fn test_add_dleq_proof() { + use crate::nuts::nut02::Id; + + let secret_key = + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); + + let blinded_message = PublicKey::from_str( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(); + + let blinded_signature = PublicKey::from_str( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(); + + let mut blind_sig = BlindSignature { + amount: Amount::from(1), + keyset_id: Id::from_str("00882760bfa2eb41").unwrap(), + c: blinded_signature, + dleq: None, + }; + + // Initially, DLEQ should be None + assert!(blind_sig.dleq.is_none()); + + // Add DLEQ proof + blind_sig + .add_dleq_proof(&blinded_message, &secret_key) + .unwrap(); + + // After adding, DLEQ should be Some + assert!(blind_sig.dleq.is_some()); + + // Verify the added DLEQ is valid + assert!(blind_sig + .verify_dleq(secret_key.public_key(), blinded_message) + .is_ok()); + } } diff --git a/crates/cashu/src/nuts/nut13.rs b/crates/cashu/src/nuts/nut13.rs index 849131974..c8841b041 100644 --- a/crates/cashu/src/nuts/nut13.rs +++ b/crates/cashu/src/nuts/nut13.rs @@ -3,13 +3,15 @@ //! use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; +use bitcoin::secp256k1::hashes::{hmac, sha256, Hash, HashEngine, HmacEngine}; +use bitcoin::{secp256k1, Network}; use thiserror::Error; use tracing::instrument; use super::nut00::{BlindedMessage, PreMint, PreMintSecrets}; use super::nut01::SecretKey; use super::nut02::Id; -use crate::amount::SplitTarget; +use crate::amount::{FeeAndAmounts, SplitTarget}; use crate::dhke::blind_message; use crate::secret::Secret; use crate::util::hex; @@ -33,11 +35,25 @@ pub enum Error { /// Bip32 Error #[error(transparent)] Bip32(#[from] bitcoin::bip32::Error), + /// HMAC Error + #[error(transparent)] + Hmac(#[from] bitcoin::secp256k1::hashes::FromSliceError), + /// SecretKey Error + #[error(transparent)] + SecpError(#[from] bitcoin::secp256k1::Error), } impl Secret { - /// Create new [`Secret`] from xpriv - pub fn from_xpriv(xpriv: Xpriv, keyset_id: Id, counter: u32) -> Result { + /// Create new [`Secret`] from seed + pub fn from_seed(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { + match keyset_id.get_version() { + super::nut02::KeySetVersion::Version00 => Self::legacy_derive(seed, keyset_id, counter), + super::nut02::KeySetVersion::Version01 => Self::derive(seed, keyset_id, counter), + } + } + + fn legacy_derive(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { + let xpriv = Xpriv::new_master(Network::Bitcoin, seed)?; let path = derive_path_from_keyset_id(keyset_id)? .child(ChildNumber::from_hardened_idx(counter)?) .child(ChildNumber::from_normal_idx(0)?); @@ -47,11 +63,34 @@ impl Secret { derived_xpriv.private_key.secret_bytes(), ))) } + + fn derive(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { + let mut message = Vec::new(); + message.extend_from_slice(b"Cashu_KDF_HMAC_SHA256"); + message.extend_from_slice(&keyset_id.to_bytes()); + message.extend_from_slice(&(counter as u64).to_be_bytes()); + message.extend_from_slice(b"\x00"); + + let mut engine = HmacEngine::::new(seed); + engine.input(&message); + let hmac_result = hmac::Hmac::::from_engine(engine); + let result_bytes = hmac_result.to_byte_array(); + + Ok(Self::new(hex::encode(&result_bytes[..32]))) + } } impl SecretKey { - /// Create new [`SecretKey`] from xpriv - pub fn from_xpriv(xpriv: Xpriv, keyset_id: Id, counter: u32) -> Result { + /// Create new [`SecretKey`] from seed + pub fn from_seed(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { + match keyset_id.get_version() { + super::nut02::KeySetVersion::Version00 => Self::legacy_derive(seed, keyset_id, counter), + super::nut02::KeySetVersion::Version01 => Self::derive(seed, keyset_id, counter), + } + } + + fn legacy_derive(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { + let xpriv = Xpriv::new_master(Network::Bitcoin, seed)?; let path = derive_path_from_keyset_id(keyset_id)? .child(ChildNumber::from_hardened_idx(counter)?) .child(ChildNumber::from_normal_idx(1)?); @@ -59,26 +98,44 @@ impl SecretKey { Ok(Self::from(derived_xpriv.private_key)) } + + fn derive(seed: &[u8; 64], keyset_id: Id, counter: u32) -> Result { + let mut message = Vec::new(); + message.extend_from_slice(b"Cashu_KDF_HMAC_SHA256"); + message.extend_from_slice(&keyset_id.to_bytes()); + message.extend_from_slice(&(counter as u64).to_be_bytes()); + message.extend_from_slice(b"\x01"); + + let mut engine = HmacEngine::::new(seed); + engine.input(&message); + let hmac_result = hmac::Hmac::::from_engine(engine); + let result_bytes = hmac_result.to_byte_array(); + + Ok(Self::from(secp256k1::SecretKey::from_slice( + &result_bytes[..32], + )?)) + } } impl PreMintSecrets { /// Generate blinded messages from predetermined secrets and blindings /// factor - #[instrument(skip(xpriv))] - pub fn from_xpriv( + #[instrument(skip(seed))] + pub fn from_seed( keyset_id: Id, counter: u32, - xpriv: Xpriv, + seed: &[u8; 64], amount: Amount, amount_split_target: &SplitTarget, + fee_and_amounts: &FeeAndAmounts, ) -> Result { let mut pre_mint_secrets = PreMintSecrets::new(keyset_id); let mut counter = counter; - for amount in amount.split_targeted(amount_split_target)? { - let secret = Secret::from_xpriv(xpriv, keyset_id, counter)?; - let blinding_factor = SecretKey::from_xpriv(xpriv, keyset_id, counter)?; + for amount in amount.split_targeted(amount_split_target, fee_and_amounts)? { + let secret = Secret::from_seed(seed, keyset_id, counter)?; + let blinding_factor = SecretKey::from_seed(seed, keyset_id, counter)?; let (blinded, r) = blind_message(&secret.to_bytes(), Some(blinding_factor))?; @@ -98,11 +155,11 @@ impl PreMintSecrets { Ok(pre_mint_secrets) } - /// New [`PreMintSecrets`] from xpriv with a zero amount used for change - pub fn from_xpriv_blank( + /// New [`PreMintSecrets`] from seed with a zero amount used for change + pub fn from_seed_blank( keyset_id: Id, counter: u32, - xpriv: Xpriv, + seed: &[u8; 64], amount: Amount, ) -> Result { if amount <= Amount::ZERO { @@ -114,8 +171,8 @@ impl PreMintSecrets { let mut counter = counter; for _ in 0..count { - let secret = Secret::from_xpriv(xpriv, keyset_id, counter)?; - let blinding_factor = SecretKey::from_xpriv(xpriv, keyset_id, counter)?; + let secret = Secret::from_seed(seed, keyset_id, counter)?; + let blinding_factor = SecretKey::from_seed(seed, keyset_id, counter)?; let (blinded, r) = blind_message(&secret.to_bytes(), Some(blinding_factor))?; @@ -141,15 +198,15 @@ impl PreMintSecrets { /// factor pub fn restore_batch( keyset_id: Id, - xpriv: Xpriv, + seed: &[u8; 64], start_count: u32, end_count: u32, ) -> Result { let mut pre_mint_secrets = PreMintSecrets::new(keyset_id); for i in start_count..=end_count { - let secret = Secret::from_xpriv(xpriv, keyset_id, i)?; - let blinding_factor = SecretKey::from_xpriv(xpriv, keyset_id, i)?; + let secret = Secret::from_seed(seed, keyset_id, i)?; + let blinding_factor = SecretKey::from_seed(seed, keyset_id, i)?; let (blinded, r) = blind_message(&secret.to_bytes(), Some(blinding_factor))?; @@ -186,7 +243,6 @@ mod tests { use bip39::Mnemonic; use bitcoin::bip32::DerivationPath; - use bitcoin::Network; use super::*; @@ -196,7 +252,6 @@ mod tests { "half depart obvious quality work element tank gorilla view sugar picture humble"; let mnemonic = Mnemonic::from_str(seed).unwrap(); let seed: [u8; 64] = mnemonic.to_seed(""); - let xpriv = Xpriv::new_master(Network::Bitcoin, &seed).unwrap(); let keyset_id = Id::from_str("009a1f293253e41e").unwrap(); let test_secrets = [ @@ -208,7 +263,7 @@ mod tests { ]; for (i, test_secret) in test_secrets.iter().enumerate() { - let secret = Secret::from_xpriv(xpriv, keyset_id, i.try_into().unwrap()).unwrap(); + let secret = Secret::from_seed(&seed, keyset_id, i.try_into().unwrap()).unwrap(); assert_eq!(secret, Secret::from_str(test_secret).unwrap()) } } @@ -218,7 +273,6 @@ mod tests { "half depart obvious quality work element tank gorilla view sugar picture humble"; let mnemonic = Mnemonic::from_str(seed).unwrap(); let seed: [u8; 64] = mnemonic.to_seed(""); - let xpriv = Xpriv::new_master(Network::Bitcoin, &seed).unwrap(); let keyset_id = Id::from_str("009a1f293253e41e").unwrap(); let test_rs = [ @@ -230,7 +284,7 @@ mod tests { ]; for (i, test_r) in test_rs.iter().enumerate() { - let r = SecretKey::from_xpriv(xpriv, keyset_id, i.try_into().unwrap()).unwrap(); + let r = SecretKey::from_seed(&seed, keyset_id, i.try_into().unwrap()).unwrap(); assert_eq!(r, SecretKey::from_hex(test_r).unwrap()) } } @@ -253,4 +307,235 @@ mod tests { ); } } + + #[test] + fn test_secret_derivation_keyset_v2() { + let seed = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + let mnemonic = Mnemonic::from_str(seed).unwrap(); + let seed: [u8; 64] = mnemonic.to_seed(""); + + // Test with a v2 keyset ID (33 bytes, starting with "01") + let keyset_id = + Id::from_str("012e23479a0029432eaad0d2040c09be53bab592d5cbf1d55e0dd26c9495951b30") + .unwrap(); + + // Expected secrets derived using the new derivation + let test_secrets = [ + "ba250bf927b1df5dd0a07c543be783a4349a7f99904acd3406548402d3484118", + "3a6423fe56abd5e74ec9d22a91ee110cd2ce45a7039901439d62e5534d3438c1", + "843484a75b78850096fac5b513e62854f11d57491cf775a6fd2edf4e583ae8c0", + "3600608d5cf8197374f060cfbcff134d2cd1fb57eea68cbcf2fa6917c58911b6", + "717fce9cc6f9ea060d20dd4e0230af4d63f3894cc49dd062fd99d033ea1ac1dd", + ]; + + for (i, test_secret) in test_secrets.iter().enumerate() { + let secret = Secret::from_seed(&seed, keyset_id, i.try_into().unwrap()).unwrap(); + // Note: The actual expected values would need to be computed from a reference implementation + // For now, we just verify the derivation works and produces consistent results + assert_eq!(secret.to_string().len(), 64); // Should be 32 bytes = 64 hex chars + + // Test deterministic derivation: same inputs should produce same outputs + let secret2 = Secret::from_str(test_secret).unwrap(); + assert_eq!(secret, secret2); + } + } + + #[test] + fn test_secret_key_derivation_keyset_v2() { + let seed = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + let mnemonic = Mnemonic::from_str(seed).unwrap(); + let seed: [u8; 64] = mnemonic.to_seed(""); + + // Test with a v2 keyset ID (33 bytes, starting with "01") + let keyset_id = + Id::from_str("012e23479a0029432eaad0d2040c09be53bab592d5cbf1d55e0dd26c9495951b30") + .unwrap(); + + let test_secret_keys = [ + "4f8b32a54aed811b692a665ed296b4c1fc2f37a8be4006379e95063a76693745", + "c4b8412ee644067007423480c9e556385b71ffdff0f340bc16a95c0534fe0e01", + "ceff40983441c40acaf77d2a8ddffd5c1c84391fb9fd0dc4607c186daab1c829", + "41ad26b840fb62d29b2318a82f1d9cd40dc0f1e58183cc57562f360a32fdfad6", + "fb986a9c76758593b0e2d1a5172ade977c858d87111a220e16c292a9347abf81", + ]; + + for (i, test_secret) in test_secret_keys.iter().enumerate() { + let secret_key = SecretKey::from_seed(&seed, keyset_id, i as u32).unwrap(); + + // Verify the secret key is valid (32 bytes) + let secret_bytes = secret_key.secret_bytes(); + assert_eq!(secret_bytes.len(), 32); + + // Test deterministic derivation + let secret_key2 = SecretKey::from_str(test_secret).unwrap(); + assert_eq!(secret_key, secret_key2); + } + } + + #[test] + fn test_v2_derivation_with_different_keysets() { + let seed = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + let mnemonic = Mnemonic::from_str(seed).unwrap(); + let seed: [u8; 64] = mnemonic.to_seed(""); + + let keyset_id_1 = + Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035") + .unwrap(); + let keyset_id_2 = + Id::from_str("01bef024fb9e85171586660abab27579888611659d357bc86bc09cb26eee8bc046") + .unwrap(); + + // Different keyset IDs should produce different secrets even with same counter + for counter in 0..3 { + let secret_1 = Secret::from_seed(&seed, keyset_id_1, counter).unwrap(); + let secret_2 = Secret::from_seed(&seed, keyset_id_2, counter).unwrap(); + assert_ne!( + secret_1, secret_2, + "Different keyset IDs should produce different secrets for counter {}", + counter + ); + + let secret_key_1 = SecretKey::from_seed(&seed, keyset_id_1, counter).unwrap(); + let secret_key_2 = SecretKey::from_seed(&seed, keyset_id_2, counter).unwrap(); + assert_ne!( + secret_key_1, secret_key_2, + "Different keyset IDs should produce different secret keys for counter {}", + counter + ); + } + } + + #[test] + fn test_v2_derivation_incremental_counters() { + let seed = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + let mnemonic = Mnemonic::from_str(seed).unwrap(); + let seed: [u8; 64] = mnemonic.to_seed(""); + + let keyset_id = + Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035") + .unwrap(); + + let mut secrets = Vec::new(); + let mut secret_keys = Vec::new(); + + // Generate secrets with incremental counters + for counter in 0..10 { + let secret = Secret::from_seed(&seed, keyset_id, counter).unwrap(); + let secret_key = SecretKey::from_seed(&seed, keyset_id, counter).unwrap(); + + // Ensure no duplicates + assert!( + !secrets.contains(&secret), + "Duplicate secret found for counter {}", + counter + ); + assert!( + !secret_keys.contains(&secret_key), + "Duplicate secret key found for counter {}", + counter + ); + + secrets.push(secret); + secret_keys.push(secret_key); + } + } + + #[test] + fn test_v2_hmac_message_construction() { + let seed = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + let mnemonic = Mnemonic::from_str(seed).unwrap(); + let seed: [u8; 64] = mnemonic.to_seed(""); + + let keyset_id = + Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035") + .unwrap(); + let counter: u32 = 42; + + // Test that the HMAC message is constructed correctly + // Message should be: b"Cashu_KDF_HMAC_SHA512" + keyset_id.to_bytes() + counter.to_be_bytes() + let _expected_prefix = b"Cashu_KDF_HMAC_SHA512"; + let keyset_bytes = keyset_id.to_bytes(); + let _counter_bytes = (counter as u64).to_be_bytes(); + + // Verify keyset ID v2 structure: version byte (01) + 32 bytes + assert_eq!(keyset_bytes.len(), 33); + assert_eq!(keyset_bytes[0], 0x01); + + // The actual HMAC construction is internal, but we can verify the derivation works + let secret = Secret::from_seed(&seed, keyset_id, counter).unwrap(); + let secret_key = SecretKey::from_seed(&seed, keyset_id, counter).unwrap(); + + // Verify outputs are valid hex strings of correct length + assert_eq!(secret.to_string().len(), 64); // 32 bytes as hex + assert_eq!(secret_key.secret_bytes().len(), 32); + } + + #[test] + fn test_pre_mint_secrets_with_v2_keyset() { + let seed = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + let mnemonic = Mnemonic::from_str(seed).unwrap(); + let seed: [u8; 64] = mnemonic.to_seed(""); + + let keyset_id = + Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035") + .unwrap(); + let amount = Amount::from(1000u64); + let split_target = SplitTarget::default(); + let fee_and_amounts = (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(); + + // Test PreMintSecrets generation with v2 keyset + let pre_mint_secrets = + PreMintSecrets::from_seed(keyset_id, 0, &seed, amount, &split_target, &fee_and_amounts) + .unwrap(); + + // Verify all secrets in the pre_mint use the new v2 derivation + for (i, pre_mint) in pre_mint_secrets.secrets.iter().enumerate() { + // Verify the secret was derived correctly + let expected_secret = Secret::from_seed(&seed, keyset_id, i as u32).unwrap(); + assert_eq!(pre_mint.secret, expected_secret); + + // Verify keyset ID version + assert_eq!( + pre_mint.blinded_message.keyset_id.get_version(), + super::super::nut02::KeySetVersion::Version01 + ); + } + } + + #[test] + fn test_restore_batch_with_v2_keyset() { + let seed = + "half depart obvious quality work element tank gorilla view sugar picture humble"; + let mnemonic = Mnemonic::from_str(seed).unwrap(); + let seed: [u8; 64] = mnemonic.to_seed(""); + + let keyset_id = + Id::from_str("01adc013fa9d85171586660abab27579888611659d357bc86bc09cb26eee8bc035") + .unwrap(); + + let start_count = 5; + let end_count = 10; + + // Test batch restoration with v2 keyset + let pre_mint_secrets = + PreMintSecrets::restore_batch(keyset_id, &seed, start_count, end_count).unwrap(); + + assert_eq!( + pre_mint_secrets.secrets.len(), + (end_count - start_count + 1) as usize + ); + + // Verify each secret in the batch + for (i, pre_mint) in pre_mint_secrets.secrets.iter().enumerate() { + let counter = start_count + i as u32; + let expected_secret = Secret::from_seed(&seed, keyset_id, counter).unwrap(); + assert_eq!(pre_mint.secret, expected_secret); + } + } } diff --git a/crates/cashu/src/nuts/nut14/mod.rs b/crates/cashu/src/nuts/nut14/mod.rs index fd52d9023..57f170775 100644 --- a/crates/cashu/src/nuts/nut14/mod.rs +++ b/crates/cashu/src/nuts/nut14/mod.rs @@ -4,8 +4,6 @@ use std::str::FromStr; -use bitcoin::hashes::sha256::Hash as Sha256Hash; -use bitcoin::hashes::Hash; use bitcoin::secp256k1::schnorr::Signature; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -14,8 +12,7 @@ use super::nut00::Witness; use super::nut10::Secret; use super::nut11::valid_signatures; use super::{Conditions, Proof}; -use crate::ensure_cdk; -use crate::util::unix_time; +use crate::util::{hex, unix_time}; pub mod serde_htlc_witness; @@ -37,9 +34,18 @@ pub enum Error { /// Preimage does not match #[error("Preimage does not match")] Preimage, + /// HTLC preimage must be valid hex encoding + #[error("Preimage must be valid hex encoding")] + InvalidHexPreimage, + /// HTLC preimage must be exactly 32 bytes + #[error("Preimage must be exactly 32 bytes (64 hex characters)")] + PreimageInvalidSize, /// Witness Signatures not provided #[error("Witness did not provide signatures")] SignaturesNotProvided, + /// SIG_ALL not supported in this context + #[error("SIG_ALL proofs must be verified using a different method")] + SigAllNotSupportedHere, /// Secp256k1 error #[error(transparent)] Secp256k1(#[from] bitcoin::secp256k1::Error), @@ -55,93 +61,416 @@ pub enum Error { #[derive(Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct HTLCWitness { - /// Primage + /// Preimage pub preimage: String, /// Signatures #[serde(skip_serializing_if = "Option::is_none")] pub signatures: Option>, } +impl HTLCWitness { + /// Decode the preimage from hex and verify it's exactly 32 bytes + /// + /// Returns the 32-byte preimage data if valid, or an error if: + /// - The hex decoding fails + /// - The decoded data is not exactly 32 bytes + pub fn preimage_data(&self) -> Result<[u8; 32], Error> { + const REQUIRED_PREIMAGE_BYTES: usize = 32; + + // Decode the 64-character hex string to bytes + let preimage_bytes = hex::decode(&self.preimage).map_err(|_| Error::InvalidHexPreimage)?; + + // Verify the preimage is exactly 32 bytes + if preimage_bytes.len() != REQUIRED_PREIMAGE_BYTES { + return Err(Error::PreimageInvalidSize); + } + + // Convert to fixed-size array + let mut array = [0u8; 32]; + array.copy_from_slice(&preimage_bytes); + Ok(array) + } +} + impl Proof { /// Verify HTLC pub fn verify_htlc(&self) -> Result<(), Error> { let secret: Secret = self.secret.clone().try_into()?; - let conditions: Option = secret + let spending_conditions: Conditions = secret .secret_data() .tags() - .and_then(|c| c.clone().try_into().ok()); + .cloned() + .unwrap_or_default() + .try_into()?; - let htlc_witness = match &self.witness { - Some(Witness::HTLCWitness(witness)) => witness, - _ => return Err(Error::IncorrectSecretKind), - }; - - if let Some(conditions) = conditions { - // Check locktime - if let Some(locktime) = conditions.locktime { - // If locktime is in passed and no refund keys provided anyone can spend - if locktime.lt(&unix_time()) && conditions.refund_keys.is_none() { - return Ok(()); - } - - // If refund keys are provided verify p2pk signatures - if let (Some(refund_key), Some(signatures)) = - (conditions.refund_keys, &self.witness) - { - let signatures = signatures - .signatures() - .ok_or(Error::SignaturesNotProvided)? - .iter() - .map(|s| Signature::from_str(s)) - .collect::, _>>()?; - - // If secret includes refund keys check that there is a valid signature - if valid_signatures(self.secret.as_bytes(), &refund_key, &signatures)?.ge(&1) { - return Ok(()); - } - } - } - // If pubkeys are present check there is a valid signature - if let Some(pubkey) = conditions.pubkeys { - let req_sigs = conditions.num_sigs.unwrap_or(1); - - let signatures = htlc_witness - .signatures - .as_ref() - .ok_or(Error::SignaturesNotProvided)?; - - let signatures = signatures - .iter() - .map(|s| Signature::from_str(s)) - .collect::, _>>()?; - - let valid_sigs = valid_signatures(self.secret.as_bytes(), &pubkey, &signatures)?; - ensure_cdk!(valid_sigs >= req_sigs, Error::IncorrectSecretKind); - } + if spending_conditions.sig_flag == super::SigFlag::SigAll { + return Err(Error::SigAllNotSupportedHere); } - if secret.kind().ne(&super::Kind::HTLC) { + if secret.kind() != super::Kind::HTLC { return Err(Error::IncorrectSecretKind); } - let hash_lock = - Sha256Hash::from_str(secret.secret_data().data()).map_err(|_| Error::InvalidHash)?; + // Get the appropriate spending conditions based on locktime + let now = unix_time(); + let requirements = + super::nut10::get_pubkeys_and_required_sigs(&secret, now).map_err(Error::NUT11)?; + + // While a Witness is usually needed in a P2PK or HTLC proof, it's not + // always needed. If we are past the locktime, and there are no refund + // keys, then the proofs are anyone-can-spend: + // NUT-11: "If the tag locktime is the unix time and the mint's local + // clock is greater than locktime, the Proof becomes spendable + // by anyone, except if [there are no refund keys]" + // Therefore, this function should not extract any Witness unless it + // is needed to get a preimage or signatures. - let preimage_hash = Sha256Hash::hash(htlc_witness.preimage.as_bytes()); + // If preimage is needed (before locktime), verify it + if requirements.preimage_needed { + // Extract HTLC witness + let htlc_witness = match &self.witness { + Some(Witness::HTLCWitness(witness)) => witness, + _ => return Err(Error::IncorrectSecretKind), + }; - if hash_lock.ne(&preimage_hash) { - return Err(Error::Preimage); + // Verify preimage using shared function + super::nut10::verify_htlc_preimage(htlc_witness, &secret)?; } - Ok(()) + if requirements.required_sigs == 0 { + return Ok(()); + } + + // if we get here, the preimage check (if it was needed) has been done + // and we know that at least one signature is required. So, we extract + // the witness.signatures and count them: + + // Extract witness signatures + let htlc_witness = match &self.witness { + Some(Witness::HTLCWitness(witness)) => witness, + _ => return Err(Error::IncorrectSecretKind), + }; + let witness_signatures = htlc_witness + .signatures + .as_ref() + .ok_or(Error::SignaturesNotProvided)?; + + // Convert signatures from strings + let signatures: Vec = witness_signatures + .iter() + .map(|s| Signature::from_str(s)) + .collect::, _>>()?; + + // Count valid signatures using relevant_pubkeys + let msg: &[u8] = self.secret.as_bytes(); + let valid_sig_count = valid_signatures(msg, &requirements.pubkeys, &signatures)?; + + // Check if we have enough valid signatures + if valid_sig_count >= requirements.required_sigs { + Ok(()) + } else { + Err(Error::NUT11(super::nut11::Error::SpendConditionsNotMet)) + } } /// Add Preimage #[inline] pub fn add_preimage(&mut self, preimage: String) { + let signatures = self + .witness + .as_ref() + .map(|w| w.signatures()) + .unwrap_or_default(); + self.witness = Some(Witness::HTLCWitness(HTLCWitness { preimage, - signatures: None, + signatures, })) } } + +#[cfg(test)] +mod tests { + use bitcoin::hashes::sha256::Hash as Sha256Hash; + use bitcoin::hashes::Hash; + + use super::*; + use crate::nuts::nut00::Witness; + use crate::nuts::nut10::Kind; + use crate::nuts::Nut10Secret; + use crate::secret::Secret as SecretString; + + /// Tests that verify_htlc correctly accepts a valid HTLC with the correct preimage. + /// + /// This test ensures that a properly formed HTLC proof with the correct preimage + /// passes verification. + /// + /// Mutant testing: Combined with negative tests, this catches mutations that + /// replace verify_htlc with Ok(()) since the negative tests will fail. + #[test] + fn test_verify_htlc_valid() { + // Create a valid HTLC secret with a known preimage (32 bytes) + let preimage_bytes = [42u8; 32]; // 32-byte preimage + let hash = Sha256Hash::hash(&preimage_bytes); + let hash_str = hash.to_string(); + + let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::>>); + let secret: SecretString = nut10_secret.try_into().unwrap(); + + let htlc_witness = HTLCWitness { + preimage: hex::encode(&preimage_bytes), + signatures: None, + }; + + let proof = Proof { + amount: crate::Amount::from(1), + keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(), + secret, + c: crate::nuts::nut01::PublicKey::from_hex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(), + witness: Some(Witness::HTLCWitness(htlc_witness)), + dleq: None, + }; + + // Valid HTLC should verify successfully + assert!(proof.verify_htlc().is_ok()); + } + + /// Tests that verify_htlc correctly rejects an HTLC with a wrong preimage. + /// + /// This test is critical for security - if the verification function doesn't properly + /// check the preimage against the hash, an attacker could spend HTLC-locked funds + /// without knowing the correct preimage. + /// + /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or remove + /// the preimage verification logic. + #[test] + fn test_verify_htlc_wrong_preimage() { + // Create an HTLC secret with a specific hash (32 bytes) + let correct_preimage_bytes = [42u8; 32]; + let hash = Sha256Hash::hash(&correct_preimage_bytes); + let hash_str = hash.to_string(); + + let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::>>); + let secret: SecretString = nut10_secret.try_into().unwrap(); + + // Use a different preimage in the witness + let wrong_preimage_bytes = [99u8; 32]; // Different from correct preimage + let htlc_witness = HTLCWitness { + preimage: hex::encode(&wrong_preimage_bytes), + signatures: None, + }; + + let proof = Proof { + amount: crate::Amount::from(1), + keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(), + secret, + c: crate::nuts::nut01::PublicKey::from_hex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(), + witness: Some(Witness::HTLCWitness(htlc_witness)), + dleq: None, + }; + + // Verification should fail with wrong preimage + let result = proof.verify_htlc(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::Preimage)); + } + + /// Tests that verify_htlc correctly rejects an HTLC with an invalid hash format. + /// + /// This test ensures that the verification function properly validates that the + /// hash in the secret data is a valid SHA256 hash. + /// + /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or + /// remove the hash validation logic. + #[test] + fn test_verify_htlc_invalid_hash() { + // Create an HTLC secret with an invalid hash (not a valid hex string) + let invalid_hash = "not_a_valid_hash"; + + let nut10_secret = Nut10Secret::new( + Kind::HTLC, + invalid_hash.to_string(), + None::>>, + ); + let secret: SecretString = nut10_secret.try_into().unwrap(); + + let preimage_bytes = [42u8; 32]; // Valid 32-byte preimage + let htlc_witness = HTLCWitness { + preimage: hex::encode(&preimage_bytes), + signatures: None, + }; + + let proof = Proof { + amount: crate::Amount::from(1), + keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(), + secret, + c: crate::nuts::nut01::PublicKey::from_hex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(), + witness: Some(Witness::HTLCWitness(htlc_witness)), + dleq: None, + }; + + // Verification should fail with invalid hash + let result = proof.verify_htlc(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::InvalidHash)); + } + + /// Tests that verify_htlc correctly rejects an HTLC with the wrong witness type. + /// + /// This test ensures that the verification function checks that the witness is + /// of the correct type (HTLCWitness) and not some other witness type. + /// + /// Mutant testing: Catches mutations that replace verify_htlc with Ok(()) or + /// remove the witness type check. + #[test] + fn test_verify_htlc_wrong_witness_type() { + // Create an HTLC secret + let preimage = "test_preimage"; + let hash = Sha256Hash::hash(preimage.as_bytes()); + let hash_str = hash.to_string(); + + let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::>>); + let secret: SecretString = nut10_secret.try_into().unwrap(); + + // Create proof with wrong witness type (P2PKWitness instead of HTLCWitness) + let proof = Proof { + amount: crate::Amount::from(1), + keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(), + secret, + c: crate::nuts::nut01::PublicKey::from_hex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(), + witness: Some(Witness::P2PKWitness(super::super::nut11::P2PKWitness { + signatures: vec![], + })), + dleq: None, + }; + + // Verification should fail with wrong witness type + let result = proof.verify_htlc(); + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::IncorrectSecretKind)); + } + + /// Tests that add_preimage correctly adds a preimage to the proof. + /// + /// This test ensures that add_preimage actually modifies the witness and doesn't + /// just return without doing anything. + /// + /// Mutant testing: Catches mutations that replace add_preimage with () without + /// actually adding the preimage. + #[test] + fn test_add_preimage() { + let preimage_bytes = [42u8; 32]; // 32-byte preimage + let hash = Sha256Hash::hash(&preimage_bytes); + let hash_str = hash.to_string(); + + let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, None::>>); + let secret: SecretString = nut10_secret.try_into().unwrap(); + + let mut proof = Proof { + amount: crate::Amount::from(1), + keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(), + secret, + c: crate::nuts::nut01::PublicKey::from_hex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(), + witness: None, + dleq: None, + }; + + // Initially, witness should be None + assert!(proof.witness.is_none()); + + // Add preimage (hex-encoded) + let preimage_hex = hex::encode(&preimage_bytes); + proof.add_preimage(preimage_hex.clone()); + + // After adding, witness should be Some with HTLCWitness + assert!(proof.witness.is_some()); + if let Some(Witness::HTLCWitness(witness)) = &proof.witness { + assert_eq!(witness.preimage, preimage_hex); + } else { + panic!("Expected HTLCWitness"); + } + + // The proof with added preimage should verify successfully + assert!(proof.verify_htlc().is_ok()); + } + + /// Tests that verify_htlc requires BOTH locktime expired AND no refund keys for "anyone can spend". + /// + /// This test catches the mutation that replaces `&&` with `||` at line 83. + /// The logic should be: (locktime expired AND no refund keys) → anyone can spend. + /// If mutated to OR, it would allow spending when locktime passed even if refund keys exist. + /// + /// Mutant testing: Catches mutations that replace `&&` with `||` in the locktime check. + #[test] + fn test_htlc_locktime_and_refund_keys_logic() { + use crate::nuts::nut01::PublicKey; + use crate::nuts::nut11::Conditions; + + let preimage_bytes = [42u8; 32]; // 32-byte preimage + let hash = Sha256Hash::hash(&preimage_bytes); + let hash_str = hash.to_string(); + + // Test: Locktime has passed (locktime=1) but refund keys ARE present + // With correct logic (&&): Since refund_keys.is_none() is false, the "anyone can spend" + // path is NOT taken, so signature is required + // With mutation (||): Since locktime.lt(&unix_time()) is true, it WOULD take the + // "anyone can spend" path immediately - WRONG! + let refund_pubkey = PublicKey::from_hex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(); + + let conditions_with_refund = Conditions { + locktime: Some(1), // Locktime in past (current time is much larger) + pubkeys: None, + refund_keys: Some(vec![refund_pubkey]), // Refund key present + num_sigs: None, + sig_flag: crate::nuts::nut11::SigFlag::default(), + num_sigs_refund: None, + }; + + let nut10_secret = Nut10Secret::new(Kind::HTLC, hash_str, Some(conditions_with_refund)); + let secret: SecretString = nut10_secret.try_into().unwrap(); + + let htlc_witness = HTLCWitness { + preimage: hex::encode(&preimage_bytes), + signatures: None, // No signature provided + }; + + let proof = Proof { + amount: crate::Amount::from(1), + keyset_id: crate::nuts::nut02::Id::from_str("00deadbeef123456").unwrap(), + secret, + c: crate::nuts::nut01::PublicKey::from_hex( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2", + ) + .unwrap(), + witness: Some(Witness::HTLCWitness(htlc_witness)), + dleq: None, + }; + + // Should FAIL because even though locktime passed, refund keys are present + // so the "anyone can spend" shortcut shouldn't apply. A signature is required. + // With && this correctly fails. With || it would incorrectly pass. + let result = proof.verify_htlc(); + assert!( + result.is_err(), + "Should fail when locktime passed but refund keys present without signature" + ); + } +} diff --git a/crates/cashu/src/nuts/nut15.rs b/crates/cashu/src/nuts/nut15.rs index d3fb5a825..b5b5c5508 100644 --- a/crates/cashu/src/nuts/nut15.rs +++ b/crates/cashu/src/nuts/nut15.rs @@ -34,6 +34,13 @@ pub struct Settings { pub methods: Vec, } +impl Settings { + /// Check if methods is empty + pub fn is_empty(&self) -> bool { + self.methods.is_empty() + } +} + // Custom deserialization to handle both array and object formats impl<'de> Deserialize<'de> for Settings { fn deserialize(deserializer: D) -> Result @@ -89,4 +96,18 @@ mod tests { let json = serde_json::to_string(&settings).unwrap(); assert_eq!(json, r#"{"methods":[{"method":"bolt11","unit":"sat"}]}"#); } + + #[test] + fn test_nut15_settings_empty() { + let settings = Settings { methods: vec![] }; + assert!(settings.is_empty()); + + let settings_with_data = Settings { + methods: vec![MppMethodSettings { + method: PaymentMethod::Bolt11, + unit: CurrencyUnit::Sat, + }], + }; + assert!(!settings_with_data.is_empty()); + } } diff --git a/crates/cashu/src/nuts/nut17/mod.rs b/crates/cashu/src/nuts/nut17/mod.rs index 10bde715c..eb05926c5 100644 --- a/crates/cashu/src/nuts/nut17/mod.rs +++ b/crates/cashu/src/nuts/nut17/mod.rs @@ -1,14 +1,13 @@ //! Specific Subscription for the cdk crate use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; -#[cfg(feature = "mint")] -use uuid::Uuid; -#[cfg(feature = "mint")] use super::PublicKey; use crate::nuts::{ CurrencyUnit, MeltQuoteBolt11Response, MintQuoteBolt11Response, PaymentMethod, ProofState, }; +use crate::quote_id::QuoteIdError; +use crate::MintQuoteBolt12Response; pub mod ws; @@ -69,6 +68,21 @@ impl SupportedMethods { commands, } } + + /// Create [`SupportedMethods`] for Bolt12 with all supported commands + pub fn default_bolt12(unit: CurrencyUnit) -> Self { + let commands = vec![ + WsCommand::Bolt12MintQuote, + WsCommand::Bolt12MeltQuote, + WsCommand::ProofState, + ]; + + Self { + method: PaymentMethod::Bolt12, + unit, + commands, + } + } } /// WebSocket commands supported by the Cashu mint @@ -82,52 +96,61 @@ pub enum WsCommand { /// Command to request a Lightning payment for melting tokens #[serde(rename = "bolt11_melt_quote")] Bolt11MeltQuote, + /// Websocket support for Bolt12 Mint Quote + #[serde(rename = "bolt12_mint_quote")] + Bolt12MintQuote, + /// Websocket support for Bolt12 Melt Quote + #[serde(rename = "bolt12_melt_quote")] + Bolt12MeltQuote, /// Command to check the state of a proof #[serde(rename = "proof_state")] ProofState, } +impl From> for NotificationPayload +where + T: Clone, +{ + fn from(mint_quote: MintQuoteBolt12Response) -> NotificationPayload { + NotificationPayload::MintQuoteBolt12Response(mint_quote) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(bound = "T: Serialize + DeserializeOwned")] #[serde(untagged)] /// Subscription response -pub enum NotificationPayload { +pub enum NotificationPayload +where + T: Clone, +{ /// Proof State ProofState(ProofState), /// Melt Quote Bolt11 Response MeltQuoteBolt11Response(MeltQuoteBolt11Response), /// Mint Quote Bolt11 Response MintQuoteBolt11Response(MintQuoteBolt11Response), + /// Mint Quote Bolt12 Response + MintQuoteBolt12Response(MintQuoteBolt12Response), } -impl From for NotificationPayload { - fn from(proof_state: ProofState) -> NotificationPayload { - NotificationPayload::ProofState(proof_state) - } -} - -impl From> for NotificationPayload { - fn from(melt_quote: MeltQuoteBolt11Response) -> NotificationPayload { - NotificationPayload::MeltQuoteBolt11Response(melt_quote) - } -} - -impl From> for NotificationPayload { - fn from(mint_quote: MintQuoteBolt11Response) -> NotificationPayload { - NotificationPayload::MintQuoteBolt11Response(mint_quote) - } -} - -#[cfg(feature = "mint")] -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Hash, Serialize)] +#[serde(bound = "T: Serialize + DeserializeOwned")] /// A parsed notification -pub enum Notification { +pub enum NotificationId +where + T: Clone, +{ /// ProofState id is a Pubkey ProofState(PublicKey), - /// MeltQuote id is an Uuid - MeltQuoteBolt11(Uuid), - /// MintQuote id is an Uuid - MintQuoteBolt11(Uuid), + /// MeltQuote id is an QuoteId + MeltQuoteBolt11(T), + /// MintQuote id is an QuoteId + MintQuoteBolt11(T), + /// MintQuote id is an QuoteId + MintQuoteBolt12(T), + /// MintQuote id is an QuoteId + MeltQuoteBolt12(T), } /// Kind @@ -140,6 +163,8 @@ pub enum Kind { Bolt11MintQuote, /// Proof State ProofState, + /// Bolt 12 Mint Quote + Bolt12MintQuote, } impl AsRef for Params { @@ -151,10 +176,9 @@ impl AsRef for Params { /// Parsing error #[derive(thiserror::Error, Debug)] pub enum Error { - #[cfg(feature = "mint")] #[error("Uuid Error: {0}")] /// Uuid Error - Uuid(#[from] uuid::Error), + QuoteId(#[from] QuoteIdError), #[error("PublicKey Error: {0}")] /// PublicKey Error diff --git a/crates/cashu/src/nuts/nut17/ws.rs b/crates/cashu/src/nuts/nut17/ws.rs index a15c52ffc..f248454c6 100644 --- a/crates/cashu/src/nuts/nut17/ws.rs +++ b/crates/cashu/src/nuts/nut17/ws.rs @@ -36,7 +36,10 @@ pub struct WsUnsubscribeResponse { /// subscription #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(bound = "T: Serialize + DeserializeOwned, I: Serialize + DeserializeOwned")] -pub struct NotificationInner { +pub struct NotificationInner +where + T: Clone, +{ /// The subscription ID #[serde(rename = "subId")] pub sub_id: I, diff --git a/crates/cashu/src/nuts/nut18/mod.rs b/crates/cashu/src/nuts/nut18/mod.rs index a3e22db37..2e07a8e48 100644 --- a/crates/cashu/src/nuts/nut18/mod.rs +++ b/crates/cashu/src/nuts/nut18/mod.rs @@ -7,5 +7,5 @@ pub mod transport; pub use error::Error; pub use payment_request::{PaymentRequest, PaymentRequestBuilder, PaymentRequestPayload}; -pub use secret::{Nut10SecretRequest, SecretDataRequest}; +pub use secret::Nut10SecretRequest; pub use transport::{Transport, TransportBuilder, TransportType}; diff --git a/crates/cashu/src/nuts/nut18/payment_request.rs b/crates/cashu/src/nuts/nut18/payment_request.rs index 8c690efe8..0d8287266 100644 --- a/crates/cashu/src/nuts/nut18/payment_request.rs +++ b/crates/cashu/src/nuts/nut18/payment_request.rs @@ -3,7 +3,6 @@ //! use std::fmt; -use std::ops::Not; use std::str::FromStr; use bitcoin::base64::engine::{general_purpose, GeneralPurpose}; @@ -40,9 +39,10 @@ pub struct PaymentRequest { pub description: Option, /// Transport #[serde(rename = "t")] - #[serde(skip_serializing_if = "Option::is_none")] - pub transports: Option>, + #[serde(skip_serializing_if = "Vec::is_empty", default = "Vec::default")] + pub transports: Vec, /// Nut10 + #[serde(skip_serializing_if = "Option::is_none")] pub nut10: Option, } @@ -167,8 +167,6 @@ impl PaymentRequestBuilder { /// Build the PaymentRequest pub fn build(self) -> PaymentRequest { - let transports = self.transports.is_empty().not().then_some(self.transports); - PaymentRequest { payment_id: self.payment_id, amount: self.amount, @@ -176,7 +174,7 @@ impl PaymentRequestBuilder { single_use: self.single_use, mints: self.mints, description: self.description, - transports, + transports: self.transports, nut10: self.nut10, } } @@ -223,8 +221,7 @@ mod tests { ); assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat); - let transport = req.transports.unwrap(); - let transport = transport.first().unwrap(); + let transport = req.transports.first().unwrap(); let expected_transport = Transport {_type: TransportType::Nostr, target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])}; @@ -244,7 +241,7 @@ mod tests { .parse() .expect("valid mint url")]), description: None, - transports: Some(vec![transport.clone()]), + transports: vec![transport.clone()], nut10: None, }; @@ -261,8 +258,7 @@ mod tests { ); assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat); - let t = req.transports.unwrap(); - let t = t.first().unwrap(); + let t = req.transports.first().unwrap(); assert_eq!(&transport, t); } @@ -292,8 +288,7 @@ mod tests { assert_eq!(request.unit.clone().unwrap(), CurrencyUnit::Sat); assert_eq!(request.mints.clone().unwrap(), vec![mint_url]); - let t = request.transports.clone().unwrap(); - let t = t.first().unwrap(); + let t = request.transports.first().unwrap(); assert_eq!(&transport, t); // Test serialization and deserialization @@ -358,14 +353,8 @@ mod tests { // Check round-trip conversion assert_eq!(converted_back.kind, secret_request.kind); - assert_eq!( - converted_back.secret_data.data, - secret_request.secret_data.data - ); - assert_eq!( - converted_back.secret_data.tags, - secret_request.secret_data.tags - ); + assert_eq!(converted_back.data, secret_request.data); + assert_eq!(converted_back.tags, secret_request.tags); // Test in PaymentRequest builder let payment_request = PaymentRequest::builder() @@ -409,7 +398,7 @@ mod tests { let bolt11 = Bolt11Invoice::from_str(bolt11).unwrap(); let nut10 = SpendingConditions::HTLCConditions { - data: bolt11.payment_hash().clone(), + data: *bolt11.payment_hash(), conditions: None, }; @@ -458,9 +447,229 @@ mod tests { // Verify the P2PK data was preserved correctly if let Some(nut10_secret) = decoded_request.nut10 { assert_eq!(nut10_secret.kind, Kind::P2PK); - assert_eq!(nut10_secret.secret_data.data, pubkey_hex); + assert_eq!(nut10_secret.data, pubkey_hex); } else { panic!("NUT10 secret data missing in decoded payment request"); } } + + /// Test vectors from NUT-18 specification + /// https://github.com/cashubtc/nuts/blob/main/tests/18-tests.md + + #[test] + fn test_basic_payment_request() { + // Basic payment request with required fields + let json = r#"{ + "i": "b7a90176", + "a": 10, + "u": "sat", + "m": ["https://8333.space:3338"], + "t": [ + { + "t": "nostr", + "a": "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5", + "g": [["n", "17"]] + } + ] + }"#; + + let expected_encoded = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF3aHR0cHM6Ly84MzMzLnNwYWNlOjMzMzg="; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + let payment_request_cloned = payment_request.clone(); + + // Verify the payment request fields + assert_eq!( + payment_request_cloned.payment_id.as_ref().unwrap(), + "b7a90176" + ); + assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(10)); + assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + payment_request_cloned.mints.unwrap(), + vec![MintUrl::from_str("https://8333.space:3338").unwrap()] + ); + + let transport = payment_request.transports.first().unwrap(); + assert_eq!(transport._type, TransportType::Nostr); + assert_eq!(transport.target, "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5"); + assert_eq!( + transport.tags, + Some(vec![vec!["n".to_string(), "17".to_string()]]) + ); + + // Test encoding - the encoded form should match the expected output + let encoded = payment_request.to_string(); + + // For now, let's verify it can be decoded back correctly + let decoded = PaymentRequest::from_str(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "b7a90176"); + assert_eq!(decoded_from_spec.amount.unwrap(), Amount::from(10)); + assert_eq!(decoded_from_spec.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + decoded_from_spec.mints.unwrap(), + vec![MintUrl::from_str("https://8333.space:3338").unwrap()] + ); + } + + #[test] + fn test_nostr_transport_payment_request() { + // Nostr transport payment request with multiple mints + let json = r#"{ + "i": "f92a51b8", + "a": 100, + "u": "sat", + "m": ["https://mint1.example.com", "https://mint2.example.com"], + "t": [ + { + "t": "nostr", + "a": "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq28spj3", + "g": [["n", "17"], ["n", "9735"]] + } + ] + }"#; + + let expected_encoded = "creqApWF0gaNhdGVub3N0cmFheD9ucHViMXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXEyOHNwajNhZ4KCYW5iMTeCYW5kOTczNWFpaGY5MmE1MWI4YWEYZGF1Y3NhdGFtgngZaHR0cHM6Ly9taW50MS5leGFtcGxlLmNvbXgZaHR0cHM6Ly9taW50Mi5leGFtcGxlLmNvbQ=="; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + let payment_request_cloned = payment_request.clone(); + + // Verify the payment request fields + assert_eq!( + payment_request_cloned.payment_id.as_ref().unwrap(), + "f92a51b8" + ); + assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(100)); + assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + payment_request_cloned.mints.unwrap(), + vec![ + MintUrl::from_str("https://mint1.example.com").unwrap(), + MintUrl::from_str("https://mint2.example.com").unwrap() + ] + ); + + let transport = payment_request_cloned.transports.first().unwrap(); + assert_eq!(transport._type, TransportType::Nostr); + assert_eq!( + transport.target, + "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq28spj3" + ); + assert_eq!( + transport.tags, + Some(vec![ + vec!["n".to_string(), "17".to_string()], + vec!["n".to_string(), "9735".to_string()] + ]) + ); + + // Test round-trip serialization + let encoded = payment_request.to_string(); + let decoded = PaymentRequest::from_str(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "f92a51b8"); + } + + #[test] + fn test_minimal_payment_request() { + // Minimal payment request with only required fields + let json = r#"{ + "i": "7f4a2b39", + "u": "sat", + "m": ["https://mint.example.com"] + }"#; + + let expected_encoded = + "creqAo2FpaDdmNGEyYjM5YXVjc2F0YW2BeBhodHRwczovL21pbnQuZXhhbXBsZS5jb20="; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + let payment_request_cloned = payment_request.clone(); + + // Verify the payment request fields + assert_eq!( + payment_request_cloned.payment_id.as_ref().unwrap(), + "7f4a2b39" + ); + assert_eq!(payment_request_cloned.amount, None); + assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + payment_request_cloned.mints.unwrap(), + vec![MintUrl::from_str("https://mint.example.com").unwrap()] + ); + assert_eq!(payment_request_cloned.transports, vec![]); + + // Test round-trip serialization + let encoded = payment_request.to_string(); + let decoded = PaymentRequest::from_str(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "7f4a2b39"); + } + + #[test] + fn test_nut10_locking_payment_request() { + // Payment request with NUT-10 P2PK locking + let json = r#"{ + "i": "c9e45d2a", + "a": 500, + "u": "sat", + "m": ["https://mint.example.com"], + "nut10": { + "k": "P2PK", + "d": "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331", + "t": [["timeout", "3600"]] + } + }"#; + + let expected_encoded = "creqApWFpaGM5ZTQ1ZDJhYWEZAfRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWVudXQxMKNha2RQMlBLYWR4QjAyYzNiNWJiMjdlMzYxNDU3YzkyZDkzZDc4ZGQ3M2QzZDUzNzMyMTEwYjJjZmU4YjUwZmJjMGFiYzYxNWU5YzMzMWF0gYJndGltZW91dGQzNjAw"; + + // Parse the JSON into a PaymentRequest + let payment_request: PaymentRequest = serde_json::from_str(json).unwrap(); + let payment_request_cloned = payment_request.clone(); + + // Verify the payment request fields + assert_eq!( + payment_request_cloned.payment_id.as_ref().unwrap(), + "c9e45d2a" + ); + assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(500)); + assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat); + assert_eq!( + payment_request_cloned.mints.unwrap(), + vec![MintUrl::from_str("https://mint.example.com").unwrap()] + ); + + // Test NUT-10 locking + let nut10 = payment_request_cloned.nut10.unwrap(); + assert_eq!(nut10.kind, Kind::P2PK); + assert_eq!( + nut10.data, + "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331" + ); + assert_eq!( + nut10.tags, + Some(vec![vec!["timeout".to_string(), "3600".to_string()]]) + ); + + // Test round-trip serialization + let encoded = payment_request.to_string(); + let decoded = PaymentRequest::from_str(&encoded).unwrap(); + assert_eq!(payment_request, decoded); + + // Test decoding the expected encoded string + let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap(); + assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "c9e45d2a"); + } } diff --git a/crates/cashu/src/nuts/nut18/secret.rs b/crates/cashu/src/nuts/nut18/secret.rs index 24b16f161..c3fc1c2f5 100644 --- a/crates/cashu/src/nuts/nut18/secret.rs +++ b/crates/cashu/src/nuts/nut18/secret.rs @@ -1,31 +1,21 @@ //! Secret types for NUT-18: Payment Requests - -use std::fmt; - -use serde::de::{self, Deserializer, SeqAccess, Visitor}; -use serde::ser::{SerializeTuple, Serializer}; use serde::{Deserialize, Serialize}; use crate::nuts::nut10::Kind; use crate::nuts::{Nut10Secret, SpendingConditions}; -/// Secret Data without nonce for payment requests -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct SecretDataRequest { - /// Expresses the spending condition specific to each kind - pub data: String, - /// Additional data committed to and can be used for feature extensions - #[serde(skip_serializing_if = "Option::is_none")] - pub tags: Option>>, -} - /// Nut10Secret without nonce for payment requests -#[derive(Debug, Clone, Hash, PartialEq, Eq)] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct Nut10SecretRequest { /// Kind of the spending condition + #[serde(rename = "k")] pub kind: Kind, - /// Secret Data without nonce - pub secret_data: SecretDataRequest, + /// Secret data + #[serde(rename = "d")] + pub data: String, + /// Additional data committed to and can be used for feature extensions + #[serde(rename = "t", skip_serializing_if = "Option::is_none")] + pub tags: Option>>, } impl Nut10SecretRequest { @@ -35,32 +25,27 @@ impl Nut10SecretRequest { S: Into, V: Into>>, { - let secret_data = SecretDataRequest { + Self { + kind, data: data.into(), tags: tags.map(|v| v.into()), - }; - - Self { kind, secret_data } + } } } impl From for Nut10SecretRequest { fn from(secret: Nut10Secret) -> Self { - let secret_data = SecretDataRequest { - data: secret.secret_data().data().to_string(), - tags: secret.secret_data().tags().cloned(), - }; - Self { kind: secret.kind(), - secret_data, + data: secret.secret_data().data().to_string(), + tags: secret.secret_data().tags().cloned(), } } } impl From for Nut10Secret { fn from(value: Nut10SecretRequest) -> Self { - Self::new(value.kind, value.secret_data.data, value.secret_data.tags) + Self::new(value.kind, value.data, value.tags) } } @@ -77,61 +62,67 @@ impl From for Nut10SecretRequest { } } -impl Serialize for Nut10SecretRequest { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - // Create a tuple representing the struct fields - let secret_tuple = (&self.kind, &self.secret_data); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nut10_secret_request_serialization() { + let request = Nut10SecretRequest::new( + Kind::P2PK, + "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198", + Some(vec![vec!["key".to_string(), "value".to_string()]]), + ); - // Serialize the tuple as a JSON array - let mut s = serializer.serialize_tuple(2)?; + let json = serde_json::to_string(&request).unwrap(); - s.serialize_element(&secret_tuple.0)?; - s.serialize_element(&secret_tuple.1)?; - s.end() + // Verify json has abbreviated field names + assert!(json.contains(r#""k":"P2PK""#)); + assert!(json.contains(r#""d":"026562"#)); + assert!(json.contains(r#""t":[["key","#)); } -} -// Custom visitor for deserializing Secret -struct SecretVisitor; + #[test] + fn test_roundtrip_serialization() { + let original = Nut10SecretRequest { + kind: Kind::P2PK, + data: "test_data".into(), + tags: Some(vec![vec!["key".to_string(), "value".to_string()]]), + }; -impl<'de> Visitor<'de> for SecretVisitor { - type Value = Nut10SecretRequest; + let json = serde_json::to_string(&original).unwrap(); + let decoded: Nut10SecretRequest = serde_json::from_str(&json).unwrap(); - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a tuple with two elements: [Kind, SecretData]") + assert_eq!(original, decoded); } - fn visit_seq(self, mut seq: A) -> Result - where - A: SeqAccess<'de>, - { - // Deserialize the kind (first element) - let kind = seq - .next_element()? - .ok_or_else(|| de::Error::invalid_length(0, &self))?; - - // Deserialize the secret_data (second element) - let secret_data = seq - .next_element()? - .ok_or_else(|| de::Error::invalid_length(1, &self))?; - - // Make sure there are no additional elements - if seq.next_element::()?.is_some() { - return Err(de::Error::invalid_length(3, &self)); - } + #[test] + fn test_from_nut10_secret() { + let secret = Nut10Secret::new( + Kind::P2PK, + "test_data", + Some(vec![vec!["key".to_string(), "value".to_string()]]), + ); + + let request: Nut10SecretRequest = secret.clone().into(); - Ok(Nut10SecretRequest { kind, secret_data }) + assert_eq!(request.kind, secret.kind()); + assert_eq!(request.data, secret.secret_data().data()); + assert_eq!(request.tags, secret.secret_data().tags().cloned()); } -} -impl<'de> Deserialize<'de> for Nut10SecretRequest { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_seq(SecretVisitor) + #[test] + fn test_into_nut10_secret() { + let request = Nut10SecretRequest { + kind: Kind::HTLC, + data: "test_hash".into(), + tags: None, + }; + + let secret: Nut10Secret = request.clone().into(); + + assert_eq!(secret.kind(), request.kind); + assert_eq!(secret.secret_data().data(), request.data); + assert_eq!(secret.secret_data().tags(), request.tags.as_ref()); } } diff --git a/crates/cashu/src/nuts/nut19.rs b/crates/cashu/src/nuts/nut19.rs index 6434fdf96..7f34c41cb 100644 --- a/crates/cashu/src/nuts/nut19.rs +++ b/crates/cashu/src/nuts/nut19.rs @@ -32,7 +32,7 @@ impl CachedEndpoint { } /// HTTP method -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum Method { @@ -43,7 +43,7 @@ pub enum Method { } /// Route path -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum Path { /// Bolt11 Mint @@ -55,4 +55,10 @@ pub enum Path { /// Swap #[serde(rename = "/v1/swap")] Swap, + /// Bolt12 Mint + #[serde(rename = "/v1/mint/bolt12")] + MintBolt12, + /// Bolt12 Melt + #[serde(rename = "/v1/melt/bolt12")] + MeltBolt12, } diff --git a/crates/cashu/src/nuts/nut20.rs b/crates/cashu/src/nuts/nut20.rs index 4546dc73f..3f5752ada 100644 --- a/crates/cashu/src/nuts/nut20.rs +++ b/crates/cashu/src/nuts/nut20.rs @@ -74,8 +74,6 @@ where #[cfg(test)] mod tests { - use uuid::Uuid; - use super::*; #[test] @@ -111,8 +109,11 @@ mod tests { assert_eq!(expected_msg_to_sign, request_msg_to_sign); } + #[cfg(feature = "mint")] #[test] fn test_valid_signature() { + use uuid::Uuid; + let pubkey = PublicKey::from_hex( "03d56ce4e446a85bbdaa547b4ec2b073d40ff802831352b8272b7dd7a4de5a7cac", ) diff --git a/crates/cashu/src/nuts/nut23.rs b/crates/cashu/src/nuts/nut23.rs index e80f480d5..452779a24 100644 --- a/crates/cashu/src/nuts/nut23.rs +++ b/crates/cashu/src/nuts/nut23.rs @@ -5,13 +5,12 @@ use std::str::FromStr; use lightning_invoice::Bolt11Invoice; use serde::de::DeserializeOwned; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; +use serde::{Deserialize, Serialize}; use thiserror::Error; -#[cfg(feature = "mint")] -use uuid::Uuid; use super::{BlindSignature, CurrencyUnit, MeltQuoteState, Mpp, PublicKey}; +#[cfg(feature = "mint")] +use crate::quote_id::QuoteId; use crate::Amount; /// NUT023 Error @@ -54,10 +53,6 @@ pub enum QuoteState { Unpaid, /// Quote has been paid and wallet can mint Paid, - /// Minting is in progress - /// **Note:** This state is to be used internally but is not part of the - /// nut. - Pending, /// ecash issued for quote Issued, } @@ -67,7 +62,6 @@ impl fmt::Display for QuoteState { match self { Self::Unpaid => write!(f, "UNPAID"), Self::Paid => write!(f, "PAID"), - Self::Pending => write!(f, "PENDING"), Self::Issued => write!(f, "ISSUED"), } } @@ -78,7 +72,6 @@ impl FromStr for QuoteState { fn from_str(state: &str) -> Result { match state { - "PENDING" => Ok(Self::Pending), "PAID" => Ok(Self::Paid), "UNPAID" => Ok(Self::Unpaid), "ISSUED" => Ok(Self::Issued), @@ -126,8 +119,8 @@ impl MintQuoteBolt11Response { } #[cfg(feature = "mint")] -impl From> for MintQuoteBolt11Response { - fn from(value: MintQuoteBolt11Response) -> Self { +impl From> for MintQuoteBolt11Response { + fn from(value: MintQuoteBolt11Response) -> Self { Self { quote: value.quote.to_string(), request: value.request, @@ -245,9 +238,9 @@ impl MeltQuoteBolt11Request { } /// Melt quote response [NUT-05] -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] -#[serde(bound = "Q: Serialize")] +#[serde(bound = "Q: Serialize + DeserializeOwned")] pub struct MeltQuoteBolt11Response { /// Quote Id pub quote: Q, @@ -255,10 +248,6 @@ pub struct MeltQuoteBolt11Response { pub amount: Amount, /// The fee reserve that is required pub fee_reserve: Amount, - /// Whether the request haas be paid - // TODO: To be deprecated - /// Deprecated - pub paid: Option, /// Quote State pub state: MeltQuoteState, /// Unix timestamp until the quote is valid @@ -287,7 +276,6 @@ impl MeltQuoteBolt11Response { quote: self.quote.to_string(), amount: self.amount, fee_reserve: self.fee_reserve, - paid: self.paid, state: self.state, expiry: self.expiry, payment_preimage: self.payment_preimage, @@ -299,13 +287,12 @@ impl MeltQuoteBolt11Response { } #[cfg(feature = "mint")] -impl From> for MeltQuoteBolt11Response { - fn from(value: MeltQuoteBolt11Response) -> Self { +impl From> for MeltQuoteBolt11Response { + fn from(value: MeltQuoteBolt11Response) -> Self { Self { quote: value.quote.to_string(), amount: value.amount, fee_reserve: value.fee_reserve, - paid: value.paid, state: value.state, expiry: value.expiry, payment_preimage: value.payment_preimage, @@ -315,97 +302,3 @@ impl From> for MeltQuoteBolt11Response { } } } - -// A custom deserializer is needed until all mints -// update some will return without the required state. -impl<'de, Q: DeserializeOwned> Deserialize<'de> for MeltQuoteBolt11Response { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let value = Value::deserialize(deserializer)?; - - let quote: Q = serde_json::from_value( - value - .get("quote") - .ok_or(serde::de::Error::missing_field("quote"))? - .clone(), - ) - .map_err(|_| serde::de::Error::custom("Invalid quote if string"))?; - - let amount = value - .get("amount") - .ok_or(serde::de::Error::missing_field("amount"))? - .as_u64() - .ok_or(serde::de::Error::missing_field("amount"))?; - let amount = Amount::from(amount); - - let fee_reserve = value - .get("fee_reserve") - .ok_or(serde::de::Error::missing_field("fee_reserve"))? - .as_u64() - .ok_or(serde::de::Error::missing_field("fee_reserve"))?; - - let fee_reserve = Amount::from(fee_reserve); - - let paid: Option = value.get("paid").and_then(|p| p.as_bool()); - - let state: Option = value - .get("state") - .and_then(|s| serde_json::from_value(s.clone()).ok()); - - let (state, paid) = match (state, paid) { - (None, None) => return Err(serde::de::Error::custom("State or paid must be defined")), - (Some(state), _) => { - let state: MeltQuoteState = MeltQuoteState::from_str(&state) - .map_err(|_| serde::de::Error::custom("Unknown state"))?; - let paid = state == MeltQuoteState::Paid; - - (state, paid) - } - (None, Some(paid)) => { - let state = if paid { - MeltQuoteState::Paid - } else { - MeltQuoteState::Unpaid - }; - (state, paid) - } - }; - - let expiry = value - .get("expiry") - .ok_or(serde::de::Error::missing_field("expiry"))? - .as_u64() - .ok_or(serde::de::Error::missing_field("expiry"))?; - - let payment_preimage: Option = value - .get("payment_preimage") - .and_then(|p| serde_json::from_value(p.clone()).ok()); - - let change: Option> = value - .get("change") - .and_then(|b| serde_json::from_value(b.clone()).ok()); - - let request: Option = value - .get("request") - .and_then(|r| serde_json::from_value(r.clone()).ok()); - - let unit: Option = value - .get("unit") - .and_then(|u| serde_json::from_value(u.clone()).ok()); - - Ok(Self { - quote, - amount, - fee_reserve, - paid: Some(paid), - state, - expiry, - payment_preimage, - change, - request, - unit, - }) - } -} diff --git a/crates/cashu/src/nuts/nut25.rs b/crates/cashu/src/nuts/nut25.rs new file mode 100644 index 000000000..9c9640087 --- /dev/null +++ b/crates/cashu/src/nuts/nut25.rs @@ -0,0 +1,104 @@ +//! Bolt12 +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{CurrencyUnit, MeltOptions, PublicKey}; +#[cfg(feature = "mint")] +use crate::quote_id::QuoteId; +use crate::Amount; + +/// NUT18 Error +#[derive(Debug, Error)] +pub enum Error { + /// Unknown Quote State + #[error("Unknown quote state")] + UnknownState, + /// Amount overflow + #[error("Amount Overflow")] + AmountOverflow, + /// Publickey not defined + #[error("Publickey not defined")] + PublickeyUndefined, +} + +/// Mint quote request [NUT-24] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct MintQuoteBolt12Request { + /// Amount + pub amount: Option, + /// Unit wallet would like to pay with + pub unit: CurrencyUnit, + /// Memo to create the invoice with + pub description: Option, + /// Pubkey + pub pubkey: PublicKey, +} + +/// Mint quote response [NUT-24] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +#[serde(bound = "Q: Serialize + for<'a> Deserialize<'a>")] +pub struct MintQuoteBolt12Response { + /// Quote Id + pub quote: Q, + /// Payment request to fulfil + pub request: String, + /// Amount + pub amount: Option, + /// Unit wallet would like to pay with + pub unit: CurrencyUnit, + /// Unix timestamp until the quote is valid + pub expiry: Option, + /// Pubkey + pub pubkey: PublicKey, + /// Amount that has been paid + pub amount_paid: Amount, + /// Amount that has been issued + pub amount_issued: Amount, +} + +#[cfg(feature = "mint")] +impl MintQuoteBolt12Response { + /// Convert the MintQuote with a quote type Q to a String + pub fn to_string_id(&self) -> MintQuoteBolt12Response { + MintQuoteBolt12Response { + quote: self.quote.to_string(), + request: self.request.clone(), + amount: self.amount, + unit: self.unit.clone(), + expiry: self.expiry, + pubkey: self.pubkey, + amount_paid: self.amount_paid, + amount_issued: self.amount_issued, + } + } +} + +#[cfg(feature = "mint")] +impl From> for MintQuoteBolt12Response { + fn from(value: MintQuoteBolt12Response) -> Self { + Self { + quote: value.quote.to_string(), + request: value.request, + expiry: value.expiry, + amount_paid: value.amount_paid, + amount_issued: value.amount_issued, + pubkey: value.pubkey, + amount: value.amount, + unit: value.unit, + } + } +} + +/// Melt quote request [NUT-18] +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct MeltQuoteBolt12Request { + /// Bolt12 invoice to be paid + pub request: String, + /// Unit wallet would like to pay with + pub unit: CurrencyUnit, + /// Payment Options + pub options: Option, +} diff --git a/crates/cashu/src/quote_id.rs b/crates/cashu/src/quote_id.rs new file mode 100644 index 000000000..4b14e21fc --- /dev/null +++ b/crates/cashu/src/quote_id.rs @@ -0,0 +1,100 @@ +//! Quote ID. The specifications only define a string but CDK uses Uuid, so we use an enum to port compatibility. +use std::fmt; +use std::str::FromStr; + +use bitcoin::base64::engine::general_purpose; +use bitcoin::base64::Engine as _; +use serde::{de, Deserialize, Deserializer, Serialize}; +use thiserror::Error; +use uuid::Uuid; + +/// Invalid UUID +#[derive(Debug, Error)] +pub enum QuoteIdError { + /// UUID Error + #[error("invalid UUID: {0}")] + Uuid(#[from] uuid::Error), + /// Invalid base64 + #[error("invalid base64")] + Base64, + /// Invalid quote ID + #[error("neither a valid UUID nor a valid base64 string")] + InvalidQuoteId, +} + +/// Mint Quote ID +#[derive(Serialize, Debug, Clone, PartialEq, PartialOrd, Ord, Eq, Hash)] +#[serde(untagged)] +pub enum QuoteId { + /// (Nutshell) base64 quote ID + BASE64(String), + /// UUID quote ID + UUID(Uuid), +} + +impl QuoteId { + /// Create a new UUID-based MintQuoteId + pub fn new_uuid() -> Self { + Self::UUID(Uuid::new_v4()) + } +} + +impl From for QuoteId { + fn from(uuid: Uuid) -> Self { + Self::UUID(uuid) + } +} + +impl fmt::Display for QuoteId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + QuoteId::BASE64(s) => write!(f, "{s}"), + QuoteId::UUID(u) => write!(f, "{}", u.hyphenated()), + } + } +} + +impl FromStr for QuoteId { + type Err = QuoteIdError; + + fn from_str(s: &str) -> Result { + // Try UUID first + if let Ok(u) = Uuid::parse_str(s) { + return Ok(QuoteId::UUID(u)); + } + + // Try base64: decode, then re-encode and compare to ensure canonical form + // Use the standard (URL/filename safe or standard) depending on your needed alphabet. + // Here we use standard base64. + match general_purpose::URL_SAFE.decode(s) { + Ok(_bytes) => Ok(QuoteId::BASE64(s.to_string())), + Err(_) => Err(QuoteIdError::InvalidQuoteId), + } + } +} + +impl<'de> Deserialize<'de> for QuoteId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + // Deserialize as plain string first + let s = String::deserialize(deserializer)?; + + // Try UUID first + if let Ok(u) = Uuid::parse_str(&s) { + return Ok(QuoteId::UUID(u)); + } + + if general_purpose::URL_SAFE.decode(&s).is_ok() { + return Ok(QuoteId::BASE64(s)); + } + + // Neither matched — return a helpful error + Err(de::Error::custom(format!( + "QuoteId must be either a UUID (e.g. {}) or a valid base64 string; got: {}", + Uuid::nil(), + s + ))) + } +} diff --git a/crates/cashu/src/secret.rs b/crates/cashu/src/secret.rs index 08198d19c..6fe61723f 100644 --- a/crates/cashu/src/secret.rs +++ b/crates/cashu/src/secret.rs @@ -6,6 +6,7 @@ use std::str::FromStr; use bitcoin::secp256k1::rand::{self, RngCore}; use serde::{Deserialize, Serialize}; use thiserror::Error; +use zeroize::Zeroize; use crate::util::hex; @@ -121,6 +122,12 @@ impl TryFrom for crate::nuts::nut10::Secret { } } +impl Drop for Secret { + fn drop(&mut self) { + self.0.zeroize(); + } +} + impl TryFrom<&Secret> for crate::nuts::nut10::Secret { type Error = Error; diff --git a/crates/cashu/src/util/mod.rs b/crates/cashu/src/util/mod.rs index ae86f8a80..8afa9f64a 100644 --- a/crates/cashu/src/util/mod.rs +++ b/crates/cashu/src/util/mod.rs @@ -1,18 +1,10 @@ //! Cashu utils -#[cfg(not(target_arch = "wasm32"))] -use std::time::{SystemTime, UNIX_EPOCH}; +pub mod hex; use bitcoin::secp256k1::{rand, All, Secp256k1}; use once_cell::sync::Lazy; - -pub mod hex; - -#[cfg(target_arch = "wasm32")] -use instant::SystemTime; - -#[cfg(target_arch = "wasm32")] -const UNIX_EPOCH: SystemTime = SystemTime::UNIX_EPOCH; +use web_time::{SystemTime, UNIX_EPOCH}; /// Secp256k1 global context pub static SECP256K1: Lazy> = Lazy::new(|| { @@ -46,7 +38,7 @@ pub enum CborError { /// /// See pub fn serialize_to_cbor_diag(data: &T) -> Result { - let mut cbor_buffer = Vec::new(); + let mut cbor_buffer = Vec::::new(); ciborium::ser::into_writer(data, &mut cbor_buffer)?; let diag = cbor_diag::parse_bytes(&cbor_buffer)?; diff --git a/crates/cdk-axum/Cargo.toml b/crates/cdk-axum/Cargo.toml index 76fd99f00..13ee55df3 100644 --- a/crates/cdk-axum/Cargo.toml +++ b/crates/cdk-axum/Cargo.toml @@ -15,7 +15,7 @@ default = ["auth"] redis = ["dep:redis"] swagger = ["cdk/swagger", "dep:utoipa"] auth = ["cdk/auth"] - +prometheus = ["dep:cdk-prometheus"] [dependencies] anyhow.workspace = true async-trait.workspace = true @@ -27,6 +27,7 @@ tokio.workspace = true tracing.workspace = true utoipa = { workspace = true, optional = true } futures.workspace = true +cdk-prometheus = { workspace = true , optional = true} moka = { version = "0.12.10", features = ["future"] } serde_json.workspace = true paste = "1.0.15" @@ -36,3 +37,8 @@ sha2 = "0.10.8" redis = { version = "0.31.0", features = [ "tokio-rustls-comp", ], optional = true } + +cdk-common = { workspace = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +uuid = { workspace = true, features = ["js"] } diff --git a/crates/cdk-axum/src/bolt12_router.rs b/crates/cdk-axum/src/bolt12_router.rs new file mode 100644 index 000000000..5017e44ff --- /dev/null +++ b/crates/cdk-axum/src/bolt12_router.rs @@ -0,0 +1,213 @@ +use anyhow::Result; +use axum::extract::{Json, Path, State}; +use axum::response::Response; +#[cfg(feature = "swagger")] +use cdk::error::ErrorResponse; +use cdk::mint::QuoteId; +#[cfg(feature = "auth")] +use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; +use cdk::nuts::{ + MeltQuoteBolt11Response, MeltQuoteBolt12Request, MeltRequest, MintQuoteBolt12Request, + MintQuoteBolt12Response, MintRequest, MintResponse, +}; +use paste::paste; +use tracing::instrument; + +#[cfg(feature = "auth")] +use crate::auth::AuthHeader; +use crate::{into_response, post_cache_wrapper, MintState}; + +post_cache_wrapper!(post_mint_bolt12, MintRequest, MintResponse); +post_cache_wrapper!( + post_melt_bolt12, + MeltRequest, + MeltQuoteBolt11Response +); + +#[cfg_attr(feature = "swagger", utoipa::path( + get, + context_path = "/v1", + path = "/mint/quote/bolt12", + responses( + (status = 200, description = "Successful response", body = MintQuoteBolt12Response, content_type = "application/json") + ) +))] +/// Get mint bolt12 quote +#[instrument(skip_all, fields(amount = ?payload.amount))] +pub async fn post_mint_bolt12_quote( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Json(payload): Json, +) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt12), + ) + .await + .map_err(into_response)?; + } + + let quote = state + .mint + .get_mint_quote(payload.into()) + .await + .map_err(into_response)?; + + Ok(Json(quote.try_into().map_err(into_response)?)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + get, + context_path = "/v1", + path = "/mint/quote/bolt12/{quote_id}", + params( + ("quote_id" = String, description = "The quote ID"), + ), + responses( + (status = 200, description = "Successful response", body = MintQuoteBolt12Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Get mint bolt12 quote +#[instrument(skip_all, fields(quote_id = ?quote_id))] +pub async fn get_check_mint_bolt12_quote( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Path(quote_id): Path, +) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt12), + ) + .await + .map_err(into_response)?; + } + + let quote = state + .mint + .check_mint_quote("e_id) + .await + .map_err(into_response)?; + + Ok(Json(quote.try_into().map_err(into_response)?)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1", + path = "/mint/bolt12", + request_body(content = MintRequest, description = "Request params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MintResponse, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Request a quote for melting tokens +#[instrument(skip_all, fields(quote_id = ?payload.quote))] +pub async fn post_mint_bolt12( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Json(payload): Json>, +) -> Result, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt12), + ) + .await + .map_err(into_response)?; + } + + let res = state + .mint + .process_mint_request(payload) + .await + .map_err(|err| { + tracing::error!("Could not process mint: {}", err); + into_response(err) + })?; + + Ok(Json(res)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1", + path = "/melt/quote/bolt12", + request_body(content = MeltQuoteBolt12Request, description = "Quote params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +pub async fn post_melt_bolt12_quote( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Json(payload): Json, +) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt12), + ) + .await + .map_err(into_response)?; + } + + let quote = state + .mint + .get_melt_quote(payload.into()) + .await + .map_err(into_response)?; + + Ok(Json(quote)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1", + path = "/melt/bolt12", + request_body(content = MeltRequest, description = "Melt params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MeltQuoteBolt11Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Melt tokens for a Bitcoin payment that the mint will make for the user in exchange +/// +/// Requests tokens to be destroyed and sent out via Lightning. +pub async fn post_melt_bolt12( + #[cfg(feature = "auth")] auth: AuthHeader, + State(state): State, + Json(payload): Json>, +) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt12), + ) + .await + .map_err(into_response)?; + } + + let res = state.mint.melt(&payload).await.map_err(into_response)?; + + Ok(Json(res)) +} diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index ec108e36c..0d0be6b5f 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -17,8 +17,11 @@ use cache::HttpCache; use cdk::mint::Mint; use router_handlers::*; +mod metrics; + #[cfg(feature = "auth")] mod auth; +mod bolt12_router; pub mod cache; mod router_handlers; mod ws; @@ -46,12 +49,19 @@ mod swagger_imports { MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintQuoteBolt11Request, MintQuoteBolt11Response, }; + #[cfg(feature = "auth")] + pub use cdk::nuts::MintAuthRequest; pub use cdk::nuts::{nut04, nut05, nut15, MeltQuoteState, MintQuoteState}; } #[cfg(feature = "swagger")] use swagger_imports::*; +use crate::bolt12_router::{ + cache_post_melt_bolt12, cache_post_mint_bolt12, get_check_mint_bolt12_quote, + post_melt_bolt12_quote, post_mint_bolt12_quote, +}; + /// CDK Mint State #[derive(Clone)] pub struct MintState { @@ -60,9 +70,47 @@ pub struct MintState { } #[cfg(feature = "swagger")] -#[derive(utoipa::OpenApi)] -#[openapi( - components(schemas( +macro_rules! define_api_doc { + ( + schemas: [$($schema:ty),* $(,)?] + $(, auth_schemas: [$($auth_schema:ty),* $(,)?])? + $(, paths: [$($path:path),* $(,)?])? + $(, auth_paths: [$($auth_path:path),* $(,)?])? + ) => { + #[derive(utoipa::OpenApi)] + #[openapi( + components(schemas( + $($schema,)* + $($($auth_schema,)*)? + )), + info(description = "Cashu CDK mint APIs", title = "cdk-mintd"), + paths( + get_keys, + get_keyset_pubkeys, + get_keysets, + get_mint_info, + post_mint_bolt11_quote, + get_check_mint_bolt11_quote, + post_mint_bolt11, + post_melt_bolt11_quote, + get_check_melt_bolt11_quote, + post_melt_bolt11, + post_swap, + post_check, + post_restore + $(,$($path,)*)? + $(,$($auth_path,)*)? + ) + )] + /// Swagger api docs + pub struct ApiDoc; + }; +} + +// Configuration without auth feature +#[cfg(all(feature = "swagger", not(feature = "auth")))] +define_api_doc! { + schemas: [ Amount, BlindedMessage, BlindSignature, @@ -112,36 +160,85 @@ pub struct MintState { nut04::Settings, nut05::Settings, nut15::Settings - )), - info(description = "Cashu CDK mint APIs", title = "cdk-mintd",), - paths( - get_keys, - get_keyset_pubkeys, - get_keysets, - get_mint_info, - post_mint_bolt11_quote, - get_check_mint_bolt11_quote, - post_mint_bolt11, - post_melt_bolt11_quote, - get_check_melt_bolt11_quote, - post_melt_bolt11, - post_swap, - post_check, - post_restore - ) -)] -/// OpenAPI spec for the mint's v1 APIs -pub struct ApiDocV1; + ] +} + +// Configuration with auth feature +#[cfg(all(feature = "swagger", feature = "auth"))] +define_api_doc! { + schemas: [ + Amount, + BlindedMessage, + BlindSignature, + BlindSignatureDleq, + CheckStateRequest, + CheckStateResponse, + ContactInfo, + CurrencyUnit, + ErrorCode, + ErrorResponse, + HTLCWitness, + Keys, + KeysResponse, + KeysetResponse, + KeySet, + KeySetInfo, + MeltRequest, + MeltQuoteBolt11Request, + MeltQuoteBolt11Response, + MeltQuoteState, + MeltMethodSettings, + MintRequest, + MintResponse, + MintInfo, + MintQuoteBolt11Request, + MintQuoteBolt11Response, + MintQuoteState, + MintMethodSettings, + MintVersion, + Mpp, + MppMethodSettings, + Nuts, + P2PKWitness, + PaymentMethod, + Proof, + ProofDleq, + ProofState, + PublicKey, + RestoreRequest, + RestoreResponse, + SecretKey, + State, + SupportedSettings, + SwapRequest, + SwapResponse, + Witness, + nut04::Settings, + nut05::Settings, + nut15::Settings + ], + auth_schemas: [MintAuthRequest], + auth_paths: [ + crate::auth::get_auth_keysets, + crate::auth::get_blind_auth_keys, + crate::auth::post_mint_auth + ] +} /// Create mint [`Router`] with required endpoints for cashu mint with the default cache -pub async fn create_mint_router(mint: Arc) -> Result { - create_mint_router_with_custom_cache(mint, Default::default()).await +pub async fn create_mint_router(mint: Arc, include_bolt12: bool) -> Result { + create_mint_router_with_custom_cache(mint, Default::default(), include_bolt12).await } async fn cors_middleware( req: axum::http::Request, next: axum::middleware::Next, ) -> Response { + #[cfg(feature = "auth")] + let allowed_headers = "Content-Type, Clear-auth, Blind-auth"; + #[cfg(not(feature = "auth"))] + let allowed_headers = "Content-Type"; + // Handle preflight requests if req.method() == axum::http::Method::OPTIONS { let mut response = Response::new("".into()); @@ -154,7 +251,7 @@ async fn cors_middleware( ); response.headers_mut().insert( "Access-Control-Allow-Headers", - "Content-Type".parse().unwrap(), + allowed_headers.parse().unwrap(), ); return response; } @@ -171,7 +268,7 @@ async fn cors_middleware( ); response.headers_mut().insert( "Access-Control-Allow-Headers", - "Content-Type".parse().unwrap(), + allowed_headers.parse().unwrap(), ); response @@ -182,6 +279,7 @@ async fn cors_middleware( pub async fn create_mint_router_with_custom_cache( mint: Arc, cache: HttpCache, + include_bolt12: bool, ) -> Result { let state = MintState { mint, @@ -208,11 +306,12 @@ pub async fn create_mint_router_with_custom_cache( .route("/melt/bolt11", post(cache_post_melt_bolt11)) .route("/checkstate", post(post_check)) .route("/info", get(get_mint_info)) - .route("/restore", post(post_restore)); + .route("/restore", post(post_restore)) + + .route("/unit/{unit}", get(get_unit_metadata)) + ; - let mint_router = Router::new() - .nest("/v1", v1_router) - .layer(from_fn(cors_middleware)); + let mint_router = Router::new().nest("/v1", v1_router); #[cfg(feature = "auth")] let mint_router = { @@ -220,7 +319,39 @@ pub async fn create_mint_router_with_custom_cache( mint_router.nest("/v1", auth_router) }; - let mint_router = mint_router.with_state(state); + // Conditionally create and merge bolt12_router + let mint_router = if include_bolt12 { + let bolt12_router = create_bolt12_router(state.clone()); + mint_router.nest("/v1", bolt12_router) + } else { + mint_router + }; + + #[cfg(feature = "prometheus")] + let mint_router = mint_router.layer(axum::middleware::from_fn_with_state( + state.clone(), + metrics::global_metrics_middleware, + )); + let mint_router = mint_router + .layer(from_fn(cors_middleware)) + .with_state(state); Ok(mint_router) } + +fn create_bolt12_router(state: MintState) -> Router { + Router::new() + .route("/melt/quote/bolt12", post(post_melt_bolt12_quote)) + .route( + "/melt/quote/bolt12/{quote_id}", + get(get_check_melt_bolt11_quote), + ) + .route("/melt/bolt12", post(cache_post_melt_bolt12)) + .route("/mint/quote/bolt12", post(post_mint_bolt12_quote)) + .route( + "/mint/quote/bolt12/{quote_id}", + get(get_check_mint_bolt12_quote), + ) + .route("/mint/bolt12", post(cache_post_mint_bolt12)) + .with_state(state) +} diff --git a/crates/cdk-axum/src/metrics.rs b/crates/cdk-axum/src/metrics.rs new file mode 100644 index 000000000..81156d3b4 --- /dev/null +++ b/crates/cdk-axum/src/metrics.rs @@ -0,0 +1,41 @@ +#[cfg(feature = "prometheus")] +use std::time::Instant; + +#[cfg(feature = "prometheus")] +use axum::body::Body; +#[cfg(feature = "prometheus")] +use axum::extract::MatchedPath; +#[cfg(feature = "prometheus")] +use axum::http::Request; +#[cfg(feature = "prometheus")] +use axum::middleware::Next; +#[cfg(feature = "prometheus")] +use axum::response::Response; +#[cfg(feature = "prometheus")] +use cdk_prometheus::global; + +/// Global metrics middleware that uses the singleton instance. +/// This version doesn't require access to MintState and can be used in any Axum application. +#[cfg(feature = "prometheus")] +pub async fn global_metrics_middleware( + matched_path: Option, + req: Request, + next: Next, +) -> Response { + let start_time = Instant::now(); + + let response = next.run(req).await; + + let endpoint_path = matched_path + .map(|mp| mp.as_str().to_string()) + .unwrap_or_default(); + + let status_code = response.status().as_u16().to_string(); + let request_duration = start_time.elapsed().as_secs_f64(); + + // Always use global metrics + global::record_http_request(&endpoint_path, &status_code); + global::record_http_request_duration(request_duration, &endpoint_path); + + response +} diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index 0fb937edc..b18071a1f 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -1,9 +1,11 @@ use anyhow::Result; use axum::extract::ws::WebSocketUpgrade; -use axum::extract::{Json, Path, State}; +use axum::extract::{FromRequestParts, Json, Path, State}; +use axum::http::request::Parts; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::error::{ErrorCode, ErrorResponse}; +use cdk::mint::QuoteId; #[cfg(feature = "auth")] use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; use cdk::nuts::{ @@ -15,13 +17,58 @@ use cdk::nuts::{ use cdk::util::unix_time; use paste::paste; use tracing::instrument; -use uuid::Uuid; #[cfg(feature = "auth")] use crate::auth::AuthHeader; use crate::ws::main_websocket; use crate::MintState; +use cdk_common::common::UnitMetadata; +use cdk::nuts::CurrencyUnit; +use std::str::FromStr; + +const PREFER_HEADER_KEY: &str = "Prefer"; + +/// Header extractor for the Prefer header +/// +/// This extractor checks for the `Prefer: respond-async` header +/// to determine if the client wants asynchronous processing +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PreferHeader { + pub respond_async: bool, +} + +impl FromRequestParts for PreferHeader +where + S: Send + Sync, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // Check for Prefer header + if let Some(prefer_value) = parts.headers.get(PREFER_HEADER_KEY) { + let value = prefer_value.to_str().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Invalid Prefer header value".to_string(), + ) + })?; + + // Check if it contains "respond-async" + let respond_async = value.to_lowercase().contains("respond-async"); + + return Ok(PreferHeader { respond_async }); + } + + // No Prefer header found - default to synchronous processing + Ok(PreferHeader { + respond_async: false, + }) + } +} + +/// Macro to add cache to endpoint +#[macro_export] macro_rules! post_cache_wrapper { ($handler:ident, $request_type:ty, $response_type:ty) => { paste! { @@ -59,12 +106,53 @@ macro_rules! post_cache_wrapper { }; } +/// Macro to add cache to endpoint with prefer header support (for async operations) +#[macro_export] +macro_rules! post_cache_wrapper_with_prefer { + ($handler:ident, $request_type:ty, $response_type:ty) => { + paste! { + /// Cache wrapper function for $handler with PreferHeader support: + /// Wrap $handler into a function that caches responses using the request as key + pub async fn []( + #[cfg(feature = "auth")] auth: AuthHeader, + prefer: PreferHeader, + state: State, + payload: Json<$request_type> + ) -> Result, Response> { + use std::ops::Deref; + + let json_extracted_payload = payload.deref(); + let State(mint_state) = state.clone(); + let cache_key = match mint_state.cache.calculate_key(&json_extracted_payload) { + Some(key) => key, + None => { + // Could not calculate key, just return the handler result + #[cfg(feature = "auth")] + return $handler(auth, prefer, state, payload).await; + #[cfg(not(feature = "auth"))] + return $handler(prefer, state, payload).await; + } + }; + if let Some(cached_response) = mint_state.cache.get::<$response_type>(&cache_key).await { + return Ok(Json(cached_response)); + } + #[cfg(feature = "auth")] + let response = $handler(auth, prefer, state, payload).await?; + #[cfg(not(feature = "auth"))] + let response = $handler(prefer, state, payload).await?; + mint_state.cache.set(cache_key, &response.deref()).await; + Ok(response) + } + } + }; +} + post_cache_wrapper!(post_swap, SwapRequest, SwapResponse); -post_cache_wrapper!(post_mint_bolt11, MintRequest, MintResponse); -post_cache_wrapper!( +post_cache_wrapper!(post_mint_bolt11, MintRequest, MintResponse); +post_cache_wrapper_with_prefer!( post_melt_bolt11, - MeltRequest, - MeltQuoteBolt11Response + MeltRequest, + MeltQuoteBolt11Response ); #[cfg_attr(feature = "swagger", utoipa::path( @@ -150,7 +238,7 @@ pub(crate) async fn post_mint_bolt11_quote( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Json(payload): Json, -) -> Result>, Response> { +) -> Result>, Response> { #[cfg(feature = "auth")] state .mint @@ -163,11 +251,11 @@ pub(crate) async fn post_mint_bolt11_quote( let quote = state .mint - .get_mint_bolt11_quote(payload) + .get_mint_quote(payload.into()) .await .map_err(into_response)?; - Ok(Json(quote)) + Ok(Json(quote.try_into().map_err(into_response)?)) } #[cfg_attr(feature = "swagger", utoipa::path( @@ -189,8 +277,8 @@ pub(crate) async fn post_mint_bolt11_quote( pub(crate) async fn get_check_mint_bolt11_quote( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, - Path(quote_id): Path, -) -> Result>, Response> { + Path(quote_id): Path, +) -> Result>, Response> { #[cfg(feature = "auth")] { state @@ -212,15 +300,28 @@ pub(crate) async fn get_check_mint_bolt11_quote( into_response(err) })?; - Ok(Json(quote)) + Ok(Json(quote.try_into().map_err(into_response)?)) } #[instrument(skip_all)] pub(crate) async fn ws_handler( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, ws: WebSocketUpgrade, -) -> impl IntoResponse { - ws.on_upgrade(|ws| main_websocket(ws, state)) +) -> Result { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Get, RoutePath::Ws), + ) + .await + .map_err(into_response)?; + } + + Ok(ws.on_upgrade(|ws| main_websocket(ws, state))) } /// Mint tokens by paying a BOLT11 Lightning invoice. @@ -242,7 +343,7 @@ pub(crate) async fn ws_handler( pub(crate) async fn post_mint_bolt11( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, - Json(payload): Json>, + Json(payload): Json>, ) -> Result, Response> { #[cfg(feature = "auth")] { @@ -284,7 +385,7 @@ pub(crate) async fn post_melt_bolt11_quote( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Json(payload): Json, -) -> Result>, Response> { +) -> Result>, Response> { #[cfg(feature = "auth")] { state @@ -299,7 +400,7 @@ pub(crate) async fn post_melt_bolt11_quote( let quote = state .mint - .get_melt_bolt11_quote(&payload) + .get_melt_quote(payload.into()) .await .map_err(into_response)?; @@ -325,8 +426,8 @@ pub(crate) async fn post_melt_bolt11_quote( pub(crate) async fn get_check_melt_bolt11_quote( #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, - Path(quote_id): Path, -) -> Result>, Response> { + Path(quote_id): Path, +) -> Result>, Response> { #[cfg(feature = "auth")] { state @@ -367,9 +468,10 @@ pub(crate) async fn get_check_melt_bolt11_quote( #[instrument(skip_all)] pub(crate) async fn post_melt_bolt11( #[cfg(feature = "auth")] auth: AuthHeader, + prefer: PreferHeader, State(state): State, - Json(payload): Json>, -) -> Result>, Response> { + Json(payload): Json>, +) -> Result>, Response> { #[cfg(feature = "auth")] { state @@ -382,11 +484,17 @@ pub(crate) async fn post_melt_bolt11( .map_err(into_response)?; } - let res = state - .mint - .melt_bolt11(&payload) - .await - .map_err(into_response)?; + let res = if prefer.respond_async { + // Asynchronous processing - return immediately after setup + state + .mint + .melt_async(&payload) + .await + .map_err(into_response)? + } else { + // Synchronous processing - wait for completion + state.mint.melt(&payload).await.map_err(into_response)? + }; Ok(Json(res)) } @@ -563,6 +671,7 @@ where | ErrorCode::TransactionUnbalanced | ErrorCode::AmountOutofLimitRange | ErrorCode::WitnessMissingOrInvalid + | ErrorCode::DuplicateSignature | ErrorCode::DuplicateInputs | ErrorCode::DuplicateOutputs | ErrorCode::MultipleUnits @@ -571,7 +680,7 @@ where | ErrorCode::BlindAuthRequired => StatusCode::BAD_REQUEST, // Auth failures (401 Unauthorized) - ErrorCode::ClearAuthFailed | ErrorCode::BlindAuthFailed => StatusCode::UNAUTHORIZED, + ErrorCode::ClearAuthFailed | ErrorCode::StaticAuthTokenMismatch | ErrorCode::BlindAuthFailed => StatusCode::UNAUTHORIZED, // Lightning/payment errors and unknown errors (500 Internal Server Error) ErrorCode::LightningError | ErrorCode::Unknown(_) => StatusCode::INTERNAL_SERVER_ERROR, @@ -579,3 +688,37 @@ where (status_code, Json(err_response)).into_response() } + + + +#[cfg_attr(feature = "swagger", utoipa::path( + get, + context_path = "/v1", + path = "/unit/{unit}", + params( + ("unit" = String, description = "The unit"), + ), + responses( + (status = 200, description = "Successful response", body = KeysResponse, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Get the metadata of a specific keyset +/// +/// Get the metadata of the mint from a specific keyset ID. +#[instrument(skip_all, fields(unit = ?unit))] +pub(crate) async fn get_unit_metadata( + State(state): State, + Path(unit): Path, +) -> Result, Response> { + let unit = cdk::nuts::nut00::CurrencyUnit::from_str(&unit).map_err(|err| { + tracing::error!("Could not parse unit: {}", err); + into_response(cdk::Error::UnsupportedUnit) + })?; + let metadata = state.mint.get_unit_metadata(unit).ok_or_else(|| { + tracing::error!("Could not get unit metadata"); + into_response(cdk::Error::UnsupportedUnit) + })?; + Ok(Json(metadata)) + +} \ No newline at end of file diff --git a/crates/cdk-axum/src/ws/mod.rs b/crates/cdk-axum/src/ws/mod.rs index 03f41af1e..b5b0060cf 100644 --- a/crates/cdk-axum/src/ws/mod.rs +++ b/crates/cdk-axum/src/ws/mod.rs @@ -1,15 +1,16 @@ use std::collections::HashMap; +use std::sync::Arc; -use axum::extract::ws::{Message, WebSocket}; +use axum::extract::ws::{CloseFrame, Message, WebSocket}; +use cdk::mint::QuoteId; use cdk::nuts::nut17::NotificationPayload; -use cdk::pub_sub::SubId; +use cdk::subscription::SubId; use cdk::ws::{ notification_to_ws_message, NotificationInner, WsErrorBody, WsMessageOrResponse, WsMethodRequest, WsRequest, }; use futures::StreamExt; use tokio::sync::mpsc; -use uuid::Uuid; use crate::MintState; @@ -36,8 +37,8 @@ pub use error::WsError; pub struct WsContext { state: MintState, - subscriptions: HashMap>, - publisher: mpsc::Sender<(SubId, NotificationPayload)>, + subscriptions: HashMap, tokio::task::JoinHandle<()>>, + publisher: mpsc::Sender<(Arc, NotificationPayload)>, } /// Main function for websocket connections @@ -74,12 +75,48 @@ pub async fn main_websocket(mut socket: WebSocket, state: MintState) { } }; - if let Err(err)= socket.send(Message::Text(message.into())).await { - tracing::error!("Could not send websocket message: {}", err); - break; - } + if let Err(err)= socket.send(Message::Text(message.into())).await { + tracing::error!("Could not send websocket message: {}", err); + break; + } } - Some(Ok(Message::Text(text))) = socket.next() => { + + Some(from_ws) = socket.next() => { + let text = match from_ws { + Ok(Message::Text(text)) => text.to_string(), + Ok(Message::Binary(bin)) => String::from_utf8_lossy(&bin).to_string(), + Ok(Message::Ping(payload)) => { + // Reply with Pong with same payload + if let Err(e) = socket.send(Message::Pong(payload)).await { + tracing::error!("failed to send pong: {e}"); + break; + } + continue; + }, + Ok(Message::Pong(_payload)) => { + tracing::error!("Unexpected pong"); + continue; + }, + Ok(Message::Close(frame)) => { + if let Some(CloseFrame { code, reason }) = frame { + tracing::info!("ws-close: code={code:?} reason='{reason}'"); + } else { + tracing::info!("ws-close: no frame"); + } + + let _ = socket.send(Message::Close(Some(CloseFrame { + code: axum::extract::ws::close_code::NORMAL, + reason: "bye!".into(), + }))).await; + break; + } + Err(err) => { + tracing::error!("ws-error: {err}"); + break; + } + }; + + let request = match serde_json::from_str::(&text) { Ok(request) => request, Err(err) => { @@ -105,7 +142,9 @@ pub async fn main_websocket(mut socket: WebSocket, state: MintState) { } } else => { - + // Unexpected, we should exit the loop + tracing::warn!("Unexpected event, closing ws"); + break; } } } diff --git a/crates/cdk-axum/src/ws/subscribe.rs b/crates/cdk-axum/src/ws/subscribe.rs index e675bf40d..94a0b2857 100644 --- a/crates/cdk-axum/src/ws/subscribe.rs +++ b/crates/cdk-axum/src/ws/subscribe.rs @@ -1,4 +1,4 @@ -use cdk::subscription::{IndexableParams, Params}; +use cdk::subscription::Params; use cdk::ws::{WsResponseResult, WsSubscribeResponse}; use super::{WsContext, WsError}; @@ -15,22 +15,20 @@ pub(crate) async fn handle( return Err(WsError::InvalidParams); } - let params: IndexableParams = params.into(); - let mut subscription = context .state .mint - .pubsub_manager - .try_subscribe(params) - .await + .pubsub_manager() + .subscribe(params) .map_err(|_| WsError::ParseError)?; let publisher = context.publisher.clone(); + let sub_id_for_sender = sub_id.clone(); context.subscriptions.insert( sub_id.clone(), tokio::spawn(async move { while let Some(response) = subscription.recv().await { - let _ = publisher.send(response).await; + let _ = publisher.try_send((sub_id_for_sender.clone(), response.into_inner())); } }), ); diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index 6ec4c8b2b..e789e05d3 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -11,15 +11,17 @@ rust-version.workspace = true readme = "README.md" [features] +default = [] sqlcipher = ["cdk-sqlite/sqlcipher"] # MSRV is not tracked with redb enabled redb = ["dep:cdk-redb"] +tor = ["cdk/tor"] [dependencies] anyhow.workspace = true bip39.workspace = true bitcoin.workspace = true -cdk = { workspace = true, default-features = false, features = ["wallet", "auth"]} +cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "nostr", "bip353"]} cdk-redb = { workspace = true, features = ["wallet"], optional = true } cdk-sqlite = { workspace = true, features = ["wallet"] } clap.workspace = true @@ -29,11 +31,8 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true home.workspace = true -nostr-sdk = { version = "0.41.0", default-features = false, features = [ - "nip04", - "nip44", - "nip59" -]} +nostr-sdk = { workspace = true } reqwest.workspace = true url.workspace = true serde_with.workspace = true +lightning.workspace = true diff --git a/crates/cdk-cli/README.md b/crates/cdk-cli/README.md index 169a47203..0cea68bb2 100644 --- a/crates/cdk-cli/README.md +++ b/crates/cdk-cli/README.md @@ -1,14 +1,458 @@ +# CDK CLI + +[![crates.io](https://img.shields.io/crates/v/cdk-cli.svg)](https://crates.io/crates/cdk-cli) +[![Documentation](https://docs.rs/cdk-cli/badge.svg)](https://docs.rs/cdk-cli) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE) + > **Warning** > This project is in early development, it does however work with real sats! Always use amounts you don't mind losing. -cdk-cli is a CLI wallet implementation using of CDK(../cdk) +A command-line Cashu wallet implementation built with the Cashu Development Kit (CDK). This tool allows you to interact with Cashu mints from the terminal, performing operations like minting, melting, and transferring ecash tokens. + +## Features + +- **Multiple Mint Support**: Connect to and manage multiple Cashu mints simultaneously +- **Token Operations**: Mint, melt, send, and receive Cashu tokens +- **Lightning Integration**: Pay Lightning invoices (BOLT11, BOLT12, BIP353) and receive payments +- **Payment Requests**: Create and pay payment requests with various conditions (P2PK, HTLC) +- **Token Transfer**: Transfer tokens between different mints +- **Multi-Currency Support**: Support for different currency units (sat, usd, eur, etc.) +- **Database Options**: SQLite or Redb backend with optional encryption (SQLCipher) +- **Tor Support**: Built-in Tor transport support (when compiled with feature) +- **Secure Storage**: Local storage of tokens, mint configurations, and seed + +## Installation + +### Option 1: Download Pre-built Binary +Download the latest release from the [GitHub releases page](https://github.com/cashubtc/cdk/releases). + +### Option 2: Build from Source +```bash +git clone https://github.com/cashubtc/cdk.git +cd cdk +cargo build --bin cdk-cli --release +# Binary will be at ./target/release/cdk-cli +``` + +### Build with Optional Features +```bash +# With Tor support +cargo build --bin cdk-cli --release --features tor + +# With SQLCipher encryption +cargo build --bin cdk-cli --release --features sqlcipher + +# With Redb database +cargo build --bin cdk-cli --release --features redb +``` + +## Quick Start + +### 1. Check Your Balance +```bash +# View your current balance across all mints +cdk-cli balance +``` + +### 2. Mint Tokens +```bash +# Create and mint tokens from a mint (amount in sats) +cdk-cli mint http://127.0.0.1:8085 100 + +# Or with a description +cdk-cli mint http://127.0.0.1:8085 100 "My first mint" + +# The command will display a Lightning invoice to pay +# After payment, tokens are automatically minted +``` + +### 3. Send Tokens +```bash +# Send tokens (you'll be prompted for amount and mint selection interactively) +cdk-cli send + +# Or specify options directly +cdk-cli send --mint-url http://127.0.0.1:8085 --memo "Payment for coffee" +``` + +### 4. Receive Tokens +```bash +# Receive a token from someone else +cdk-cli receive + +# Receive from untrusted mint with transfer to trusted mint +cdk-cli receive --allow-untrusted --transfer-to http://127.0.0.1:8085 +``` + +## Global Options + +The CLI supports several global options that apply to all commands: + +```bash +# Use a specific database engine +cdk-cli --engine sqlite balance +cdk-cli --engine redb balance + +# Set a custom work directory +cdk-cli --work-dir ~/my-wallet balance + +# Set logging level +cdk-cli --log-level info balance + +# Use a specific currency unit +cdk-cli --unit usd balance + +# Use NIP-98 Wallet Signing Proxy +cdk-cli --proxy https://proxy.example.com balance + +# Disable Tor (when built with Tor feature, it's on by default) +cdk-cli --tor off balance +``` + +## Commands Reference + +### Balance Operations + +```bash +# Check balance across all mints +cdk-cli balance +``` + +### Minting Tokens + +```bash +# Mint tokens with a Lightning invoice +cdk-cli mint + +# With options +cdk-cli mint http://127.0.0.1:8085 1000 \ + --method bolt11 + +# Using an existing quote +cdk-cli mint http://127.0.0.1:8085 --quote-id + +# Claim pending mint quotes that have been paid +cdk-cli mint-pending +``` + +### Sending & Receiving Tokens + +```bash +# Send tokens (interactive) +cdk-cli send + +# Send with specific options +cdk-cli send \ + --memo "Coffee payment" \ + --mint-url http://127.0.0.1:8085 \ + --include-fee \ + --offline + +# Send with P2PK lock +cdk-cli send --pubkey --required-sigs 1 + +# Send with HTLC (Hash Time Locked Contract) +cdk-cli send --hash --locktime + +# Send as V3 token +cdk-cli send --v3 + +# Send with automatic transfer from other mints if needed +cdk-cli send --allow-transfer --max-transfer-amount 1000 + +# Receive tokens +cdk-cli receive + +# Receive with signing key (for P2PK) +cdk-cli receive --signing-key + +# Receive with HTLC preimage +cdk-cli receive --preimage + +# Receive via Nostr +cdk-cli receive --nostr-key --relay wss://relay.example.com +``` + +### Lightning Payments + +```bash +# Pay a Lightning invoice (interactive - will prompt for invoice) +cdk-cli melt + +# Specify mint and payment method +cdk-cli melt --mint-url http://127.0.0.1:8085 --method bolt11 + +# Pay BOLT12 offer +cdk-cli melt --method bolt12 + +# Pay BIP353 address +cdk-cli melt --method bip353 + +# Multi-path payment +cdk-cli melt --mpp +``` + +### Payment Requests + +```bash +# Create a payment request (interactive via Nostr) +cdk-cli create-request + +# Create with specific amount +cdk-cli create-request --amount 1000 "Invoice for services" + +# Create with P2PK condition +cdk-cli create-request --amount 500 \ + --pubkey \ + --pubkey \ + --num-sigs 2 + +# Create with HTLC +cdk-cli create-request --amount 1000 --hash +# Or use preimage instead +cdk-cli create-request --amount 1000 --preimage + +# Create with HTTP transport +cdk-cli create-request --amount 1000 \ + --transport http \ + --http-url https://myserver.com/payment + +# Create without transport (just print the request) +cdk-cli create-request --amount 1000 --transport none + +# Pay a payment request +cdk-cli pay-request + +# Decode a payment request +cdk-cli decode-request +``` + +### Token Transfer Between Mints + +```bash +# Transfer tokens between mints (interactive) +cdk-cli transfer + +# Transfer specific amount +cdk-cli transfer \ + --source-mint http://mint1.example.com \ + --target-mint http://mint2.example.com \ + --amount 1000 + +# Transfer full balance from one mint to another +cdk-cli transfer \ + --source-mint http://mint1.example.com \ + --target-mint http://mint2.example.com \ + --full-balance +``` + +### Mint Information & Management + +```bash +# Get mint information +cdk-cli mint-info + +# Update mint URL (if mint has migrated) +cdk-cli update-mint-url + +# List proofs from mint +cdk-cli list-mint-proofs +``` + +### Token & Proof Management + +```bash +# Decode a Cashu token +cdk-cli decode-token + +# Check pending proofs and reclaim if no longer pending +cdk-cli check-pending + +# Burn spent tokens (cleanup) +cdk-cli burn + +# Restore proofs from seed for a specific mint +cdk-cli restore +``` + +### Advanced Features + +#### Blind Authentication (NUT-14) + +```bash +# Mint blind authentication proofs +cdk-cli mint-blind-auth --amount +``` + +#### CAT (Cashu Authentication Tokens) + +```bash +# Login with username/password +cdk-cli cat-login --username --password + +# Login with device code flow (OAuth-style) +cdk-cli cat-device-login +``` + +## Configuration + +### Storage Location + +The CLI stores its configuration and wallet data in: +- **Linux/macOS**: `~/.cdk-cli/` +- **Windows**: `%USERPROFILE%\.cdk-cli\` + +You can override this with the `--work-dir` option. + +### Database Options + +The CLI supports multiple database backends: + +#### SQLite (default) +```bash +cdk-cli --engine sqlite balance +``` + +#### SQLCipher (encrypted SQLite) +```bash +# Requires building with --features sqlcipher +cdk-cli --engine sqlite --password mypassword balance +``` + +#### Redb +```bash +# Requires building with --features redb +cdk-cli --engine redb balance +``` + +### Seed Management + +The wallet seed is automatically generated and stored in `/seed` on first run. This seed is used to derive all keys and can be used to restore your wallet. + +**Important**: Back up your seed file securely. Anyone with access to the seed can spend your tokens. + +## Examples + +### Complete Workflow Example + +```bash +# 1. Start a test mint (in another terminal) +cdk-mintd + +# 2. Mint some tokens +cdk-cli mint http://127.0.0.1:8085 1000 "Initial mint" +# Pay the displayed Lightning invoice + +# 3. Check balance +cdk-cli balance + +# 4. Send some tokens +cdk-cli send +# Follow interactive prompts + +# 5. The recipient can receive with: +cdk-cli receive + +# 6. Pay a Lightning invoice +cdk-cli melt +# Follow prompts to enter invoice +``` + +### Multi-Mint Setup + +```bash +# Mint from multiple mints +cdk-cli mint http://mint1.example.com 5000 +cdk-cli mint http://mint2.example.com 3000 + +# Check balance (shows breakdown by mint) +cdk-cli balance + +# Transfer between mints +cdk-cli transfer \ + --source-mint http://mint1.example.com \ + --target-mint http://mint2.example.com \ + --amount 2000 +``` + +### Payment Request Workflow + +```bash +# Recipient creates a payment request +cdk-cli create-request --amount 1000 "Payment for services" +# Copy the payment request string + +# Sender pays the request +cdk-cli pay-request +``` + +### P2PK (Pay to Public Key) Usage + +```bash +# Send tokens locked to a public key +cdk-cli send --pubkey --required-sigs 1 + +# Recipient receives with their private key +cdk-cli receive --signing-key +``` + +### HTLC (Hash Time Locked Contract) Usage + +```bash +# Create a preimage and hash (externally) +# hash = SHA256(preimage) + +# Send with HTLC +cdk-cli send --hash --locktime 1700000000 + +# Recipient receives with preimage +cdk-cli receive --preimage +``` + +## Help and Documentation + +```bash +# General help +cdk-cli --help + +# Help for specific commands +cdk-cli mint --help +cdk-cli send --help +cdk-cli receive --help +cdk-cli create-request --help +``` + +## Troubleshooting + +### Pending Tokens +If you have pending tokens (sent but not received, or mint quotes paid but not claimed): + +```bash +# Check and reclaim pending proofs +cdk-cli check-pending + +# Claim paid mint quotes +cdk-cli mint-pending +``` + +### Cleaning Up +```bash +# Remove spent tokens from database +cdk-cli burn +``` + +### Restore from Seed +```bash +# Restore proofs from a specific mint +cdk-cli restore +``` ## License -Code is under the [MIT](../../LICENSE) +Code is under the [MIT License](../../LICENSE) ## Contribution -All contributions welcome. +All contributions are welcome. Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, shall be licensed as above, without any additional terms or conditions. diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 252be05b3..9cafe3a03 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -9,10 +9,12 @@ use bip39::Mnemonic; use cdk::cdk_database; use cdk::cdk_database::WalletDatabase; use cdk::nuts::CurrencyUnit; -use cdk::wallet::{HttpClient, MultiMintWallet, Wallet, WalletBuilder}; +use cdk::wallet::MultiMintWallet; #[cfg(feature = "redb")] use cdk_redb::WalletRedbDatabase; use cdk_sqlite::WalletSqliteDatabase; +#[cfg(all(feature = "tor", not(target_arch = "wasm32")))] +use clap::ValueEnum; use clap::{Parser, Subcommand}; use tracing::Level; use tracing_subscriber::EnvFilter; @@ -27,11 +29,15 @@ const DEFAULT_WORK_DIR: &str = ".cdk-cli"; const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); /// Simple CLI application to interact with cashu +#[cfg(all(feature = "tor", not(target_arch = "wasm32")))] +#[derive(Copy, Clone, Debug, ValueEnum)] +enum TorToggle { + On, + Off, +} + #[derive(Parser)] -#[command(name = "cdk-cli")] -#[command(author = "thesimplekid ")] -#[command(version = CARGO_PKG_VERSION.unwrap_or("Unknown"))] -#[command(author, version, about, long_about = None)] +#[command(name = "cdk-cli", author = "thesimplekid ", version = CARGO_PKG_VERSION.unwrap_or("Unknown"), about, long_about = None)] struct Cli { /// Database engine to use (sqlite/redb) #[arg(short, long, default_value = "sqlite")] @@ -49,6 +55,14 @@ struct Cli { /// NWS Proxy #[arg(short, long)] proxy: Option, + /// Currency unit to use for the wallet + #[arg(short, long, default_value = "sat")] + unit: String, + /// Use Tor transport (only when built with --features tor). Defaults to 'on' when feature is enabled. + #[cfg(all(feature = "tor", not(target_arch = "wasm32")))] + #[arg(long = "tor", value_enum, default_value_t = TorToggle::On)] + transport: TorToggle, + /// Subcommand to run #[command(subcommand)] command: Commands, } @@ -58,7 +72,7 @@ enum Commands { /// Decode a token DecodeToken(sub_commands::decode_token::DecodeTokenSubCommand), /// Balance - Balance, + Balance(sub_commands::balance::BalanceSubCommand), /// Pay bolt11 invoice Melt(sub_commands::melt::MeltSubCommand), /// Claim pending mint quotes that have been paid @@ -67,6 +81,8 @@ enum Commands { Receive(sub_commands::receive::ReceiveSubCommand), /// Send Send(sub_commands::send::SendSubCommand), + /// Transfer tokens between mints + Transfer(sub_commands::transfer::TransferSubCommand), /// Reclaim pending proofs that are no longer pending CheckPending, /// View mint info @@ -100,9 +116,9 @@ async fn main() -> Result<()> { let args: Cli = Cli::parse(); let default_filter = args.log_level; - let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn"; + let filter = "rustls=warn,hyper_util=warn,reqwest=warn"; - let env_filter = EnvFilter::new(format!("{default_filter},{sqlx_filter}")); + let env_filter = EnvFilter::new(format!("{default_filter},{filter}")); // Parse input tracing_subscriber::fmt().with_env_filter(env_filter).init(); @@ -115,7 +131,10 @@ async fn main() -> Result<()> { } }; - fs::create_dir_all(&work_dir)?; + // Create work directory if it doesn't exist + if !work_dir.exists() { + fs::create_dir_all(&work_dir)?; + } let localstore: Arc + Send + Sync> = match args.engine.as_str() { @@ -126,7 +145,7 @@ async fn main() -> Result<()> { #[cfg(feature = "sqlcipher")] let sql = { match args.password { - Some(pass) => WalletSqliteDatabase::new(&sql_path, pass).await?, + Some(pass) => WalletSqliteDatabase::new((sql_path, pass)).await?, None => bail!("Missing database password"), } }; @@ -168,67 +187,54 @@ async fn main() -> Result<()> { }; let seed = mnemonic.to_seed_normalized(""); - let mut wallets: Vec = Vec::new(); - - let mints = localstore.get_mints().await?; + // Parse currency unit from args + let currency_unit = CurrencyUnit::from_str(&args.unit) + .unwrap_or_else(|_| CurrencyUnit::Custom(args.unit.clone())); - for (mint_url, mint_info) in mints { - let units = if let Some(mint_info) = mint_info { - mint_info.supported_units().into_iter().cloned().collect() - } else { - vec![CurrencyUnit::Sat] - }; - - let proxy_client = if let Some(proxy_url) = args.proxy.as_ref() { - Some(HttpClient::with_proxy( - mint_url.clone(), + // Create MultiMintWallet with specified currency unit + // The constructor will automatically load wallets for this currency unit + let multi_mint_wallet = match &args.proxy { + Some(proxy_url) => { + MultiMintWallet::new_with_proxy( + localstore.clone(), + seed, + currency_unit.clone(), proxy_url.clone(), - None, - true, - )?) - } else { - None - }; - - let seed = mnemonic.to_seed_normalized(""); - - for unit in units { - let mint_url_clone = mint_url.clone(); - let mut builder = WalletBuilder::new() - .mint_url(mint_url_clone.clone()) - .unit(unit) - .localstore(localstore.clone()) - .seed(&seed); - - if let Some(http_client) = &proxy_client { - builder = builder.client(http_client.clone()); - } - - let wallet = builder.build()?; - - let wallet_clone = wallet.clone(); - - tokio::spawn(async move { - if let Err(err) = wallet_clone.get_mint_info().await { - tracing::error!( - "Could not get mint quote for {}, {}", - wallet_clone.mint_url, - err - ); + ) + .await? + } + None => { + #[cfg(all(feature = "tor", not(target_arch = "wasm32")))] + { + match args.transport { + TorToggle::On => { + MultiMintWallet::new_with_tor( + localstore.clone(), + seed, + currency_unit.clone(), + ) + .await? + } + TorToggle::Off => { + MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone()) + .await? + } } - }); - - wallets.push(wallet); + } + #[cfg(not(all(feature = "tor", not(target_arch = "wasm32"))))] + { + MultiMintWallet::new(localstore.clone(), seed, currency_unit.clone()).await? + } } - } - - let multi_mint_wallet = MultiMintWallet::new(localstore, Arc::new(seed), wallets); + }; match &args.command { Commands::DecodeToken(sub_command_args) => { sub_commands::decode_token::decode_token(sub_command_args) } - Commands::Balance => sub_commands::balance::balance(&multi_mint_wallet).await, + Commands::Balance(sub_command_args) => { + sub_commands::balance::balance(&multi_mint_wallet, sub_command_args).await + } Commands::Melt(sub_command_args) => { sub_commands::melt::pay(&multi_mint_wallet, sub_command_args).await } @@ -238,6 +244,9 @@ async fn main() -> Result<()> { Commands::Send(sub_command_args) => { sub_commands::send::send(&multi_mint_wallet, sub_command_args).await } + Commands::Transfer(sub_command_args) => { + sub_commands::transfer::transfer(&multi_mint_wallet, sub_command_args).await + } Commands::CheckPending => { sub_commands::check_pending::check_pending(&multi_mint_wallet).await } diff --git a/crates/cdk-cli/src/sub_commands/balance.rs b/crates/cdk-cli/src/sub_commands/balance.rs index 4e25e74ac..19561029a 100644 --- a/crates/cdk-cli/src/sub_commands/balance.rs +++ b/crates/cdk-cli/src/sub_commands/balance.rs @@ -3,30 +3,55 @@ use std::collections::BTreeMap; use anyhow::Result; use cdk::mint_url::MintUrl; use cdk::nuts::CurrencyUnit; -use cdk::wallet::multi_mint_wallet::MultiMintWallet; +use cdk::wallet::MultiMintWallet; use cdk::Amount; +use clap::Args; + +use std::str::FromStr; + +#[derive(Args)] +pub struct BalanceSubCommand { + /// Currency unit e.g. sat, msat, usd, eur + #[arg(short, long)] + pub unit: String, +} + +pub async fn balance( + multi_mint_wallet: &MultiMintWallet, + sub_command_args: &BalanceSubCommand, +) -> Result<()> { + println!("Balance for unit: {}", sub_command_args.unit); + + let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; + // Show individual mint balances + let mint_balances = mint_balances(multi_mint_wallet).await?; + + // Show total balance using the new unified interface + let total = multi_mint_wallet.total_balance().await?; + if !mint_balances.is_empty() { + println!(); + println!("Total balance across all wallets: {} {}", total, unit); + } -pub async fn balance(multi_mint_wallet: &MultiMintWallet) -> Result<()> { - mint_balances(multi_mint_wallet, &CurrencyUnit::Sat).await?; Ok(()) } pub async fn mint_balances( multi_mint_wallet: &MultiMintWallet, - unit: &CurrencyUnit, -) -> Result> { - let wallets: BTreeMap = multi_mint_wallet.get_balances(unit).await?; +) -> Result> { + let wallets: BTreeMap = + multi_mint_wallet.get_balances().await?; let mut wallets_vec = Vec::with_capacity(wallets.len()); - for (i, (mint_url, amount)) in wallets + for (i, (mint_url, (amount, unit))) in wallets .iter() - .filter(|(_, a)| a > &&Amount::ZERO) + .filter(|(_, (a, _))| a > &&Amount::ZERO) .enumerate() { let mint_url = mint_url.clone(); println!("{i}: {mint_url} {amount} {unit}"); - wallets_vec.push((mint_url, *amount)) + wallets_vec.push((mint_url, (amount.clone(), unit.clone()))); } Ok(wallets_vec) } diff --git a/crates/cdk-cli/src/sub_commands/burn.rs b/crates/cdk-cli/src/sub_commands/burn.rs index 4fbc7dcb0..f6f7d560b 100644 --- a/crates/cdk-cli/src/sub_commands/burn.rs +++ b/crates/cdk-cli/src/sub_commands/burn.rs @@ -1,9 +1,5 @@ -use std::str::FromStr; - use anyhow::Result; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; -use cdk::wallet::types::WalletKey; use cdk::wallet::MultiMintWallet; use cdk::Amount; use clap::Args; @@ -12,9 +8,6 @@ use clap::Args; pub struct BurnSubCommand { /// Mint Url mint_url: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, } pub async fn burn( @@ -22,14 +15,10 @@ pub async fn burn( sub_command_args: &BurnSubCommand, ) -> Result<()> { let mut total_burnt = Amount::ZERO; - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; match &sub_command_args.mint_url { Some(mint_url) => { - let wallet = multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit)) - .await - .unwrap(); + let wallet = multi_mint_wallet.get_wallet(mint_url).await.unwrap(); total_burnt = wallet.check_all_pending_proofs().await?; } None => { diff --git a/crates/cdk-cli/src/sub_commands/cat_device_login.rs b/crates/cdk-cli/src/sub_commands/cat_device_login.rs index 29727c7d8..8f54b9fa7 100644 --- a/crates/cdk-cli/src/sub_commands/cat_device_login.rs +++ b/crates/cdk-cli/src/sub_commands/cat_device_login.rs @@ -1,11 +1,9 @@ use std::path::Path; -use std::str::FromStr; use std::time::Duration; use anyhow::{anyhow, Result}; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintInfo}; -use cdk::wallet::types::WalletKey; +use cdk::nuts::MintInfo; use cdk::wallet::MultiMintWallet; use cdk::OidcClient; use clap::Args; @@ -18,14 +16,6 @@ use crate::token_storage; pub struct CatDeviceLoginSubCommand { /// Mint url mint_url: MintUrl, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - #[arg(short, long)] - unit: String, - /// Client ID for OIDC authentication - #[arg(default_value = "cashu-client")] - #[arg(long)] - client_id: String, } pub async fn cat_device_login( @@ -34,27 +24,18 @@ pub async fn cat_device_login( work_dir: &Path, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let wallet = match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { - Some(wallet) => wallet.clone(), - None => { - multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) - .await? - } - }; + // 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 - .get_mint_info() + let mint_info = multi_mint_wallet + .fetch_mint_info(&mint_url) .await? .ok_or(anyhow!("Mint info not found"))?; - let (access_token, refresh_token) = - get_device_code_token(&mint_info, &sub_command_args.client_id).await; + let (access_token, refresh_token) = get_device_code_token(&mint_info).await; // Save tokens to file in work directory if let Err(e) = @@ -74,7 +55,7 @@ pub async fn cat_device_login( Ok(()) } -async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String, String) { +async fn get_device_code_token(mint_info: &MintInfo) -> (String, String) { let openid_discovery = mint_info .nuts .nut21 @@ -82,7 +63,14 @@ async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String .expect("Nut21 defined") .openid_discovery; - let oidc_client = OidcClient::new(openid_discovery); + let client_id = mint_info + .nuts + .nut21 + .clone() + .expect("Nut21 defined") + .client_id; + + let oidc_client = OidcClient::new(openid_discovery, None); // Get the OIDC configuration let oidc_config = oidc_client @@ -97,7 +85,10 @@ async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String let client = reqwest::Client::new(); let device_code_response = client .post(device_auth_url) - .form(&[("client_id", client_id)]) + .form(&[ + ("client_id", client_id.clone().as_str()), + ("scope", "openid offline_access"), + ]) .send() .await .expect("Failed to send device code request"); @@ -143,7 +134,7 @@ async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String .form(&[ ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), ("device_code", device_code), - ("client_id", client_id), + ("client_id", client_id.clone().as_str()), ]) .send() .await diff --git a/crates/cdk-cli/src/sub_commands/cat_login.rs b/crates/cdk-cli/src/sub_commands/cat_login.rs index 61a296b91..7d6be5449 100644 --- a/crates/cdk-cli/src/sub_commands/cat_login.rs +++ b/crates/cdk-cli/src/sub_commands/cat_login.rs @@ -1,10 +1,8 @@ use std::path::Path; -use std::str::FromStr; use anyhow::{anyhow, Result}; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintInfo}; -use cdk::wallet::types::WalletKey; +use cdk::nuts::MintInfo; use cdk::wallet::MultiMintWallet; use cdk::OidcClient; use clap::Args; @@ -20,14 +18,6 @@ pub struct CatLoginSubCommand { username: String, /// Password password: String, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - #[arg(short, long)] - unit: String, - /// Client ID for OIDC authentication - #[arg(default_value = "cashu-client")] - #[arg(long)] - client_id: String, } pub async fn cat_login( @@ -36,28 +26,19 @@ pub async fn cat_login( work_dir: &Path, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let wallet = match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { - Some(wallet) => wallet.clone(), - None => { - multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) - .await? - } - }; - - let mint_info = wallet - .get_mint_info() + // 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"))?; let (access_token, refresh_token) = get_access_token( &mint_info, - &sub_command_args.client_id, &sub_command_args.username, &sub_command_args.password, ) @@ -80,12 +61,7 @@ pub async fn cat_login( Ok(()) } -async fn get_access_token( - mint_info: &MintInfo, - client_id: &str, - user: &str, - password: &str, -) -> (String, String) { +async fn get_access_token(mint_info: &MintInfo, user: &str, password: &str) -> (String, String) { let openid_discovery = mint_info .nuts .nut21 @@ -93,7 +69,14 @@ async fn get_access_token( .expect("Nut21 defined") .openid_discovery; - let oidc_client = OidcClient::new(openid_discovery); + let client_id = mint_info + .nuts + .nut21 + .clone() + .expect("Nut21 defined") + .client_id; + + let oidc_client = OidcClient::new(openid_discovery, None); // Get the token endpoint from the OIDC configuration let token_url = oidc_client @@ -105,7 +88,8 @@ async fn get_access_token( // Create the request parameters let params = [ ("grant_type", "password"), - ("client_id", client_id), + ("client_id", &client_id), + ("scope", "openid offline_access"), ("username", user), ("password", password), ]; diff --git a/crates/cdk-cli/src/sub_commands/create_request.rs b/crates/cdk-cli/src/sub_commands/create_request.rs index cda60dd49..2959536d9 100644 --- a/crates/cdk-cli/src/sub_commands/create_request.rs +++ b/crates/cdk-cli/src/sub_commands/create_request.rs @@ -1,24 +1,11 @@ -use std::str::FromStr; - -use anyhow::{bail, Result}; -use bitcoin::hashes::sha256::Hash as Sha256Hash; -use cdk::nuts::nut01::PublicKey; -use cdk::nuts::nut11::{Conditions, SigFlag, SpendingConditions}; -use cdk::nuts::nut18::{Nut10SecretRequest, TransportType}; -use cdk::nuts::{CurrencyUnit, PaymentRequest, PaymentRequestPayload, Token, Transport}; -use cdk::wallet::{MultiMintWallet, ReceiveOptions}; +use anyhow::Result; +use cdk::wallet::{payment_request as pr, MultiMintWallet}; use clap::Args; -use nostr_sdk::nips::nip19::Nip19Profile; -use nostr_sdk::prelude::*; -use nostr_sdk::{Client as NostrClient, Filter, Keys, ToBech32}; #[derive(Args)] pub struct CreateRequestSubCommand { #[arg(short, long)] amount: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, /// Quote description description: Option, /// P2PK: Public key(s) for which the token can be spent with valid signature(s) @@ -55,241 +42,30 @@ pub async fn create_request( multi_mint_wallet: &MultiMintWallet, sub_command_args: &CreateRequestSubCommand, ) -> Result<()> { - // Get available mints from the wallet - let mints: Vec = multi_mint_wallet - .get_balances(&CurrencyUnit::Sat) - .await? - .keys() - .cloned() - .collect(); - - // Process transport based on command line args - let transport_type = sub_command_args.transport.to_lowercase(); - let transports = match transport_type.as_str() { - "nostr" => { - let keys = Keys::generate(); - - // Use custom relays if provided, otherwise use defaults - let relays = if let Some(custom_relays) = &sub_command_args.nostr_relay { - if !custom_relays.is_empty() { - println!("Using custom Nostr relays: {custom_relays:?}"); - custom_relays.clone() - } else { - // Empty vector provided, fall back to defaults - vec![ - "wss://relay.nos.social".to_string(), - "wss://relay.damus.io".to_string(), - ] - } - } else { - // No relays provided, use defaults - vec![ - "wss://relay.nos.social".to_string(), - "wss://relay.damus.io".to_string(), - ] - }; - - let nprofile = Nip19Profile::new(keys.public_key, relays.clone())?; - - let nostr_transport = Transport { - _type: TransportType::Nostr, - target: nprofile.to_bech32()?, - tags: Some(vec![vec!["n".to_string(), "17".to_string()]]), - }; - - // We'll need the Nostr keys and relays later for listening - let transport_info = Some((keys, relays, nprofile.public_key)); - - (Some(vec![nostr_transport]), transport_info) - } - "http" => { - if let Some(url) = &sub_command_args.http_url { - let http_transport = Transport { - _type: TransportType::HttpPost, - target: url.clone(), - tags: None, - }; - - (Some(vec![http_transport]), None) - } else { - println!( - "Warning: HTTP transport selected but no URL provided, skipping transport" - ); - (None, None) - } - } - "none" => (None, None), - _ => { - println!("Warning: Unknown transport type '{transport_type}', defaulting to none"); - (None, None) - } - }; - - // Create spending conditions based on provided arguments - // Handle the following cases: - // 1. Only P2PK condition - // 2. Only HTLC condition with hash - // 3. Only HTLC condition with preimage - // 4. Both P2PK and HTLC conditions - - let spending_conditions = if let Some(pubkey_strings) = &sub_command_args.pubkey { - // Parse all pubkeys - let mut parsed_pubkeys = Vec::new(); - for pubkey_str in pubkey_strings { - match PublicKey::from_str(pubkey_str) { - Ok(pubkey) => parsed_pubkeys.push(pubkey), - Err(err) => { - println!("Error parsing pubkey {pubkey_str}: {err}"); - // Continue with other pubkeys - } - } - } - - if parsed_pubkeys.is_empty() { - println!("No valid pubkeys provided"); - None - } else { - // We have pubkeys for P2PK condition - let num_sigs = sub_command_args.num_sigs.min(parsed_pubkeys.len() as u64); - - // Check if we also have an HTLC condition - if let Some(hash_str) = &sub_command_args.hash { - // Create conditions with the pubkeys - let conditions = Conditions { - locktime: None, - pubkeys: Some(parsed_pubkeys), - refund_keys: None, - num_sigs: Some(num_sigs), - sig_flag: SigFlag::SigInputs, - num_sigs_refund: None, - }; - - // Try to parse the hash - match Sha256Hash::from_str(hash_str) { - Ok(hash) => { - // Create HTLC condition with P2PK in the conditions - Some(SpendingConditions::HTLCConditions { - data: hash, - conditions: Some(conditions), - }) - } - Err(err) => { - println!("Error parsing hash: {err}"); - // Fallback to just P2PK with multiple pubkeys - bail!("Error parsing hash"); - } - } - } else if let Some(preimage) = &sub_command_args.preimage { - // Create conditions with the pubkeys - let conditions = Conditions { - locktime: None, - pubkeys: Some(parsed_pubkeys), - refund_keys: None, - num_sigs: Some(num_sigs), - sig_flag: SigFlag::SigInputs, - num_sigs_refund: None, - }; - - // Create HTLC conditions with the hash and pubkeys in conditions - Some(SpendingConditions::new_htlc( - preimage.to_string(), - Some(conditions), - )?) - } else { - // Only P2PK condition with multiple pubkeys - Some(SpendingConditions::new_p2pk( - *parsed_pubkeys.first().unwrap(), - Some(Conditions { - locktime: None, - pubkeys: Some(parsed_pubkeys[1..].to_vec()), - refund_keys: None, - num_sigs: Some(num_sigs), - sig_flag: SigFlag::SigInputs, - num_sigs_refund: None, - }), - )) - } - } - } else if let Some(hash_str) = &sub_command_args.hash { - // Only HTLC condition with provided hash - match Sha256Hash::from_str(hash_str) { - Ok(hash) => Some(SpendingConditions::HTLCConditions { - data: hash, - conditions: None, - }), - Err(err) => { - println!("Error parsing hash: {err}"); - None - } - } - } else if let Some(preimage) = &sub_command_args.preimage { - // Only HTLC condition with provided preimage - // For HTLC, create the hash from the preimage and use it directly - Some(SpendingConditions::new_htlc(preimage.to_string(), None)?) - } else { - None - }; - - // Convert SpendingConditions to Nut10SecretRequest - let nut10 = spending_conditions.map(Nut10SecretRequest::from); - - // Extract the transports option from our match result - let (transports_option, nostr_info) = transports; - - let req = PaymentRequest { - payment_id: None, - amount: sub_command_args.amount.map(|a| a.into()), - unit: Some(CurrencyUnit::from_str(&sub_command_args.unit)?), - single_use: Some(true), - mints: Some(mints), + // Gather parameters for library call + let params = pr::CreateRequestParams { + amount: sub_command_args.amount, + unit: multi_mint_wallet.unit().to_string(), description: sub_command_args.description.clone(), - transports: transports_option, - nut10, + pubkeys: sub_command_args.pubkey.clone(), + num_sigs: sub_command_args.num_sigs, + hash: sub_command_args.hash.clone(), + preimage: sub_command_args.preimage.clone(), + transport: sub_command_args.transport.to_lowercase(), + http_url: sub_command_args.http_url.clone(), + nostr_relays: sub_command_args.nostr_relay.clone(), }; - // Always print the request - println!("{req}"); + let (req, nostr_wait) = multi_mint_wallet.create_request(params).await?; - // Only listen for Nostr payment if Nostr transport was selected - if let Some((keys, relays, pubkey)) = nostr_info { - println!("Listening for payment via Nostr..."); + // Print the request to stdout + println!("{}", req); - let client = NostrClient::new(keys); - let filter = Filter::new().pubkey(pubkey); - - for relay in relays { - client.add_read_relay(relay).await?; - } - - client.connect().await; - client.subscribe(filter, None).await?; - - // Handle subscription notifications with `handle_notifications` method - client - .handle_notifications(|notification| async { - let mut exit = false; - if let RelayPoolNotification::Event { - subscription_id: _, - event, - .. - } = notification - { - let unwrapped = client.unwrap_gift_wrap(&event).await?; - let rumor = unwrapped.rumor; - let payload: PaymentRequestPayload = serde_json::from_str(&rumor.content)?; - let token = - Token::new(payload.mint, payload.proofs, payload.memo, payload.unit); - - let amount = multi_mint_wallet - .receive(&token.to_string(), ReceiveOptions::default()) - .await?; - - println!("Received {amount}"); - exit = true; - } - Ok(exit) // Set to true to exit from the loop - }) - .await?; + // If we set up Nostr transport, optionally wait for payment and receive it + if let Some(info) = nostr_wait { + println!("Listening for payment via Nostr..."); + let amount = multi_mint_wallet.wait_for_nostr_payment(info).await?; + println!("Received {}", amount); } Ok(()) diff --git a/crates/cdk-cli/src/sub_commands/melt.rs b/crates/cdk-cli/src/sub_commands/melt.rs index bd92dde9c..96a2cb448 100644 --- a/crates/cdk-cli/src/sub_commands/melt.rs +++ b/crates/cdk-cli/src/sub_commands/melt.rs @@ -1,218 +1,369 @@ use std::str::FromStr; use anyhow::{bail, Result}; -use cdk::amount::MSAT_IN_SAT; +use cdk::amount::{amount_for_offer, Amount, MSAT_IN_SAT}; +use cdk::mint_url::MintUrl; use cdk::nuts::{CurrencyUnit, MeltOptions}; -use cdk::wallet::multi_mint_wallet::MultiMintWallet; -use cdk::wallet::types::WalletKey; +use cdk::wallet::MultiMintWallet; use cdk::Bolt11Invoice; -use clap::Args; -use tokio::task::JoinSet; - -use crate::sub_commands::balance::mint_balances; -use crate::utils::{ - get_number_input, get_user_input, get_wallet_by_index, get_wallet_by_mint_url, - validate_mint_number, -}; +use clap::{Args, ValueEnum}; +use lightning::offers::offer::Offer; + +use crate::utils::{get_number_input, get_user_input}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +pub enum PaymentType { + /// BOLT11 invoice + Bolt11, + /// BOLT12 offer + Bolt12, + /// Bip353 + Bip353, +} #[derive(Args)] pub struct MeltSubCommand { - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, /// Mpp #[arg(short, long, conflicts_with = "mint_url")] mpp: bool, /// Mint URL to use for melting #[arg(long, conflicts_with = "mpp")] mint_url: Option, + /// Payment method (bolt11, bolt12, or bip353) + #[arg(long, default_value = "bolt11")] + method: PaymentType, } -pub async fn pay( - multi_mint_wallet: &MultiMintWallet, - sub_command_args: &MeltSubCommand, -) -> Result<()> { - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let mints_amounts = mint_balances(multi_mint_wallet, &unit).await?; - - let mut mints = vec![]; - let mut mint_amounts = vec![]; - if sub_command_args.mpp { - // MPP functionality expects multiple mints, so mint_url flag doesn't fully apply here, - // but we can offer to use the specified mint as the first one if provided - if let Some(mint_url) = &sub_command_args.mint_url { - println!("Using mint URL {mint_url} as the first mint for MPP payment."); - - // Check if the mint exists - if let Ok(_wallet) = - get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit.clone()).await - { - // Find the index of this mint in the mints_amounts list - if let Some(mint_index) = mints_amounts - .iter() - .position(|(url, _)| url.to_string() == *mint_url) - { - mints.push(mint_index); - let melt_amount: u64 = - get_number_input("Enter amount to mint from this mint in sats.")?; - mint_amounts.push(melt_amount); - } else { - println!("Warning: Mint URL exists but no balance found. Continuing with manual selection."); - } - } else { - println!("Warning: Could not find wallet for the specified mint URL. Continuing with manual selection."); +/// Helper function to check if there are enough funds and create appropriate MeltOptions +fn create_melt_options( + available_funds: u64, + payment_amount: Option, + prompt: &str, +) -> Result> { + match payment_amount { + Some(amount) => { + // Payment has a specified amount + if amount > available_funds { + bail!("Not enough funds; payment requires {} msats", amount); } + Ok(None) // Use default options } - loop { - let mint_number: String = - get_user_input("Enter mint number to melt from and -1 when done.")?; + None => { + // Payment doesn't have an amount, ask user for it + let user_amount = get_number_input::(prompt)? * MSAT_IN_SAT; - if mint_number == "-1" || mint_number.is_empty() { - break; + if user_amount > available_funds { + bail!("Not enough funds"); } - let mint_number: usize = mint_number.parse()?; - validate_mint_number(mint_number, mints_amounts.len())?; + Ok(Some(MeltOptions::new_amountless(user_amount))) + } + } +} - mints.push(mint_number); - let melt_amount: u64 = - get_number_input("Enter amount to mint from this mint in sats.")?; - mint_amounts.push(melt_amount); +pub async fn pay( + multi_mint_wallet: &MultiMintWallet, + sub_command_args: &MeltSubCommand, +) -> Result<()> { + // Check total balance across all wallets + let total_balance = multi_mint_wallet.total_balance().await?; + if total_balance == Amount::ZERO { + bail!("No funds available"); + } + + // Determine which mint to use for melting BEFORE processing payment (unless using MPP) + let selected_mint = if sub_command_args.mpp { + None // MPP mode handles mint selection differently + } else if let Some(mint_url) = &sub_command_args.mint_url { + Some(MintUrl::from_str(mint_url)?) + } else { + // Display all mints with their balances and let user select + let balances_map = multi_mint_wallet.get_balances().await?; + if balances_map.is_empty() { + bail!("No mints available in the wallet"); } - let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice request")?)?; + let balances_vec: Vec<(MintUrl, (Amount, CurrencyUnit))> = + balances_map.into_iter().collect(); + + println!("\nAvailable mints and balances:"); + for (index, (mint_url, (balance, unit))) in balances_vec.iter().enumerate() { + println!( + " {}: {} - {} {}", + index, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } + println!(" {}: Any mint (auto-select best)", balances_vec.len()); - let mut quotes = JoinSet::new(); + let selection = loop { + let selection: usize = + get_number_input("Enter mint number to melt from (or select Any)")?; - for (mint, amount) in mints.iter().zip(mint_amounts) { - let wallet = mints_amounts[*mint].0.clone(); + if selection == balances_vec.len() { + break None; // "Any" option selected + } - let wallet = multi_mint_wallet - .get_wallet(&WalletKey::new(wallet, unit.clone())) - .await - .expect("Known wallet"); - let options = MeltOptions::new_mpp(amount * 1000); + if let Some((mint_url, _)) = balances_vec.get(selection) { + break Some(mint_url.clone()); + } - let bolt11_clone = bolt11.clone(); + println!("Invalid selection, please try again."); + }; - quotes.spawn(async move { - let quote = wallet - .melt_quote(bolt11_clone.to_string(), Some(options)) - .await; + selection + }; - (wallet, quote) - }); + if sub_command_args.mpp { + // Manual MPP - user specifies which mints and amounts to use + if !matches!(sub_command_args.method, PaymentType::Bolt11) { + bail!("MPP is only supported for BOLT11 invoices"); } - let quotes = quotes.join_all().await; - - for (wallet, quote) in quotes.iter() { - if let Err(quote) = quote { - tracing::error!("Could not get quote for {}: {:?}", wallet.mint_url, quote); - bail!("Could not get melt quote for {}", wallet.mint_url); - } else { - let quote = quote.as_ref().unwrap(); - println!( - "Melt quote {} for mint {} of amount {} with fee {}.", - quote.id, wallet.mint_url, quote.amount, quote.fee_reserve - ); - } + let bolt11_str = get_user_input("Enter bolt11 invoice")?; + let _bolt11 = Bolt11Invoice::from_str(&bolt11_str)?; // Validate invoice format + + // Show available mints and balances + let balances = multi_mint_wallet.get_balances().await?; + println!("\nAvailable mints and balances:"); + for (i, (mint_url, (balance, unit))) in balances.iter().enumerate() { + println!( + " {}: {} - {} {}", + i, + mint_url, + balance, + multi_mint_wallet.unit() + ); } - let mut melts = JoinSet::new(); + // Collect mint selections and amounts + let mut mint_amounts = Vec::new(); + loop { + let mint_input = get_user_input("Enter mint number to use (or 'done' to finish)")?; - for (wallet, quote) in quotes { - let quote = quote.expect("Errors checked above"); + if mint_input.to_lowercase() == "done" || mint_input.is_empty() { + break; + } - melts.spawn(async move { - let melt = wallet.melt("e.id).await; - (wallet, melt) - }); + let mint_index: usize = mint_input.parse()?; + let mint_url = balances + .iter() + .nth(mint_index) + .map(|(url, _)| url.clone()) + .ok_or_else(|| anyhow::anyhow!("Invalid mint index"))?; + + let amount: u64 = get_number_input(&format!( + "Enter amount to use from this mint ({})", + multi_mint_wallet.unit() + ))?; + mint_amounts.push((mint_url, Amount::from(amount))); } - let melts = melts.join_all().await; - - let mut error = false; - - for (wallet, melt) in melts { - match melt { - Ok(melt) => { - println!( - "Melt for {} paid {} with fee of {} ", - wallet.mint_url, melt.amount, melt.fee_paid - ); - } - Err(err) => { - println!("Melt for {} failed with {}", wallet.mint_url, err); - error = true; - } - } + if mint_amounts.is_empty() { + bail!("No mints selected for MPP payment"); } - if error { - bail!("Could not complete all melts"); + // Get quotes for each mint + println!("\nGetting melt quotes..."); + let quotes = multi_mint_wallet + .mpp_melt_quote(bolt11_str, mint_amounts) + .await?; + + // Display quotes + println!("\nMelt quotes obtained:"); + for (mint_url, quote) in "es { + println!(" {} - Quote ID: {}", mint_url, quote.id); + println!(" Amount: {}, Fee: {}", quote.amount, quote.fee_reserve); } - } else { - // Get wallet either by mint URL or by index - let wallet = if let Some(mint_url) = &sub_command_args.mint_url { - // Use the provided mint URL - get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit.clone()).await? - } else { - // Fallback to the index-based selection - let mint_number: usize = get_number_input("Enter mint number to melt from")?; - get_wallet_by_index(multi_mint_wallet, &mints_amounts, mint_number, unit.clone()) - .await? - }; - // Find the mint amount for the selected wallet to check available funds - let mint_url = &wallet.mint_url; - let mint_amount = mints_amounts + // Execute the melts + let quotes_to_execute: Vec<(MintUrl, String)> = quotes .iter() - .find(|(url, _)| url == mint_url) - .map(|(_, amount)| *amount) - .ok_or_else(|| anyhow::anyhow!("Could not find balance for mint: {}", mint_url))?; + .map(|(url, quote)| (url.clone(), quote.id.clone())) + .collect(); - let available_funds = >::into(mint_amount) * MSAT_IN_SAT; + println!("\nExecuting MPP payment..."); + let results = multi_mint_wallet.mpp_melt(quotes_to_execute).await?; - let bolt11 = Bolt11Invoice::from_str(&get_user_input("Enter bolt11 invoice request")?)?; + // Display results + println!("\nPayment results:"); + let mut total_paid = Amount::ZERO; + let mut total_fees = Amount::ZERO; - // Determine payment amount and options - let options = if bolt11.amount_milli_satoshis().is_none() { - // Get user input for amount - let prompt = format!( - "Enter the amount you would like to pay in sats for a {} payment.", - if sub_command_args.mpp { - "MPP" - } else { - "amountless invoice" - } + for (mint_url, melted) in results { + println!( + " {} - Paid: {}, Fee: {}", + mint_url, melted.amount, melted.fee_paid ); + total_paid += melted.amount; + total_fees += melted.fee_paid; - let user_amount = get_number_input::(&prompt)? * MSAT_IN_SAT; - - if user_amount > available_funds { - bail!("Not enough funds"); + if let Some(preimage) = melted.preimage { + println!(" Preimage: {}", preimage); } + } - Some(MeltOptions::new_amountless(user_amount)) - } else { - // Check if invoice amount exceeds available funds - let invoice_amount = bolt11.amount_milli_satoshis().unwrap(); - if invoice_amount > available_funds { - bail!("Not enough funds"); + println!("\nTotal paid: {} {}", total_paid, multi_mint_wallet.unit()); + println!("Total fees: {} {}", total_fees, multi_mint_wallet.unit()); + } else { + let available_funds = >::into(total_balance) * MSAT_IN_SAT; + + // Process payment based on payment method using new unified interface + match sub_command_args.method { + PaymentType::Bolt11 => { + // Process BOLT11 payment + let bolt11_str = get_user_input("Enter bolt11 invoice")?; + let bolt11 = Bolt11Invoice::from_str(&bolt11_str)?; + + // Determine payment amount and options + let prompt = format!( + "Enter the amount you would like to pay in {} for this amountless invoice.", + multi_mint_wallet.unit() + ); + let options = + create_melt_options(available_funds, bolt11.amount_milli_satoshis(), &prompt)?; + + // Use selected mint or auto-select + let melted = if let Some(mint_url) = selected_mint { + // User selected a specific mint - use the new mint-specific functions + let quote = multi_mint_wallet + .melt_quote(&mint_url, bolt11_str.clone(), options) + .await?; + + println!("Melt quote created:"); + println!(" Quote ID: {}", quote.id); + println!(" Amount: {}", quote.amount); + println!(" Fee Reserve: {}", quote.fee_reserve); + + // Execute the melt + multi_mint_wallet + .melt_with_mint(&mint_url, "e.id) + .await? + } else { + // User selected "Any" - let the wallet auto-select the best mint + multi_mint_wallet.melt(&bolt11_str, options, None).await? + }; + + println!("Payment successful: {:?}", melted); + if let Some(preimage) = melted.preimage { + println!("Payment preimage: {}", preimage); + } } - None - }; + PaymentType::Bolt12 => { + // Process BOLT12 payment (offer) + let offer_str = get_user_input("Enter BOLT12 offer")?; + let offer = Offer::from_str(&offer_str) + .map_err(|e| anyhow::anyhow!("Invalid BOLT12 offer: {:?}", e))?; + + // Determine if offer has an amount + let prompt = format!( + "Enter the amount you would like to pay in {} for this amountless offer:", + multi_mint_wallet.unit() + ); + let amount_msat = match amount_for_offer(&offer, &CurrencyUnit::Msat) { + Ok(amount) => Some(u64::from(amount)), + Err(_) => None, + }; - // Process payment - let quote = wallet.melt_quote(bolt11.to_string(), options).await?; - println!("{quote:?}"); + let options = create_melt_options(available_funds, amount_msat, &prompt)?; + + // Get wallet for BOLT12 using the selected mint + let mint_url = if let Some(specific_mint) = selected_mint { + specific_mint + } else { + // User selected "Any" - just pick the first mint with any balance + let balances = multi_mint_wallet.get_balances().await?; + + balances + .into_iter() + .find(|(_, (balance, _))| *balance > Amount::ZERO) + .map(|(mint_url, _)| mint_url) + .ok_or_else(|| anyhow::anyhow!("No mint available for BOLT12 payment"))? + }; + + let wallet = multi_mint_wallet + .get_wallet(&mint_url) + .await + .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))?; + + // Get melt quote for BOLT12 + let quote = wallet.melt_bolt12_quote(offer_str, options).await?; + + // Display quote info + println!("Melt quote created:"); + println!(" Quote ID: {}", quote.id); + println!(" Amount: {}", quote.amount); + println!(" Fee Reserve: {}", quote.fee_reserve); + println!(" State: {}", quote.state); + println!(" Expiry: {}", quote.expiry); + + // Execute the melt + let melted = wallet.melt("e.id).await?; + println!( + "Payment successful: Paid {} with fee {}", + melted.amount, melted.fee_paid + ); + if let Some(preimage) = melted.preimage { + println!("Payment preimage: {}", preimage); + } + } + PaymentType::Bip353 => { + let bip353_addr = get_user_input("Enter Bip353 address")?; - let melt = wallet.melt("e.id).await?; - println!("Paid invoice: {}", melt.state); + let prompt = format!( + "Enter the amount you would like to pay in {} for this amountless offer:", + multi_mint_wallet.unit() + ); + // BIP353 payments are always amountless for now + let options = create_melt_options(available_funds, None, &prompt)?; - if let Some(preimage) = melt.preimage { - println!("Payment preimage: {preimage}"); + // Get wallet for BIP353 using the selected mint + let mint_url = if let Some(specific_mint) = selected_mint { + specific_mint + } else { + // User selected "Any" - just pick the first mint with any balance + let balances = multi_mint_wallet.get_balances().await?; + + balances + .into_iter() + .find(|(_, (balance, _))| *balance > Amount::ZERO) + .map(|(mint_url, _)| mint_url) + .ok_or_else(|| anyhow::anyhow!("No mint available for BIP353 payment"))? + }; + + let wallet = multi_mint_wallet + .get_wallet(&mint_url) + .await + .ok_or_else(|| anyhow::anyhow!("Mint {} not found", mint_url))?; + + // Get melt quote for BIP353 address (internally resolves and gets BOLT12 quote) + let quote = wallet + .melt_bip353_quote( + &bip353_addr, + options.expect("Amount is required").amount_msat(), + ) + .await?; + + // Display quote info + println!("Melt quote created:"); + println!(" Quote ID: {}", quote.id); + println!(" Amount: {}", quote.amount); + println!(" Fee Reserve: {}", quote.fee_reserve); + println!(" State: {}", quote.state); + println!(" Expiry: {}", quote.expiry); + + // Execute the melt + let melted = wallet.melt("e.id).await?; + println!( + "Payment successful: Paid {} with fee {}", + melted.amount, melted.fee_paid + ); + if let Some(preimage) = melted.preimage { + println!("Payment preimage: {}", preimage); + } + } } } diff --git a/crates/cdk-cli/src/sub_commands/mint.rs b/crates/cdk-cli/src/sub_commands/mint.rs index 9372bd589..84ca828a7 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -4,9 +4,9 @@ use anyhow::{anyhow, Result}; use cdk::amount::SplitTarget; use cdk::mint_url::MintUrl; use cdk::nuts::nut00::ProofsMethods; -use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload}; -use cdk::wallet::{MultiMintWallet, WalletSubscription}; -use cdk::Amount; +use cdk::nuts::PaymentMethod; +use cdk::wallet::MultiMintWallet; +use cdk::{Amount, StreamExt}; use clap::Args; use serde::{Deserialize, Serialize}; @@ -18,15 +18,24 @@ pub struct MintSubCommand { mint_url: MintUrl, /// Amount amount: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, /// Quote description #[serde(skip_serializing_if = "Option::is_none")] description: Option, /// Quote Id #[arg(short, long)] quote_id: Option, + /// Payment method + #[arg(long, default_value = "bolt11")] + method: String, + /// Expiry + #[arg(short, long)] + expiry: Option, + /// Expiry + #[arg(short, long)] + single_use: Option, + /// Wait duration in seconds for mint quote polling + #[arg(long, default_value = "30")] + wait_duration: u64, } pub async fn mint( @@ -34,45 +43,68 @@ pub async fn mint( sub_command_args: &MintSubCommand, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; let description: Option = sub_command_args.description.clone(); - let wallet = get_or_create_wallet(multi_mint_wallet, &mint_url, unit).await?; + let wallet = get_or_create_wallet(multi_mint_wallet, &mint_url).await?; + + let payment_method = PaymentMethod::from_str(&sub_command_args.method)?; + + let quote = match &sub_command_args.quote_id { + None => match payment_method { + PaymentMethod::Bolt11 => { + let amount = sub_command_args + .amount + .ok_or(anyhow!("Amount must be defined"))?; + let quote = wallet.mint_quote(Amount::from(amount), description).await?; + + println!("Quote: {quote:#?}"); - let quote_id = match &sub_command_args.quote_id { - None => { - let amount = sub_command_args - .amount - .ok_or(anyhow!("Amount must be defined"))?; - let quote = wallet.mint_quote(Amount::from(amount), description).await?; + println!("Please pay: {}", quote.request); - println!("Quote: {quote:#?}"); + quote + } + PaymentMethod::Bolt12 => { + let amount = sub_command_args.amount; + println!("{:?}", sub_command_args.single_use); + let quote = wallet + .mint_bolt12_quote(amount.map(|a| a.into()), description) + .await?; - println!("Please pay: {}", quote.request); + println!("Quote: {quote:#?}"); - let mut subscription = wallet - .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote - .id - .clone()])) - .await; + println!("Please pay: {}", quote.request); - while let Some(msg) = subscription.recv().await { - if let NotificationPayload::MintQuoteBolt11Response(response) = msg { - if response.state == MintQuoteState::Paid { - break; - } - } + quote + } + _ => { + todo!() } - quote.id - } - Some(quote_id) => quote_id.to_string(), + }, + Some(quote_id) => wallet + .localstore + .get_mint_quote(quote_id) + .await? + .ok_or(anyhow!("Unknown quote"))?, }; - let proofs = wallet.mint("e_id, SplitTarget::default(), None).await?; + tracing::debug!("Attempting mint for: {}", payment_method); + + let mut amount_minted = Amount::ZERO; - let receive_amount = proofs.total_amount()?; + let mut proof_streams = wallet.proof_stream(quote, SplitTarget::default(), None); + + while let Some(proofs) = proof_streams.next().await { + let proofs = match proofs { + Ok(proofs) => proofs, + Err(err) => { + tracing::error!("Proof streams ended with {:?}", err); + break; + } + }; + amount_minted += proofs.total_amount()?; + } - println!("Received {receive_amount} from mint {mint_url}"); + println!("Received {amount_minted} from mint {mint_url}"); Ok(()) } diff --git a/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs b/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs index 2e1f06529..f9a18df50 100644 --- a/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs +++ b/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs @@ -1,10 +1,8 @@ use std::path::Path; -use std::str::FromStr; use anyhow::{anyhow, Result}; use cdk::mint_url::MintUrl; -use cdk::nuts::{CurrencyUnit, MintInfo}; -use cdk::wallet::types::WalletKey; +use cdk::nuts::MintInfo; use cdk::wallet::MultiMintWallet; use cdk::{Amount, OidcClient}; use clap::Args; @@ -21,10 +19,6 @@ pub struct MintBlindAuthSubCommand { /// Cat (access token) #[arg(long)] cat: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - #[arg(short, long)] - unit: String, } pub async fn mint_blind_auth( @@ -33,21 +27,13 @@ pub async fn mint_blind_auth( work_dir: &Path, ) -> Result<()> { let mint_url = sub_command_args.mint_url.clone(); - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let wallet = match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { - Some(wallet) => wallet.clone(), - None => { - multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) - .await? - } - }; + // Ensure the mint exists + if !multi_mint_wallet.has_mint(&mint_url).await { + multi_mint_wallet.add_mint(mint_url.clone()).await?; + } - wallet.get_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 { @@ -75,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 @@ -83,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.get_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"); @@ -101,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" @@ -109,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); @@ -126,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?; } } @@ -136,8 +128,8 @@ pub async fn mint_blind_auth( let amount = match sub_command_args.amount { Some(amount) => amount, None => { - let mint_info = wallet - .get_mint_info() + let mint_info = multi_mint_wallet + .fetch_mint_info(&mint_url) .await? .ok_or(anyhow!("Unknown mint info"))?; mint_info @@ -146,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()); @@ -164,7 +158,7 @@ async fn refresh_access_token( .ok_or_else(|| anyhow::anyhow!("OIDC discovery information not available"))? .openid_discovery; - let oidc_client = OidcClient::new(openid_discovery); + let oidc_client = OidcClient::new(openid_discovery, None); // Get the token endpoint from the OIDC configuration let token_url = oidc_client.get_oidc_config().await?.token_endpoint; diff --git a/crates/cdk-cli/src/sub_commands/mod.rs b/crates/cdk-cli/src/sub_commands/mod.rs index aaf1cb925..0a52cf12b 100644 --- a/crates/cdk-cli/src/sub_commands/mod.rs +++ b/crates/cdk-cli/src/sub_commands/mod.rs @@ -16,4 +16,5 @@ pub mod pending_mints; pub mod receive; pub mod restore; pub mod send; +pub mod transfer; pub mod update_mint_url; diff --git a/crates/cdk-cli/src/sub_commands/pay_request.rs b/crates/cdk-cli/src/sub_commands/pay_request.rs index 0da976094..a5e820064 100644 --- a/crates/cdk-cli/src/sub_commands/pay_request.rs +++ b/crates/cdk-cli/src/sub_commands/pay_request.rs @@ -1,13 +1,10 @@ use std::io::{self, Write}; use anyhow::{anyhow, Result}; -use cdk::nuts::nut18::TransportType; -use cdk::nuts::{PaymentRequest, PaymentRequestPayload, Token}; -use cdk::wallet::{MultiMintWallet, SendOptions}; +use cdk::nuts::PaymentRequest; +use cdk::wallet::MultiMintWallet; +use cdk::Amount; use clap::Args; -use nostr_sdk::nips::nip19::Nip19Profile; -use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys}; -use reqwest::Client; #[derive(Args)] pub struct PayRequestSubCommand { @@ -22,7 +19,8 @@ pub async fn pay_request( let unit = &payment_request.unit; - let amount = match payment_request.amount { + // Determine amount: use from request or prompt user + let amount: Amount = match payment_request.amount { Some(amount) => amount, None => { println!("Enter the amount you would like to pay"); @@ -65,132 +63,12 @@ pub async fn pay_request( } } - let matching_wallet = matching_wallets.first().unwrap(); - - let transports = payment_request - .transports - .clone() - .ok_or(anyhow!("Cannot pay request without transport"))?; - - // We prefer nostr transport if it is available to hide ip. - let transport = transports - .iter() - .find(|t| t._type == TransportType::Nostr) - .or_else(|| { - transports - .iter() - .find(|t| t._type == TransportType::HttpPost) - }); - - let prepared_send = matching_wallet - .prepare_send( - amount, - SendOptions { - include_fee: true, - ..Default::default() - }, - ) - .await?; - - let token = matching_wallet.send(prepared_send, None).await?; - - // We need the keysets information to properly convert from token proof to proof - let keysets_info = match matching_wallet - .localstore - .get_mint_keysets(token.mint_url()?) - .await? - { - Some(keysets_info) => keysets_info, - None => matching_wallet.get_mint_keysets().await?, // Hit the keysets endpoint if we don't have the keysets for this Mint - }; - let proofs = token.proofs(&keysets_info)?; - - if let Some(transport) = transport { - let payload = PaymentRequestPayload { - id: payment_request.payment_id.clone(), - memo: None, - mint: matching_wallet.mint_url.clone(), - unit: matching_wallet.unit.clone(), - proofs, - }; - - match transport._type { - TransportType::Nostr => { - let keys = Keys::generate(); - let client = NostrClient::new(keys); - let nprofile = Nip19Profile::from_bech32(&transport.target)?; - - println!("{:?}", nprofile.relays); - - let rumor = EventBuilder::new( - nostr_sdk::Kind::from_u16(14), - serde_json::to_string(&payload)?, - ) - .build(nprofile.public_key); - let relays = nprofile.relays; - - for relay in relays.iter() { - client.add_write_relay(relay).await?; - } - - client.connect().await; - - let gift_wrap = client - .gift_wrap_to(relays, &nprofile.public_key, rumor, None) - .await?; - - println!( - "Published event {} succufully to {}", - gift_wrap.val, - gift_wrap - .success - .iter() - .map(|s| s.to_string()) - .collect::>() - .join(", ") - ); - - if !gift_wrap.failed.is_empty() { - println!( - "Could not publish to {:?}", - gift_wrap - .failed - .keys() - .map(|relay| relay.to_string()) - .collect::>() - .join(", ") - ); - } - } - - TransportType::HttpPost => { - let client = Client::new(); - - let res = client - .post(transport.target.clone()) - .json(&payload) - .send() - .await?; - - let status = res.status(); - if status.is_success() { - println!("Successfully posted payment"); - } else { - println!("{res:?}"); - println!("Error posting payment"); - } - } - } - } else { - // If no transport is available, print the token - let token = Token::new( - matching_wallet.mint_url.clone(), - proofs, - None, - matching_wallet.unit.clone(), - ); - println!("Token: {token}"); - } + let matching_wallet = matching_wallets + .first() + .ok_or_else(|| anyhow!("No wallet found that can pay this request"))?; - Ok(()) + matching_wallet + .pay_request(payment_request.clone(), Some(amount)) + .await + .map_err(|e| anyhow!(e.to_string())) } diff --git a/crates/cdk-cli/src/sub_commands/pending_mints.rs b/crates/cdk-cli/src/sub_commands/pending_mints.rs index a752d11c8..b033f9eeb 100644 --- a/crates/cdk-cli/src/sub_commands/pending_mints.rs +++ b/crates/cdk-cli/src/sub_commands/pending_mints.rs @@ -2,11 +2,9 @@ use anyhow::Result; use cdk::wallet::MultiMintWallet; pub async fn mint_pending(multi_mint_wallet: &MultiMintWallet) -> Result<()> { - let amounts = multi_mint_wallet.check_all_mint_quotes(None).await?; + let amount = multi_mint_wallet.check_all_mint_quotes(None).await?; - for (unit, amount) in amounts { - println!("Unit: {unit}, Amount: {amount}"); - } + println!("Amount: {amount}"); Ok(()) } diff --git a/crates/cdk-cli/src/sub_commands/receive.rs b/crates/cdk-cli/src/sub_commands/receive.rs index 0bb219d7d..ee04c5245 100644 --- a/crates/cdk-cli/src/sub_commands/receive.rs +++ b/crates/cdk-cli/src/sub_commands/receive.rs @@ -4,11 +4,11 @@ use std::str::FromStr; use std::time::Duration; use anyhow::{anyhow, Result}; +use cdk::mint_url::MintUrl; use cdk::nuts::{SecretKey, Token}; use cdk::util::unix_time; use cdk::wallet::multi_mint_wallet::MultiMintWallet; -use cdk::wallet::types::WalletKey; -use cdk::wallet::ReceiveOptions; +use cdk::wallet::{MultiMintReceiveOptions, ReceiveOptions}; use cdk::Amount; use clap::Args; use nostr_sdk::nips::nip04; @@ -36,6 +36,12 @@ pub struct ReceiveSubCommand { /// Preimage #[arg(short, long, action = clap::ArgAction::Append)] preimage: Vec, + /// Allow receiving from untrusted mints (mints not already in the wallet) + #[arg(long, default_value = "false")] + allow_untrusted: bool, + /// Transfer tokens from untrusted mints to this mint + #[arg(long, value_name = "MINT_URL")] + transfer_to: Option, } pub async fn receive( @@ -69,6 +75,8 @@ pub async fn receive( token_str, &signing_keys, &sub_command_args.preimage, + sub_command_args.allow_untrusted, + sub_command_args.transfer_to.as_deref(), ) .await? } @@ -109,6 +117,8 @@ pub async fn receive( token_str, &signing_keys, &sub_command_args.preimage, + sub_command_args.allow_untrusted, + sub_command_args.transfer_to.as_deref(), ) .await { @@ -135,29 +145,40 @@ async fn receive_token( token_str: &str, signing_keys: &[SecretKey], preimage: &[String], + allow_untrusted: bool, + transfer_to: Option<&str>, ) -> Result { let token: Token = Token::from_str(token_str)?; let mint_url = token.mint_url()?; - let unit = token.unit().unwrap_or_default(); - - if multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - .is_none() - { - get_or_create_wallet(multi_mint_wallet, &mint_url, unit).await?; + + // Parse transfer_to mint URL if provided + let transfer_to_mint = if let Some(mint_str) = transfer_to { + Some(MintUrl::from_str(mint_str)?) + } else { + None + }; + + // Check if the mint is already trusted + let is_trusted = multi_mint_wallet.get_wallet(&mint_url).await.is_some(); + + // If mint is not trusted and we don't allow untrusted, add it first (old behavior) + if !is_trusted && !allow_untrusted { + get_or_create_wallet(multi_mint_wallet, &mint_url).await?; } + // Create multi-mint receive options + let multi_mint_options = MultiMintReceiveOptions::default() + .allow_untrusted(allow_untrusted) + .transfer_to_mint(transfer_to_mint) + .receive_options(ReceiveOptions { + p2pk_signing_keys: signing_keys.to_vec(), + preimages: preimage.to_vec(), + ..Default::default() + }); + let amount = multi_mint_wallet - .receive( - token_str, - ReceiveOptions { - p2pk_signing_keys: signing_keys.to_vec(), - preimages: preimage.to_vec(), - ..Default::default() - }, - ) + .receive(token_str, multi_mint_options) .await?; Ok(amount) } diff --git a/crates/cdk-cli/src/sub_commands/restore.rs b/crates/cdk-cli/src/sub_commands/restore.rs index 9f12e8471..1647660f9 100644 --- a/crates/cdk-cli/src/sub_commands/restore.rs +++ b/crates/cdk-cli/src/sub_commands/restore.rs @@ -1,9 +1,5 @@ -use std::str::FromStr; - use anyhow::Result; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; -use cdk::wallet::types::WalletKey; use cdk::wallet::MultiMintWallet; use clap::Args; @@ -11,27 +7,23 @@ use clap::Args; pub struct RestoreSubCommand { /// Mint Url mint_url: MintUrl, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, } pub async fn restore( multi_mint_wallet: &MultiMintWallet, sub_command_args: &RestoreSubCommand, ) -> Result<()> { - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; let mint_url = sub_command_args.mint_url.clone(); - let wallet = match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { + let wallet = match multi_mint_wallet.get_wallet(&mint_url).await { Some(wallet) => wallet.clone(), None => { + multi_mint_wallet.add_mint(mint_url.clone()).await?; multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) - .await? + .get_wallet(&mint_url) + .await + .expect("Wallet should exist after adding mint") + .clone() } }; diff --git a/crates/cdk-cli/src/sub_commands/send.rs b/crates/cdk-cli/src/sub_commands/send.rs index 5d1775476..7f4ba1b57 100644 --- a/crates/cdk-cli/src/sub_commands/send.rs +++ b/crates/cdk-cli/src/sub_commands/send.rs @@ -1,16 +1,15 @@ use std::str::FromStr; use anyhow::{anyhow, Result}; -use cdk::nuts::{Conditions, CurrencyUnit, PublicKey, SpendingConditions}; +use cdk::mint_url::MintUrl; +use cdk::nuts::{Conditions, PublicKey, SpendingConditions}; use cdk::wallet::types::SendKind; use cdk::wallet::{MultiMintWallet, SendMemo, SendOptions}; use cdk::Amount; use clap::Args; -use crate::sub_commands::balance::mint_balances; -use crate::utils::{ - check_sufficient_funds, get_number_input, get_wallet_by_index, get_wallet_by_mint_url, -}; +use crate::utils::get_number_input; +use cdk::nuts::CurrencyUnit; #[derive(Args)] pub struct SendSubCommand { @@ -50,39 +49,79 @@ pub struct SendSubCommand { /// Mint URL to use for sending #[arg(long)] mint_url: Option, - /// Currency unit e.g. sat - #[arg(default_value = "sat")] - unit: String, + /// Allow transferring funds from other mints if the target mint has insufficient balance + #[arg(long)] + allow_transfer: bool, + /// Maximum amount to transfer from other mints + #[arg(long)] + max_transfer_amount: Option, + + /// Specific mints to exclude from transfers (can be specified multiple times) + #[arg(long, action = clap::ArgAction::Append)] + excluded_mints: Vec, } pub async fn send( multi_mint_wallet: &MultiMintWallet, sub_command_args: &SendSubCommand, ) -> Result<()> { - let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; - let mints_amounts = mint_balances(multi_mint_wallet, &unit).await?; - - // Get wallet either by mint URL or by index - let wallet = if let Some(mint_url) = &sub_command_args.mint_url { - // Use the provided mint URL - get_wallet_by_mint_url(multi_mint_wallet, mint_url, unit).await? + // Determine which mint to use for sending BEFORE asking for amount + let selected_mint = if let Some(mint_url) = &sub_command_args.mint_url { + Some(MintUrl::from_str(mint_url)?) } else { - // Fallback to the index-based selection - let mint_number: usize = get_number_input("Enter mint number to create token")?; - get_wallet_by_index(multi_mint_wallet, &mints_amounts, mint_number, unit).await? - }; + // Display all mints with their balances and let user select + let balances_map = multi_mint_wallet.get_balances().await?; + if balances_map.is_empty() { + return Err(anyhow!("No mints available in the wallet")); + } - let token_amount = Amount::from(get_number_input::("Enter value of token in sats")?); + let balances_vec: Vec<(MintUrl, (Amount, CurrencyUnit))> = + balances_map.into_iter().collect(); - // Find the mint amount for the selected wallet to check if we have sufficient funds - let mint_url = &wallet.mint_url; - let mint_amount = mints_amounts - .iter() - .find(|(url, _)| url == mint_url) - .map(|(_, amount)| *amount) - .ok_or_else(|| anyhow!("Could not find balance for mint: {}", mint_url))?; + println!("\nAvailable mints and balances:"); + for (index, (mint_url, (balance, unit))) in balances_vec.iter().enumerate() { + println!( + " {}: {} - {} {}", + index, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } + println!(" {}: Any mint (auto-select best)", balances_vec.len()); + + let selection = loop { + let selection: usize = + get_number_input("Enter mint number to send from (or select Any)")?; + + if selection == balances_vec.len() { + break None; // "Any" option selected + } + + if let Some((mint_url, _)) = balances_vec.get(selection) { + break Some(mint_url.clone()); + } + + println!("Invalid selection, please try again."); + }; - check_sufficient_funds(mint_amount, token_amount)?; + selection + }; + + let token_amount = Amount::from(get_number_input::(&format!( + "Enter value of token in {}", + multi_mint_wallet.unit() + ))?); + + // Check total balance across all wallets + let total_balance = multi_mint_wallet.total_balance().await?; + if total_balance < token_amount { + return Err(anyhow!( + "Insufficient funds. Total balance: {}, Required: {}", + total_balance, + token_amount + )); + } let conditions = match (&sub_command_args.preimage, &sub_command_args.hash) { (Some(_), Some(_)) => { @@ -206,22 +245,66 @@ pub async fn send( (false, None) => SendKind::OnlineExact, }; - let prepared_send = wallet - .prepare_send( - token_amount, - SendOptions { - memo: sub_command_args.memo.clone().map(|memo| SendMemo { - memo, - include_memo: true, - }), - send_kind, - include_fee: sub_command_args.include_fee, - conditions, - ..Default::default() - }, - ) - .await?; - let token = wallet.send(prepared_send, None).await?; + let send_options = SendOptions { + memo: sub_command_args.memo.clone().map(|memo| SendMemo { + memo, + include_memo: true, + }), + send_kind, + include_fee: sub_command_args.include_fee, + conditions, + ..Default::default() + }; + + // Parse excluded mints from CLI arguments + let excluded_mints: Result, _> = sub_command_args + .excluded_mints + .iter() + .map(|url| MintUrl::from_str(url)) + .collect(); + let excluded_mints = excluded_mints?; + + // Prepare and confirm the send based on mint selection + let token = if let Some(specific_mint) = selected_mint { + // User selected a specific mint + let multi_mint_options = cdk::wallet::multi_mint_wallet::MultiMintSendOptions { + allow_transfer: sub_command_args.allow_transfer, + max_transfer_amount: sub_command_args.max_transfer_amount.map(Amount::from), + allowed_mints: vec![specific_mint.clone()], // Use selected mint as the only allowed mint + excluded_mints, + send_options: send_options.clone(), + }; + + let prepared = multi_mint_wallet + .prepare_send(specific_mint, token_amount, multi_mint_options) + .await?; + + let memo = send_options.memo.clone(); + prepared.confirm(memo).await? + } else { + // User selected "Any" - find the first mint with sufficient balance + let balances = multi_mint_wallet.get_balances().await?; + let best_mint = balances + .into_iter() + .find(|(_, (balance, _))| *balance >= token_amount) + .map(|(mint_url, _)| mint_url) + .ok_or_else(|| anyhow!("No mint has sufficient balance for the requested amount"))?; + + let multi_mint_options = cdk::wallet::multi_mint_wallet::MultiMintSendOptions { + allow_transfer: sub_command_args.allow_transfer, + max_transfer_amount: sub_command_args.max_transfer_amount.map(Amount::from), + allowed_mints: vec![best_mint.clone()], // Use the best mint as the only allowed mint + excluded_mints, + send_options: send_options.clone(), + }; + + let prepared = multi_mint_wallet + .prepare_send(best_mint, token_amount, multi_mint_options) + .await?; + + let memo = send_options.memo.clone(); + prepared.confirm(memo).await? + }; match sub_command_args.v3 { true => { diff --git a/crates/cdk-cli/src/sub_commands/transfer.rs b/crates/cdk-cli/src/sub_commands/transfer.rs new file mode 100644 index 000000000..13adeb4e4 --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/transfer.rs @@ -0,0 +1,210 @@ +use std::str::FromStr; + +use anyhow::{bail, Result}; +use cdk::mint_url::MintUrl; +use cdk::wallet::multi_mint_wallet::TransferMode; +use cdk::wallet::MultiMintWallet; +use cdk::Amount; +use clap::Args; + +use crate::utils::get_number_input; + +#[derive(Args)] +pub struct TransferSubCommand { + /// Source mint URL to transfer from (optional - will prompt if not provided) + #[arg(long)] + source_mint: Option, + /// Target mint URL to transfer to (optional - will prompt if not provided) + #[arg(long)] + target_mint: Option, + /// Amount to transfer (optional - will prompt if not provided) + #[arg(short, long, conflicts_with = "full_balance")] + amount: Option, + /// Transfer all available balance from source mint + #[arg(long, conflicts_with = "amount")] + full_balance: bool, +} + +/// Helper function to select a mint from available mints +async fn select_mint( + multi_mint_wallet: &MultiMintWallet, + prompt: &str, + exclude_mint: Option<&MintUrl>, +) -> Result { + let balances = multi_mint_wallet.get_balances().await?; + + // Filter out excluded mint if provided + let available_mints: Vec<_> = balances + .iter() + .filter(|(url, _)| exclude_mint.is_none_or(|excluded| url != &excluded)) + .collect(); + + if available_mints.is_empty() { + bail!("No available mints found"); + } + + println!("\nAvailable mints:"); + for (i, (mint_url, (balance, unit))) in available_mints.iter().enumerate() { + println!( + " {}: {} - {} {}", + i, + mint_url, + balance, + multi_mint_wallet.unit() + ); + } + + let mint_number: usize = get_number_input(prompt)?; + available_mints + .get(mint_number) + .map(|(url, _)| (*url).clone()) + .ok_or_else(|| anyhow::anyhow!("Invalid mint number")) +} + +pub async fn transfer( + multi_mint_wallet: &MultiMintWallet, + sub_command_args: &TransferSubCommand, +) -> Result<()> { + // Check total balance across all wallets + let total_balance = multi_mint_wallet.total_balance().await?; + if total_balance == Amount::ZERO { + bail!("No funds available"); + } + + // Get source mint URL either from args or by prompting user + let source_mint_url = if let Some(source_mint) = &sub_command_args.source_mint { + let url = MintUrl::from_str(source_mint)?; + // Verify the mint is in the wallet + if !multi_mint_wallet.has_mint(&url).await { + bail!( + "Source mint {} is not in the wallet. Please add it first.", + url + ); + } + url + } else { + // Show available mints and let user select source + select_mint( + multi_mint_wallet, + "Enter source mint number to transfer from", + None, + ) + .await? + }; + + // Get target mint URL either from args or by prompting user + let target_mint_url = if let Some(target_mint) = &sub_command_args.target_mint { + let url = MintUrl::from_str(target_mint)?; + // Verify the mint is in the wallet + if !multi_mint_wallet.has_mint(&url).await { + bail!( + "Target mint {} is not in the wallet. Please add it first.", + url + ); + } + url + } else { + // Show available mints (excluding source) and let user select target + select_mint( + multi_mint_wallet, + "Enter target mint number to transfer to", + Some(&source_mint_url), + ) + .await? + }; + + // Ensure source and target are different + if source_mint_url == target_mint_url { + bail!("Source and target mints must be different"); + } + + // Check source mint balance + let balances = multi_mint_wallet.get_balances().await?; + let source_balance = balances + .get(&source_mint_url) + .map(|(balance, _)| balance) + .cloned() + .unwrap_or(Amount::ZERO); + + if source_balance == Amount::ZERO { + bail!("Source mint has no balance to transfer"); + } + + // Determine transfer mode based on user input + let transfer_mode = if sub_command_args.full_balance { + println!( + "\nTransferring full balance ({} {}) from {} to {}...", + source_balance, + multi_mint_wallet.unit(), + source_mint_url, + target_mint_url + ); + TransferMode::FullBalance + } else { + let amount = match sub_command_args.amount { + Some(amt) => Amount::from(amt), + None => Amount::from(get_number_input::(&format!( + "Enter amount to transfer in {}", + multi_mint_wallet.unit() + ))?), + }; + + if source_balance < amount { + bail!( + "Insufficient funds in source mint. Available: {} {}, Required: {} {}", + source_balance, + multi_mint_wallet.unit(), + amount, + multi_mint_wallet.unit() + ); + } + + println!( + "\nTransferring {} {} from {} to {}...", + amount, + multi_mint_wallet.unit(), + source_mint_url, + target_mint_url + ); + TransferMode::ExactReceive(amount) + }; + + // Perform the transfer + let transfer_result = multi_mint_wallet + .transfer(&source_mint_url, &target_mint_url, transfer_mode) + .await?; + + println!("\nTransfer completed successfully!"); + println!( + "Amount sent: {} {}", + transfer_result.amount_sent, + multi_mint_wallet.unit() + ); + println!( + "Amount received: {} {}", + transfer_result.amount_received, + multi_mint_wallet.unit() + ); + if transfer_result.fees_paid > Amount::ZERO { + println!( + "Fees paid: {} {}", + transfer_result.fees_paid, + multi_mint_wallet.unit() + ); + } + println!("\nUpdated balances:"); + println!( + " Source mint ({}): {} {}", + source_mint_url, + transfer_result.source_balance_after, + multi_mint_wallet.unit() + ); + println!( + " Target mint ({}): {} {}", + target_mint_url, + transfer_result.target_balance_after, + multi_mint_wallet.unit() + ); + + Ok(()) +} diff --git a/crates/cdk-cli/src/sub_commands/update_mint_url.rs b/crates/cdk-cli/src/sub_commands/update_mint_url.rs index b67495b1c..51afc6fc1 100644 --- a/crates/cdk-cli/src/sub_commands/update_mint_url.rs +++ b/crates/cdk-cli/src/sub_commands/update_mint_url.rs @@ -1,7 +1,5 @@ use anyhow::{anyhow, Result}; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; -use cdk::wallet::types::WalletKey; use cdk::wallet::MultiMintWallet; use clap::Args; @@ -23,10 +21,7 @@ pub async fn update_mint_url( } = sub_command_args; let mut wallet = multi_mint_wallet - .get_wallet(&WalletKey::new( - sub_command_args.old_mint_url.clone(), - CurrencyUnit::Sat, - )) + .get_wallet(&sub_command_args.old_mint_url) .await .ok_or(anyhow!("Unknown mint url"))? .clone(); diff --git a/crates/cdk-cli/src/utils.rs b/crates/cdk-cli/src/utils.rs index c71005098..06c3f3d22 100644 --- a/crates/cdk-cli/src/utils.rs +++ b/crates/cdk-cli/src/utils.rs @@ -1,12 +1,9 @@ use std::io::{self, Write}; use std::str::FromStr; -use anyhow::{bail, Result}; +use anyhow::Result; use cdk::mint_url::MintUrl; -use cdk::nuts::CurrencyUnit; use cdk::wallet::multi_mint_wallet::MultiMintWallet; -use cdk::wallet::types::WalletKey; -use cdk::Amount; /// Helper function to get user input with a prompt pub fn get_user_input(prompt: &str) -> Result { @@ -28,73 +25,21 @@ where Ok(number) } -/// Helper function to validate a mint number against available mints -pub fn validate_mint_number(mint_number: usize, mint_count: usize) -> Result<()> { - if mint_number >= mint_count { - bail!("Invalid mint number"); - } - Ok(()) -} - -/// Helper function to check if there are enough funds for an operation -pub fn check_sufficient_funds(available: Amount, required: Amount) -> Result<()> { - if required.gt(&available) { - bail!("Not enough funds"); - } - Ok(()) -} - -/// Helper function to get a wallet from the multi-mint wallet by mint URL -pub async fn get_wallet_by_mint_url( - multi_mint_wallet: &MultiMintWallet, - mint_url_str: &str, - unit: CurrencyUnit, -) -> Result { - let mint_url = MintUrl::from_str(mint_url_str)?; - - let wallet_key = WalletKey::new(mint_url.clone(), unit); - let wallet = multi_mint_wallet - .get_wallet(&wallet_key) - .await - .ok_or_else(|| anyhow::anyhow!("Wallet not found for mint URL: {}", mint_url_str))?; - - Ok(wallet.clone()) -} - -/// Helper function to get a wallet from the multi-mint wallet -pub async fn get_wallet_by_index( - multi_mint_wallet: &MultiMintWallet, - mint_amounts: &[(MintUrl, Amount)], - mint_number: usize, - unit: CurrencyUnit, -) -> Result { - validate_mint_number(mint_number, mint_amounts.len())?; - - let wallet_key = WalletKey::new(mint_amounts[mint_number].0.clone(), unit); - let wallet = multi_mint_wallet - .get_wallet(&wallet_key) - .await - .ok_or_else(|| anyhow::anyhow!("Wallet not found"))?; - - Ok(wallet.clone()) -} - /// Helper function to create or get a wallet pub async fn get_or_create_wallet( multi_mint_wallet: &MultiMintWallet, mint_url: &MintUrl, - unit: CurrencyUnit, ) -> Result { - match multi_mint_wallet - .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) - .await - { + match multi_mint_wallet.get_wallet(mint_url).await { Some(wallet) => Ok(wallet.clone()), None => { tracing::debug!("Wallet does not exist creating.."); - multi_mint_wallet - .create_and_add_wallet(&mint_url.to_string(), unit, None) + multi_mint_wallet.add_mint(mint_url.clone()).await?; + Ok(multi_mint_wallet + .get_wallet(mint_url) .await + .expect("Wallet should exist after adding mint") + .clone()) } } } diff --git a/crates/cdk-cln/Cargo.toml b/crates/cdk-cln/Cargo.toml index e2d5a9d6a..c0a9b9748 100644 --- a/crates/cdk-cln/Cargo.toml +++ b/crates/cdk-cln/Cargo.toml @@ -22,3 +22,6 @@ tracing.workspace = true thiserror.workspace = true uuid.workspace = true serde_json.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +uuid = { workspace = true, features = ["js"] } diff --git a/crates/cdk-cln/README.md b/crates/cdk-cln/README.md index 97e954888..fd80efbe4 100644 --- a/crates/cdk-cln/README.md +++ b/crates/cdk-cln/README.md @@ -17,4 +17,41 @@ Add this to your `Cargo.toml`: cdk-cln = "*" ``` +## Configuration for cdk-mintd + +### Config File + +```toml +[ln] +ln_backend = "cln" + +[cln] +rpc_path = "/path/to/.lightning/bitcoin/lightning-rpc" +bolt12 = true # Optional, defaults to true +fee_percent = 0.02 # Optional, defaults to 2% +reserve_fee_min = 2 # Optional, defaults to 2 sats +``` + +### Environment Variables + +All configuration can be set via environment variables: + +| Variable | Description | Required | +|----------|-------------|----------| +| `CDK_MINTD_LN_BACKEND` | Set to `cln` | Yes | +| `CDK_MINTD_CLN_RPC_PATH` | Path to CLN RPC socket | Yes | +| `CDK_MINTD_CLN_BOLT12` | Enable BOLT12 support (default: `true`) | No | +| `CDK_MINTD_CLN_FEE_PERCENT` | Fee percentage (default: `0.02`) | No | +| `CDK_MINTD_CLN_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No | + +### Example + +```bash +export CDK_MINTD_LN_BACKEND=cln +export CDK_MINTD_CLN_RPC_PATH=/home/user/.lightning/bitcoin/lightning-rpc +cdk-mintd +``` + +## License + This project is licensed under the [MIT License](../../LICENSE). diff --git a/crates/cdk-cln/src/error.rs b/crates/cdk-cln/src/error.rs index 7fd489ff1..85025b865 100644 --- a/crates/cdk-cln/src/error.rs +++ b/crates/cdk-cln/src/error.rs @@ -26,6 +26,15 @@ pub enum Error { /// Amount Error #[error(transparent)] Amount(#[from] cdk_common::amount::Error), + /// UTF-8 Error + #[error(transparent)] + Utf8(#[from] std::string::FromUtf8Error), + /// Bolt12 Error + #[error("Bolt12 error: {0}")] + Bolt12(String), + /// Database Error + #[error("Database error: {0}")] + Database(String), } impl From for cdk_common::payment::Error { diff --git a/crates/cdk-cln/src/lib.rs b/crates/cdk-cln/src/lib.rs index 916e30e3b..392c7d4e3 100644 --- a/crates/cdk-cln/src/lib.rs +++ b/crates/cdk-cln/src/lib.rs @@ -10,33 +10,45 @@ use std::pin::Pin; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::Duration; use async_trait::async_trait; +use bitcoin::hashes::sha256::Hash; use cdk_common::amount::{to_unit, Amount}; use cdk_common::common::FeeReserve; -use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState}; +use cdk_common::database::mint::DynMintKVStore; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ - self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment, - PaymentQuoteResponse, + self, Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions, + CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment, + OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, WaitPaymentResponse, }; use cdk_common::util::{hex, unix_time}; -use cdk_common::{mint, Bolt11Invoice}; +use cdk_common::Bolt11Invoice; use cln_rpc::model::requests::{ - InvoiceRequest, ListinvoicesRequest, ListpaysRequest, PayRequest, WaitanyinvoiceRequest, + DecodeRequest, FetchinvoiceRequest, InvoiceRequest, ListinvoicesRequest, ListpaysRequest, + OfferRequest, PayRequest, WaitanyinvoiceRequest, }; use cln_rpc::model::responses::{ - ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPaysStatus, PayStatus, - WaitanyinvoiceStatus, + DecodeResponse, ListinvoicesInvoices, ListinvoicesInvoicesStatus, ListpaysPaysStatus, + PayStatus, WaitanyinvoiceResponse, WaitanyinvoiceStatus, }; -use cln_rpc::primitives::{Amount as CLN_Amount, AmountOrAny}; +use cln_rpc::primitives::{Amount as CLN_Amount, AmountOrAny, Sha256}; +use cln_rpc::ClnRpc; use error::Error; use futures::{Stream, StreamExt}; use serde_json::Value; use tokio_util::sync::CancellationToken; +use tracing::instrument; use uuid::Uuid; pub mod error; +// KV Store constants for CLN +const CLN_KV_PRIMARY_NAMESPACE: &str = "cdk_cln_lightning_backend"; +const CLN_KV_SECONDARY_NAMESPACE: &str = "payment_indices"; +const LAST_PAY_INDEX_KV_KEY: &str = "last_pay_index"; + /// CLN mint backend #[derive(Clone)] pub struct Cln { @@ -44,16 +56,22 @@ pub struct Cln { fee_reserve: FeeReserve, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, + kv_store: DynMintKVStore, } impl Cln { /// Create new [`Cln`] - pub async fn new(rpc_socket: PathBuf, fee_reserve: FeeReserve) -> Result { + pub async fn new( + rpc_socket: PathBuf, + fee_reserve: FeeReserve, + kv_store: DynMintKVStore, + ) -> Result { Ok(Self { rpc_socket, fee_reserve, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + kv_store, }) } } @@ -68,6 +86,7 @@ impl MintPayment for Cln { unit: CurrencyUnit::Msat, invoice_description: true, amountless: true, + bolt12: true, })?) } @@ -81,86 +100,188 @@ impl MintPayment for Cln { self.wait_invoice_cancel_token.cancel() } - async fn wait_any_incoming_payment( + #[instrument(skip_all)] + async fn wait_payment_event( &self, - ) -> Result + Send>>, Self::Err> { - let last_pay_index = self.get_last_pay_index().await?; - let cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; + ) -> Result + Send>>, Self::Err> { + tracing::info!( + "CLN: Starting wait_any_incoming_payment with socket: {:?}", + self.rpc_socket + ); + + let last_pay_index = self.get_last_pay_index().await?.inspect(|&idx| { + tracing::info!("CLN: Found last payment index: {}", idx); + }); + + tracing::debug!("CLN: Connecting to CLN node..."); + let cln_client = match cln_rpc::ClnRpc::new(&self.rpc_socket).await { + Ok(client) => { + tracing::debug!("CLN: Successfully connected to CLN node"); + client + } + Err(err) => { + tracing::error!("CLN: Failed to connect to CLN node: {}", err); + return Err(Error::from(err).into()); + } + }; + tracing::debug!("CLN: Creating stream processing pipeline"); + let kv_store = self.kv_store.clone(); let stream = futures::stream::unfold( ( cln_client, last_pay_index, self.wait_invoice_cancel_token.clone(), Arc::clone(&self.wait_invoice_is_active), + kv_store, ), - |(mut cln_client, mut last_pay_idx, cancel_token, is_active)| async move { + |(mut cln_client, mut last_pay_idx, cancel_token, is_active, kv_store)| async move { // Set the stream as active is_active.store(true, Ordering::SeqCst); + tracing::debug!("CLN: Stream is now active, waiting for invoice events with lastpay_index: {:?}", last_pay_idx); loop { - let request = WaitanyinvoiceRequest { - timeout: None, - lastpay_index: last_pay_idx, - }; tokio::select! { _ = cancel_token.cancelled() => { // Set the stream as inactive is_active.store(false, Ordering::SeqCst); + tracing::info!("CLN: Invoice stream cancelled"); // End the stream return None; } - result = cln_client.call_typed(&request) => { + result = cln_client.call(cln_rpc::Request::WaitAnyInvoice(WaitanyinvoiceRequest { + timeout: None, + lastpay_index: last_pay_idx, + })) => { + tracing::debug!("CLN: Received response from WaitAnyInvoice call"); match result { Ok(invoice) => { + tracing::debug!("CLN: Successfully received invoice data"); + // Try to convert the invoice to WaitanyinvoiceResponse + let wait_any_response_result: Result = + invoice.try_into(); + + let wait_any_response = match wait_any_response_result { + Ok(response) => { + tracing::debug!("CLN: Parsed WaitAnyInvoice response successfully"); + response + } + Err(e) => { + tracing::warn!( + "CLN: Failed to parse WaitAnyInvoice response: {:?}", + e + ); + // Continue to the next iteration without panicking + continue; + } + }; // Check the status of the invoice // We only want to yield invoices that have been paid - match invoice.status { - WaitanyinvoiceStatus::PAID => (), - WaitanyinvoiceStatus::EXPIRED => continue, + match wait_any_response.status { + WaitanyinvoiceStatus::PAID => { + tracing::info!("CLN: Invoice with payment index {} is PAID", + wait_any_response.pay_index.unwrap_or_default()); + } + WaitanyinvoiceStatus::EXPIRED => { + tracing::debug!("CLN: Invoice with payment index {} is EXPIRED, skipping", + wait_any_response.pay_index.unwrap_or_default()); + continue; + } } - last_pay_idx = invoice.pay_index; + last_pay_idx = wait_any_response.pay_index; + tracing::debug!("CLN: Updated last_pay_idx to {:?}", last_pay_idx); - let payment_hash = invoice.payment_hash.to_string(); - let request_look_up = match invoice.bolt12 { + // Store the updated pay index in KV store for persistence + if let Some(pay_index) = last_pay_idx { + let index_str = pay_index.to_string(); + if let Ok(mut tx) = kv_store.begin_transaction().await { + if let Err(e) = tx.kv_write(CLN_KV_PRIMARY_NAMESPACE, CLN_KV_SECONDARY_NAMESPACE, LAST_PAY_INDEX_KV_KEY, index_str.as_bytes()).await { + tracing::warn!("CLN: Failed to write last pay index {} to KV store: {}", pay_index, e); + } else if let Err(e) = tx.commit().await { + tracing::warn!("CLN: Failed to commit last pay index {} to KV store: {}", pay_index, e); + } else { + tracing::debug!("CLN: Stored last pay index {} in KV store", pay_index); + } + } else { + tracing::warn!("CLN: Failed to begin KV transaction for storing pay index {}", pay_index); + } + } + + let payment_hash = wait_any_response.payment_hash; + tracing::debug!("CLN: Payment hash: {}", payment_hash); + + let amount_msats = match wait_any_response.amount_received_msat { + Some(amt) => { + tracing::info!("CLN: Received payment of {} msats for {}", + amt.msat(), payment_hash); + amt + } + None => { + tracing::error!("CLN: No amount in paid invoice, this should not happen"); + continue; + } + }; + + let payment_hash = Hash::from_bytes_ref(payment_hash.as_ref()); + + let request_lookup_id = match wait_any_response.bolt12 { // If it is a bolt12 payment we need to get the offer_id as this is what we use as the request look up. // Since this is not returned in the wait any response, // we need to do a second query for it. - Some(_) => { + Some(bolt12) => { + tracing::info!("CLN: Processing BOLT12 payment, bolt12 value: {}", bolt12); match fetch_invoice_by_payment_hash( &mut cln_client, - &payment_hash, + payment_hash, ) .await { Ok(Some(invoice)) => { if let Some(local_offer_id) = invoice.local_offer_id { - local_offer_id.to_string() + tracing::info!("CLN: Received bolt12 payment of {} msats for offer {}", + amount_msats.msat(), local_offer_id); + PaymentIdentifier::OfferId(local_offer_id.to_string()) } else { + tracing::warn!("CLN: BOLT12 invoice has no local_offer_id, skipping"); continue; } } - Ok(None) => continue, + Ok(None) => { + tracing::warn!("CLN: Failed to find invoice by payment hash, skipping"); + continue; + } Err(e) => { tracing::warn!( - "Error fetching invoice by payment hash: {e}" + "CLN: Error fetching invoice by payment hash: {e}" ); continue; } } } - None => payment_hash, + None => { + tracing::info!("CLN: Processing BOLT11 payment with hash {}", payment_hash); + PaymentIdentifier::PaymentHash(*payment_hash.as_ref()) + }, }; - return Some((request_look_up, (cln_client, last_pay_idx, cancel_token, is_active))); + let response = WaitPaymentResponse { + payment_identifier: request_lookup_id, + payment_amount: amount_msats.msat().into(), + unit: CurrencyUnit::Msat, + payment_id: payment_hash.to_string() + }; + tracing::info!("CLN: Created WaitPaymentResponse with amount {} msats", amount_msats.msat()); + let event = Event::PaymentReceived(response); + + break Some((event, (cln_client, last_pay_idx, cancel_token, is_active, kv_store))); } Err(e) => { - tracing::warn!("Error fetching invoice: {e}"); - is_active.store(false, Ordering::SeqCst); - return None; + tracing::warn!("CLN: Error fetching invoice: {e}"); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; } } } @@ -170,80 +291,190 @@ impl MintPayment for Cln { ) .boxed(); + tracing::info!("CLN: Successfully initialized invoice stream"); Ok(stream) } + #[instrument(skip_all)] async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(request)?; - - let amount_msat = match options { - Some(amount) => amount.amount_msat(), - None => bolt11 - .amount_milli_satoshis() - .ok_or(Error::UnknownInvoiceAmount)? - .into(), - }; - - let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; - - let relative_fee_reserve = - (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; - - let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); - - let fee = max(relative_fee_reserve, absolute_fee_reserve); + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + // If we have specific amount options, use those + let amount_msat: Amount = if let Some(melt_options) = bolt11_options.melt_options { + match melt_options { + MeltOptions::Amountless { amountless } => { + let amount_msat = amountless.amount_msat; + + if let Some(invoice_amount) = + bolt11_options.bolt11.amount_milli_satoshis() + { + if !invoice_amount == u64::from(amount_msat) { + return Err(payment::Error::AmountMismatch); + } + } + amount_msat + } + MeltOptions::Mpp { mpp } => mpp.amount, + } + } else { + // Fall back to invoice amount + bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + .into() + }; + // Convert to target unit + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + // Calculate fee + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + let fee = max(relative_fee_reserve, absolute_fee_reserve); + + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::PaymentHash( + *bolt11_options.bolt11.payment_hash().as_ref(), + )), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = bolt12_options.offer; + + let amount_msat: u64 = if let Some(amount) = bolt12_options.melt_options { + amount.amount_msat().into() + } else { + // Fall back to offer amount + let decode_response = self.decode_string(offer.to_string()).await?; + + decode_response + .offer_amount_msat + .ok_or(Error::UnknownInvoiceAmount)? + .msat() + }; - Ok(PaymentQuoteResponse { - request_lookup_id: bolt11.payment_hash().to_string(), - amount, - unit: unit.clone(), - fee: fee.into(), - state: MeltQuoteState::Unpaid, - }) + // Convert to target unit + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + // Calculate fee + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + let fee = max(relative_fee_reserve, absolute_fee_reserve); + + Ok(PaymentQuoteResponse { + request_lookup_id: None, + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) + } + } } + #[instrument(skip_all)] async fn make_payment( &self, - melt_quote: mint::MeltQuote, - partial_amount: Option, - max_fee: Option, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?; - let pay_state = self - .check_outgoing_payment(&bolt11.payment_hash().to_string()) - .await?; + let max_fee_msat: Option; + let mut partial_amount: Option = None; + let mut amount_msat: Option = None; - match pay_state.status { - MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => (), - MeltQuoteState::Paid => { - tracing::debug!("Melt attempted on invoice already paid"); - return Err(Self::Err::InvoiceAlreadyPaid); - } - MeltQuoteState::Pending => { - tracing::debug!("Melt attempted on invoice already pending"); - return Err(Self::Err::InvoicePaymentPending); + let mut cln_client = self.cln_client().await?; + + let invoice = match &options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let payment_identifier = + PaymentIdentifier::PaymentHash(*bolt11_options.bolt11.payment_hash().as_ref()); + + self.check_outgoing_unpaided(&payment_identifier).await?; + + if let Some(melt_options) = bolt11_options.melt_options { + match melt_options { + MeltOptions::Mpp { mpp } => partial_amount = Some(mpp.amount.into()), + MeltOptions::Amountless { amountless } => { + amount_msat = Some(amountless.amount_msat.into()); + } + } + } + + max_fee_msat = bolt11_options.max_fee_amount.map(|a| a.into()); + + bolt11_options.bolt11.to_string() } - } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = &bolt12_options.offer; + + let amount_msat: u64 = if let Some(amount) = bolt12_options.melt_options { + amount.amount_msat().into() + } else { + // Fall back to offer amount + let decode_response = self.decode_string(offer.to_string()).await?; + + decode_response + .offer_amount_msat + .ok_or(Error::UnknownInvoiceAmount)? + .msat() + }; - let amount_msat = partial_amount - .is_none() - .then(|| { - melt_quote - .msat_to_pay - .map(|a| CLN_Amount::from_msat(a.into())) - }) - .flatten(); + // Fetch invoice from offer + + let cln_response = cln_client + .call_typed(&FetchinvoiceRequest { + amount_msat: Some(CLN_Amount::from_msat(amount_msat)), + payer_metadata: None, + payer_note: None, + quantity: None, + recurrence_counter: None, + recurrence_label: None, + recurrence_start: None, + timeout: None, + offer: offer.to_string(), + bip353: None, + }) + .await + .map_err(|err| { + tracing::error!("Could not fetch invoice for offer: {:?}", err); + Error::ClnRpc(err) + })?; + + let decode_response = self.decode_string(cln_response.invoice.clone()).await?; + + let payment_identifier = PaymentIdentifier::Bolt12PaymentHash( + hex::decode( + decode_response + .invoice_payment_hash + .ok_or(Error::UnknownInvoice)?, + ) + .map_err(|e| Error::Bolt12(e.to_string()))? + .try_into() + .map_err(|_| Error::InvalidHash)?, + ); + + self.check_outgoing_unpaided(&payment_identifier).await?; + + max_fee_msat = bolt12_options.max_fee_amount.map(|a| a.into()); + + cln_response.invoice + } + }; - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; let cln_response = cln_client .call_typed(&PayRequest { - bolt11: melt_quote.request.to_string(), - amount_msat, + bolt11: invoice, + amount_msat: amount_msat.map(CLN_Amount::from_msat), label: None, riskfactor: None, maxfeepercent: None, @@ -252,22 +483,9 @@ impl MintPayment for Cln { exemptfee: None, localinvreqid: None, exclude: None, - maxfee: max_fee - .map(|a| { - let msat = to_unit(a, &melt_quote.unit, &CurrencyUnit::Msat)?; - Ok::(CLN_Amount::from_msat(msat.into())) - }) - .transpose()?, + maxfee: max_fee_msat.map(CLN_Amount::from_msat), description: None, - partial_msat: partial_amount - .map(|a| { - let msat = to_unit(a, &melt_quote.unit, &CurrencyUnit::Msat)?; - - Ok::(CLN_Amount::from_msat( - msat.into(), - )) - }) - .transpose()?, + partial_msat: partial_amount.map(CLN_Amount::from_msat), }) .await; @@ -279,16 +497,25 @@ impl MintPayment for Cln { PayStatus::FAILED => MeltQuoteState::Failed, }; + let payment_identifier = match options { + OutgoingPaymentOptions::Bolt11(_) => { + PaymentIdentifier::PaymentHash(*pay_response.payment_hash.as_ref()) + } + OutgoingPaymentOptions::Bolt12(_) => { + PaymentIdentifier::Bolt12PaymentHash(*pay_response.payment_hash.as_ref()) + } + }; + MakePaymentResponse { payment_proof: Some(hex::encode(pay_response.payment_preimage.to_vec())), - payment_lookup_id: pay_response.payment_hash.to_string(), + payment_lookup_id: payment_identifier, status, total_spent: to_unit( pay_response.amount_sent_msat.msat(), &CurrencyUnit::Msat, - &melt_quote.unit, + unit, )?, - unit: melt_quote.unit, + unit: unit.clone(), } } Err(err) => { @@ -300,90 +527,202 @@ impl MintPayment for Cln { Ok(response) } + #[instrument(skip_all)] async fn create_incoming_payment_request( &self, - amount: Amount, unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + options: IncomingPaymentOptions, ) -> Result { - let time_now = unix_time(); - - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; - - let label = Uuid::new_v4().to_string(); - - let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; - let amount_msat = AmountOrAny::Amount(CLN_Amount::from_msat(amount.into())); - - let invoice_response = cln_client - .call_typed(&InvoiceRequest { - amount_msat, + match options { + IncomingPaymentOptions::Bolt11(Bolt11IncomingPaymentOptions { description, - label: label.clone(), - expiry: unix_expiry.map(|t| t - time_now), - fallbacks: None, - preimage: None, - cltv: None, - deschashonly: None, - exposeprivatechannels: None, - }) - .await - .map_err(Error::from)?; + amount, + unix_expiry, + }) => { + let time_now = unix_time(); + + let mut cln_client = self.cln_client().await?; + + let label = Uuid::new_v4().to_string(); + + let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + let amount_msat = AmountOrAny::Amount(CLN_Amount::from_msat(amount.into())); + + let invoice_response = cln_client + .call_typed(&InvoiceRequest { + amount_msat, + description: description.unwrap_or_default(), + label: label.clone(), + expiry: unix_expiry.map(|t| t - time_now), + fallbacks: None, + preimage: None, + cltv: None, + deschashonly: None, + exposeprivatechannels: None, + }) + .await + .map_err(Error::from)?; - let request = Bolt11Invoice::from_str(&invoice_response.bolt11)?; - let expiry = request.expires_at().map(|t| t.as_secs()); - let payment_hash = request.payment_hash(); + let request = Bolt11Invoice::from_str(&invoice_response.bolt11)?; + let expiry = request.expires_at().map(|t| t.as_secs()); + let payment_hash = request.payment_hash(); - Ok(CreateIncomingPaymentResponse { - request_lookup_id: payment_hash.to_string(), - request: request.to_string(), - expiry, - }) + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::PaymentHash(*payment_hash.as_ref()), + request: request.to_string(), + expiry, + }) + } + IncomingPaymentOptions::Bolt12(bolt12_options) => { + let Bolt12IncomingPaymentOptions { + description, + amount, + unix_expiry, + } = *bolt12_options; + let mut cln_client = self.cln_client().await?; + + let label = Uuid::new_v4().to_string(); + + // Match like this until we change to option + let amount = match amount { + Some(amount) => { + let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + + amount.to_string() + } + None => "any".to_string(), + }; + + // It seems that the only way to force cln to create a unique offer + // is to encode some random data in the offer + let issuer = Uuid::new_v4().to_string(); + + let offer_response = cln_client + .call_typed(&OfferRequest { + amount, + absolute_expiry: unix_expiry, + description: Some(description.unwrap_or_default()), + issuer: Some(issuer.to_string()), + label: Some(label.to_string()), + single_use: None, + quantity_max: None, + recurrence: None, + recurrence_base: None, + recurrence_limit: None, + recurrence_paywindow: None, + recurrence_start_any_period: None, + }) + .await + .map_err(Error::from)?; + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::OfferId( + offer_response.offer_id.to_string(), + ), + request: offer_response.bolt12, + expiry: unix_expiry, + }) + } + } } + #[instrument(skip(self))] async fn check_incoming_payment_status( &self, - payment_hash: &str, - ) -> Result { - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; - - let listinvoices_response = cln_client - .call_typed(&ListinvoicesRequest { - payment_hash: Some(payment_hash.to_string()), - label: None, - invstring: None, - offer_id: None, - index: None, - limit: None, - start: None, - }) - .await - .map_err(Error::from)?; - - let status = match listinvoices_response.invoices.first() { - Some(invoice_response) => cln_invoice_status_to_mint_state(invoice_response.status), - None => { - tracing::info!( - "Check invoice called on unknown look up id: {}", - payment_hash - ); - return Err(Error::WrongClnResponse.into()); + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let mut cln_client = self.cln_client().await?; + + let listinvoices_response = match payment_identifier { + PaymentIdentifier::Label(label) => { + // Query by label + cln_client + .call_typed(&ListinvoicesRequest { + payment_hash: None, + label: Some(label.to_string()), + invstring: None, + offer_id: None, + index: None, + limit: None, + start: None, + }) + .await + .map_err(Error::from)? + } + PaymentIdentifier::OfferId(offer_id) => { + // Query by offer_id + cln_client + .call_typed(&ListinvoicesRequest { + payment_hash: None, + label: None, + invstring: None, + offer_id: Some(offer_id.to_string()), + index: None, + limit: None, + start: None, + }) + .await + .map_err(Error::from)? + } + PaymentIdentifier::PaymentHash(payment_hash) => { + // Query by payment_hash + cln_client + .call_typed(&ListinvoicesRequest { + payment_hash: Some(hex::encode(payment_hash)), + label: None, + invstring: None, + offer_id: None, + index: None, + limit: None, + start: None, + }) + .await + .map_err(Error::from)? + } + _ => { + tracing::error!("Unsupported payment id for CLN"); + return Err(payment::Error::UnknownPaymentState); } }; - Ok(status) + Ok(listinvoices_response + .invoices + .iter() + .filter(|p| p.status == ListinvoicesInvoicesStatus::PAID) + .filter(|p| p.amount_msat.is_some()) // Filter out invoices without an amount + .map(|p| WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: p + .amount_msat + // Safe to expect since we filtered for Some + .expect("We have filter out those without amounts") + .msat() + .into(), + unit: CurrencyUnit::Msat, + payment_id: p.payment_hash.to_string(), + }) + .collect()) } + #[instrument(skip(self))] async fn check_outgoing_payment( &self, - payment_hash: &str, + payment_identifier: &PaymentIdentifier, ) -> Result { - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; + let mut cln_client = self.cln_client().await?; + + let payment_hash = match payment_identifier { + PaymentIdentifier::PaymentHash(hash) => hash, + PaymentIdentifier::Bolt12PaymentHash(hash) => hash, + _ => { + tracing::error!("Unsupported identifier to check outgoing payment for cln."); + return Err(payment::Error::UnknownPaymentState); + } + }; let listpays_response = cln_client .call_typed(&ListpaysRequest { - payment_hash: Some(payment_hash.parse().map_err(|_| Error::InvalidHash)?), + payment_hash: Some(*Sha256::from_bytes_ref(payment_hash)), bolt11: None, status: None, start: None, @@ -398,7 +737,7 @@ impl MintPayment for Cln { let status = cln_pays_status_to_mint_state(pays_response.status); Ok(MakePaymentResponse { - payment_lookup_id: pays_response.payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: pays_response.preimage.map(|p| hex::encode(p.to_vec())), status, total_spent: pays_response @@ -408,7 +747,7 @@ impl MintPayment for Cln { }) } None => Ok(MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: None, status: MeltQuoteState::Unknown, total_spent: Amount::ZERO, @@ -419,9 +758,34 @@ impl MintPayment for Cln { } impl Cln { + async fn cln_client(&self) -> Result { + Ok(cln_rpc::ClnRpc::new(&self.rpc_socket).await?) + } + /// Get last pay index for cln async fn get_last_pay_index(&self) -> Result, Error> { - let mut cln_client = cln_rpc::ClnRpc::new(&self.rpc_socket).await?; + // First try to read from KV store + if let Some(stored_index) = self + .kv_store + .kv_read( + CLN_KV_PRIMARY_NAMESPACE, + CLN_KV_SECONDARY_NAMESPACE, + LAST_PAY_INDEX_KV_KEY, + ) + .await + .map_err(|e| Error::Database(e.to_string()))? + { + if let Ok(index_str) = std::str::from_utf8(&stored_index) { + if let Ok(index) = index_str.parse::() { + tracing::debug!("CLN: Retrieved last pay index {} from KV store", index); + return Ok(Some(index)); + } + } + } + + // Fall back to querying CLN directly + tracing::debug!("CLN: No stored last pay index found in KV store, querying CLN directly"); + let mut cln_client = self.cln_client().await?; let listinvoices_response = cln_client .call_typed(&ListinvoicesRequest { index: None, @@ -440,13 +804,40 @@ impl Cln { None => Ok(None), } } -} -fn cln_invoice_status_to_mint_state(status: ListinvoicesInvoicesStatus) -> MintQuoteState { - match status { - ListinvoicesInvoicesStatus::UNPAID => MintQuoteState::Unpaid, - ListinvoicesInvoicesStatus::PAID => MintQuoteState::Paid, - ListinvoicesInvoicesStatus::EXPIRED => MintQuoteState::Unpaid, + /// Decode string + #[instrument(skip(self))] + async fn decode_string(&self, string: String) -> Result { + let mut cln_client = self.cln_client().await?; + + cln_client + .call_typed(&DecodeRequest { string }) + .await + .map_err(|err| { + tracing::error!("Could not fetch invoice for offer: {:?}", err); + Error::ClnRpc(err) + }) + } + + /// Checks that outgoing payment is not already paid + #[instrument(skip(self))] + async fn check_outgoing_unpaided( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result<(), payment::Error> { + let pay_state = self.check_outgoing_payment(payment_identifier).await?; + + match pay_state.status { + MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => Ok(()), + MeltQuoteState::Paid => { + tracing::debug!("Melt attempted on invoice already paid"); + Err(payment::Error::InvoiceAlreadyPaid) + } + MeltQuoteState::Pending => { + tracing::debug!("Melt attempted on invoice already pending"); + Err(payment::Error::InvoicePaymentPending) + } + } } } @@ -460,23 +851,57 @@ fn cln_pays_status_to_mint_state(status: ListpaysPaysStatus) -> MeltQuoteState { async fn fetch_invoice_by_payment_hash( cln_client: &mut cln_rpc::ClnRpc, - payment_hash: &str, + payment_hash: &Hash, ) -> Result, Error> { - match cln_client - .call_typed(&ListinvoicesRequest { - payment_hash: Some(payment_hash.to_string()), - index: None, - invstring: None, - label: None, - limit: None, - offer_id: None, - start: None, - }) - .await - { - Ok(invoice_response) => Ok(invoice_response.invoices.first().cloned()), + tracing::debug!("Fetching invoice by payment hash: {}", payment_hash); + + let payment_hash_str = payment_hash.to_string(); + tracing::debug!("Payment hash string: {}", payment_hash_str); + + let request = ListinvoicesRequest { + payment_hash: Some(payment_hash_str), + index: None, + invstring: None, + label: None, + limit: None, + offer_id: None, + start: None, + }; + tracing::debug!("Created ListinvoicesRequest"); + + match cln_client.call_typed(&request).await { + Ok(invoice_response) => { + let invoice_count = invoice_response.invoices.len(); + tracing::debug!( + "Received {} invoices for payment hash {}", + invoice_count, + payment_hash + ); + + if invoice_count > 0 { + let first_invoice = invoice_response.invoices.first().cloned(); + if let Some(invoice) = &first_invoice { + tracing::debug!("Found invoice with payment hash {}", payment_hash); + tracing::debug!( + "Invoice details - local_offer_id: {:?}, status: {:?}", + invoice.local_offer_id, + invoice.status + ); + } else { + tracing::warn!("No invoice found with payment hash {}", payment_hash); + } + Ok(first_invoice) + } else { + tracing::warn!("No invoices returned for payment hash {}", payment_hash); + Ok(None) + } + } Err(e) => { - tracing::warn!("Error fetching invoice: {e}"); + tracing::error!( + "Error fetching invoice by payment hash {}: {}", + payment_hash, + e + ); Err(Error::from(e)) } } diff --git a/crates/cdk-common/Cargo.toml b/crates/cdk-common/Cargo.toml index 2c9ce3912..8424d21a5 100644 --- a/crates/cdk-common/Cargo.toml +++ b/crates/cdk-common/Cargo.toml @@ -18,6 +18,7 @@ bench = [] wallet = ["cashu/wallet"] mint = ["cashu/mint", "dep:uuid"] auth = ["cashu/auth"] +prometheus = ["cdk-prometheus/default"] [dependencies] async-trait.workspace = true @@ -27,8 +28,10 @@ cbor-diag.workspace = true ciborium.workspace = true serde.workspace = true lightning-invoice.workspace = true +lightning.workspace = true thiserror.workspace = true tracing.workspace = true +cdk-prometheus = { workspace = true, optional = true} url.workspace = true uuid = { workspace = true, optional = true } utoipa = { workspace = true, optional = true } @@ -36,10 +39,22 @@ futures.workspace = true anyhow.workspace = true serde_json.workspace = true serde_with.workspace = true +web-time.workspace = true +tokio.workspace = true +parking_lot = "0.12.5" [target.'cfg(target_arch = "wasm32")'.dependencies] -instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] } +uuid = { workspace = true, features = ["js"], optional = true } +getrandom = { version = "0.2", features = ["js"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" [dev-dependencies] rand.workspace = true bip39.workspace = true +wasm-bindgen-test = "0.3" +criterion.workspace = true + +[[bench]] +name = "transaction_id_benchmark" +harness = false diff --git a/crates/cdk-common/benches/transaction_id_benchmark.rs b/crates/cdk-common/benches/transaction_id_benchmark.rs new file mode 100644 index 000000000..8242131d4 --- /dev/null +++ b/crates/cdk-common/benches/transaction_id_benchmark.rs @@ -0,0 +1,29 @@ +use cashu::nuts::nut01::SecretKey; +use cashu::PublicKey; +use cdk_common::wallet::TransactionId; +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; + +fn generate_public_keys(count: usize) -> Vec { + (0..count) + .map(|_| SecretKey::generate().public_key()) + .collect() +} + +fn bench_transaction_id(c: &mut Criterion) { + let mut group = c.benchmark_group("TransactionId::new"); + + let sizes = vec![1, 10, 50, 100, 500]; + + for size in sizes { + let public_keys = generate_public_keys(size); + + group.bench_with_input(BenchmarkId::from_parameter(size), &size, |b, _| { + b.iter(|| TransactionId::new(public_keys.clone())); + }); + } + + group.finish(); +} + +criterion_group!(benches, bench_transaction_id); +criterion_main!(benches); diff --git a/crates/cdk-common/src/common.rs b/crates/cdk-common/src/common.rs index b2920bb39..254a8d52b 100644 --- a/crates/cdk-common/src/common.rs +++ b/crates/cdk-common/src/common.rs @@ -31,7 +31,7 @@ impl Melted { pub fn from_proofs( state: MeltQuoteState, preimage: Option, - amount: Amount, + quote_amount: Amount, proofs: Proofs, change_proofs: Option, ) -> Result { @@ -41,16 +41,22 @@ impl Melted { None => Amount::ZERO, }; + tracing::info!( + "Proofs amount: {} Amount: {} Change: {}", + proofs_amount, + quote_amount, + change_amount + ); + let fee_paid = proofs_amount - .checked_sub(amount + change_amount) - .ok_or(Error::AmountOverflow) - .unwrap(); + .checked_sub(quote_amount + change_amount) + .ok_or(Error::AmountOverflow)?; Ok(Self { state, preimage, change: change_proofs, - amount, + amount: quote_amount, fee_paid, }) } @@ -162,8 +168,8 @@ impl PaymentProcessorKey { } } -/// Secs wuotes are valid -#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize, Default)] +/// Seconds quotes are valid +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct QuoteTTL { /// Seconds mint quote is valid pub mint_ttl: u64, @@ -178,6 +184,26 @@ impl QuoteTTL { } } +impl Default for QuoteTTL { + fn default() -> Self { + Self { + mint_ttl: 60 * 60, // 1 hour + melt_ttl: 60, // 1 minute + } + } +} + +/// Unit Metadata +#[derive(Debug, Default, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnitMetadata { + /// Human readable description of the unit + pub description: String, + /// URL for more info + pub url: String, + /// Whether the unit is non-fungible + pub is_non_fungible: bool, +} + #[cfg(test)] mod tests { use std::str::FromStr; diff --git a/crates/cdk-common/src/database/mint/auth/mod.rs b/crates/cdk-common/src/database/mint/auth/mod.rs index a71257ee3..26f258195 100644 --- a/crates/cdk-common/src/database/mint/auth/mod.rs +++ b/crates/cdk-common/src/database/mint/auth/mod.rs @@ -88,3 +88,7 @@ pub trait MintAuthDatabase { &self, ) -> Result>, Self::Err>; } + +/// Type alias for trait objects +pub type DynMintAuthDatabase = + std::sync::Arc + Send + Sync>; diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 95eb3db09..3cf85a1bc 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -3,16 +3,16 @@ use std::collections::HashMap; use async_trait::async_trait; -use cashu::MintInfo; -use uuid::Uuid; +use cashu::quote_id::QuoteId; +use cashu::Amount; use super::Error; -use crate::common::QuoteTTL; -use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote}; +use crate::mint::{self, MintKeySetInfo, MintQuote as MintMintQuote, Operation}; use crate::nuts::{ - BlindSignature, CurrencyUnit, Id, MeltQuoteState, MintQuoteState, Proof, Proofs, PublicKey, + BlindSignature, BlindedMessage, CurrencyUnit, Id, MeltQuoteState, Proof, Proofs, PublicKey, State, }; +use crate::payment::PaymentIdentifier; #[cfg(feature = "auth")] mod auth; @@ -21,7 +21,76 @@ mod auth; pub mod test; #[cfg(feature = "auth")] -pub use auth::{MintAuthDatabase, MintAuthTransaction}; +pub use auth::{DynMintAuthDatabase, MintAuthDatabase, MintAuthTransaction}; + +/// Valid ASCII characters for namespace and key strings in KV store +pub const KVSTORE_NAMESPACE_KEY_ALPHABET: &str = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"; + +/// Maximum length for namespace and key strings in KV store +pub const KVSTORE_NAMESPACE_KEY_MAX_LEN: usize = 120; + +/// Validates that a string contains only valid KV store characters and is within length limits +pub fn validate_kvstore_string(s: &str) -> Result<(), Error> { + if s.len() > KVSTORE_NAMESPACE_KEY_MAX_LEN { + return Err(Error::KVStoreInvalidKey(format!( + "{KVSTORE_NAMESPACE_KEY_MAX_LEN} exceeds maximum length of key characters" + ))); + } + + if !s + .chars() + .all(|c| KVSTORE_NAMESPACE_KEY_ALPHABET.contains(c)) + { + return Err(Error::KVStoreInvalidKey("key contains invalid characters. Only ASCII letters, numbers, underscore, and hyphen are allowed".to_string())); + } + + Ok(()) +} + +/// Validates namespace and key parameters for KV store operations +pub fn validate_kvstore_params( + primary_namespace: &str, + secondary_namespace: &str, + key: &str, +) -> Result<(), Error> { + // Validate primary namespace + validate_kvstore_string(primary_namespace)?; + + // Validate secondary namespace + validate_kvstore_string(secondary_namespace)?; + + // Validate key + validate_kvstore_string(key)?; + + // Check empty namespace rules + if primary_namespace.is_empty() && !secondary_namespace.is_empty() { + return Err(Error::KVStoreInvalidKey( + "If primary_namespace is empty, secondary_namespace must also be empty".to_string(), + )); + } + + // Check for potential collisions between keys and namespaces in the same namespace + let namespace_key = format!("{primary_namespace}/{secondary_namespace}"); + if key == primary_namespace || key == secondary_namespace || key == namespace_key { + return Err(Error::KVStoreInvalidKey(format!( + "Key '{key}' conflicts with namespace names" + ))); + } + + Ok(()) +} + +/// Information about a melt request stored in the database +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MeltRequestInfo { + /// Total amount of all input proofs in the melt request + pub inputs_amount: Amount, + /// Fee amount associated with the input proofs + pub inputs_fee: Amount, + /// Blinded messages for change outputs + pub change_outputs: Vec, +} /// KeysDatabaseWriter #[async_trait] @@ -39,7 +108,7 @@ pub trait KeysDatabase { /// Mint Keys Database Error type Err: Into + From; - /// Beings a transaction + /// Begins a transaction async fn begin_transaction<'a>( &'a self, ) -> Result + Send + Sync + 'a>, Error>; @@ -63,23 +132,62 @@ pub trait QuotesTransaction<'a> { /// Mint Quotes Database Error type Err: Into + From; + /// Add melt_request with quote_id, inputs_amount, and inputs_fee + async fn add_melt_request( + &mut self, + quote_id: &QuoteId, + inputs_amount: Amount, + inputs_fee: Amount, + ) -> Result<(), Self::Err>; + + /// Add blinded_messages for a quote_id + async fn add_blinded_messages( + &mut self, + quote_id: Option<&QuoteId>, + blinded_messages: &[BlindedMessage], + operation: &Operation, + ) -> Result<(), Self::Err>; + + /// Delete blinded_messages by their blinded secrets + async fn delete_blinded_messages( + &mut self, + blinded_secrets: &[PublicKey], + ) -> Result<(), Self::Err>; + + /// Get melt_request and associated blinded_messages by quote_id + async fn get_melt_request_and_blinded_messages( + &mut self, + quote_id: &QuoteId, + ) -> Result, Self::Err>; + + /// Delete melt_request and associated blinded_messages by quote_id + async fn delete_melt_request(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err>; + /// Get [`MintMintQuote`] and lock it for update in this transaction - async fn get_mint_quote(&mut self, quote_id: &Uuid) - -> Result, Self::Err>; + async fn get_mint_quote( + &mut self, + quote_id: &QuoteId, + ) -> Result, Self::Err>; /// Add [`MintMintQuote`] - async fn add_or_replace_mint_quote(&mut self, quote: MintMintQuote) -> Result<(), Self::Err>; - /// Update state of [`MintMintQuote`] - async fn update_mint_quote_state( + async fn add_mint_quote(&mut self, quote: MintMintQuote) -> Result<(), Self::Err>; + /// Increment amount paid [`MintMintQuote`] + async fn increment_mint_quote_amount_paid( + &mut self, + quote_id: &QuoteId, + amount_paid: Amount, + payment_id: String, + ) -> Result; + /// Increment amount paid [`MintMintQuote`] + async fn increment_mint_quote_amount_issued( &mut self, - quote_id: &Uuid, - state: MintQuoteState, - ) -> Result; - /// Remove [`MintMintQuote`] - async fn remove_mint_quote(&mut self, quote_id: &Uuid) -> Result<(), Self::Err>; + quote_id: &QuoteId, + amount_issued: Amount, + ) -> Result; + /// Get [`mint::MeltQuote`] and lock it for update in this transaction async fn get_melt_quote( &mut self, - quote_id: &Uuid, + quote_id: &QuoteId, ) -> Result, Self::Err>; /// Add [`mint::MeltQuote`] async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err>; @@ -87,8 +195,8 @@ pub trait QuotesTransaction<'a> { /// Updates the request lookup id for a melt quote async fn update_melt_quote_request_lookup_id( &mut self, - quote_id: &Uuid, - new_request_lookup_id: &str, + quote_id: &QuoteId, + new_request_lookup_id: &PaymentIdentifier, ) -> Result<(), Self::Err>; /// Update [`mint::MeltQuote`] state @@ -96,16 +204,22 @@ pub trait QuotesTransaction<'a> { /// It is expected for this function to fail if the state is already set to the new state async fn update_melt_quote_state( &mut self, - quote_id: &Uuid, + quote_id: &QuoteId, new_state: MeltQuoteState, + payment_proof: Option, ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err>; - /// Remove [`mint::MeltQuote`] - async fn remove_melt_quote(&mut self, quote_id: &Uuid) -> Result<(), Self::Err>; + /// Get all [`MintMintQuote`]s and lock it for update in this transaction async fn get_mint_quote_by_request( &mut self, request: &str, ) -> Result, Self::Err>; + + /// Get all [`MintMintQuote`]s + async fn get_mint_quote_by_request_lookup_id( + &mut self, + request_lookup_id: &PaymentIdentifier, + ) -> Result, Self::Err>; } /// Mint Quote Database trait @@ -115,7 +229,7 @@ pub trait QuotesDatabase { type Err: Into + From; /// Get [`MintMintQuote`] - async fn get_mint_quote(&self, quote_id: &Uuid) -> Result, Self::Err>; + async fn get_mint_quote(&self, quote_id: &QuoteId) -> Result, Self::Err>; /// Get all [`MintMintQuote`]s async fn get_mint_quote_by_request( @@ -125,17 +239,15 @@ pub trait QuotesDatabase { /// Get all [`MintMintQuote`]s async fn get_mint_quote_by_request_lookup_id( &self, - request_lookup_id: &str, + request_lookup_id: &PaymentIdentifier, ) -> Result, Self::Err>; /// Get Mint Quotes async fn get_mint_quotes(&self) -> Result, Self::Err>; - /// Get Mint Quotes with state - async fn get_mint_quotes_with_state( - &self, - state: MintQuoteState, - ) -> Result, Self::Err>; /// Get [`mint::MeltQuote`] - async fn get_melt_quote(&self, quote_id: &Uuid) -> Result, Self::Err>; + async fn get_melt_quote( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err>; /// Get all [`mint::MeltQuote`]s async fn get_melt_quotes(&self) -> Result, Self::Err>; } @@ -150,7 +262,12 @@ pub trait ProofsTransaction<'a> { /// /// Adds proofs to the database. The database should error if the proof already exits, with a /// `AttemptUpdateSpentProof` if the proof is already spent or a `Duplicate` error otherwise. - async fn add_proofs(&mut self, proof: Proofs, quote_id: Option) -> Result<(), Self::Err>; + async fn add_proofs( + &mut self, + proof: Proofs, + quote_id: Option, + operation: &Operation, + ) -> Result<(), Self::Err>; /// Updates the proofs to a given states and return the previous states async fn update_proofs_states( &mut self, @@ -162,8 +279,14 @@ pub trait ProofsTransaction<'a> { async fn remove_proofs( &mut self, ys: &[PublicKey], - quote_id: Option, + quote_id: Option, ) -> Result<(), Self::Err>; + + /// Get ys by quote id + async fn get_proof_ys_by_quote_id( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err>; } /// Mint Proof Database trait @@ -175,14 +298,21 @@ pub trait ProofsDatabase { /// Get [`Proofs`] by ys async fn get_proofs_by_ys(&self, ys: &[PublicKey]) -> Result>, Self::Err>; /// Get ys by quote id - async fn get_proof_ys_by_quote_id(&self, quote_id: &Uuid) -> Result, Self::Err>; + async fn get_proof_ys_by_quote_id( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err>; /// Get [`Proofs`] state async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err>; + /// Get [`Proofs`] by state async fn get_proofs_by_keyset_id( &self, keyset_id: &Id, ) -> Result<(Proofs, Vec>), Self::Err>; + + /// Get total proofs redeemed by keyset id + async fn get_total_redeemed(&self) -> Result, Self::Err>; } #[async_trait] @@ -196,7 +326,7 @@ pub trait SignaturesTransaction<'a> { &mut self, blinded_messages: &[PublicKey], blind_signatures: &[BlindSignature], - quote_id: Option, + quote_id: Option, ) -> Result<(), Self::Err>; /// Get [`BlindSignature`]s @@ -217,16 +347,60 @@ pub trait SignaturesDatabase { &self, blinded_messages: &[PublicKey], ) -> Result>, Self::Err>; + /// Get [`BlindSignature`]s for keyset_id async fn get_blind_signatures_for_keyset( &self, keyset_id: &Id, ) -> Result, Self::Err>; + /// Get [`BlindSignature`]s for quote async fn get_blind_signatures_for_quote( &self, - quote_id: &Uuid, + quote_id: &QuoteId, ) -> Result, Self::Err>; + + /// Get total amount issued by keyset id + async fn get_total_issued(&self) -> Result, Self::Err>; +} + +#[async_trait] +/// Saga Transaction trait +pub trait SagaTransaction<'a> { + /// Saga Database Error + type Err: Into + From; + + /// Get saga by operation_id + async fn get_saga( + &mut self, + operation_id: &uuid::Uuid, + ) -> Result, 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 + From; + + /// Get all incomplete sagas for a given operation kind + async fn get_incomplete_sagas( + &self, + operation_kind: mint::OperationKind, + ) -> Result, Self::Err>; } #[async_trait] @@ -242,34 +416,101 @@ pub trait DbTransactionFinalizer { async fn rollback(self: Box) -> Result<(), Self::Err>; } -/// Base database writer +/// Key-Value Store Transaction trait #[async_trait] +pub trait KVStoreTransaction<'a, Error>: DbTransactionFinalizer { + /// Read value from key-value store + async fn kv_read( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Error>; + + /// Write value to key-value store + async fn kv_write( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + value: &[u8], + ) -> Result<(), Error>; + + /// Remove value from key-value store + async fn kv_remove( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result<(), Error>; + + /// List keys in a namespace + async fn kv_list( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + ) -> Result, Error>; +} + +/// Base database writer pub trait Transaction<'a, Error>: DbTransactionFinalizer + QuotesTransaction<'a, Err = Error> + SignaturesTransaction<'a, Err = Error> + ProofsTransaction<'a, Err = Error> + + KVStoreTransaction<'a, Error> + + SagaTransaction<'a, Err = Error> { - /// Set [`QuoteTTL`] - async fn set_quote_ttl(&mut self, quote_ttl: QuoteTTL) -> Result<(), Error>; +} + +/// Key-Value Store Database trait +#[async_trait] +pub trait KVStoreDatabase { + /// KV Store Database Error + type Err: Into + From; + + /// Read value from key-value store + async fn kv_read( + &self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Self::Err>; - /// Set [`MintInfo`] - async fn set_mint_info(&mut self, mint_info: MintInfo) -> Result<(), Error>; + /// List keys in a namespace + async fn kv_list( + &self, + primary_namespace: &str, + secondary_namespace: &str, + ) -> Result, Self::Err>; +} + +/// Key-Value Store Database trait +#[async_trait] +pub trait KVStore: KVStoreDatabase { + /// Begins a KV transaction + async fn begin_transaction<'a>( + &'a self, + ) -> Result + Send + Sync + 'a>, Error>; } +/// Type alias for Mint Kv store +pub type DynMintKVStore = std::sync::Arc + Send + Sync>; + /// Mint Database trait #[async_trait] pub trait Database: - QuotesDatabase + ProofsDatabase + SignaturesDatabase + KVStoreDatabase + + QuotesDatabase + + ProofsDatabase + + SignaturesDatabase + + SagaDatabase { - /// Beings a transaction + /// Begins a transaction async fn begin_transaction<'a>( &'a self, ) -> Result + Send + Sync + 'a>, Error>; - - /// Get [`MintInfo`] - async fn get_mint_info(&self) -> Result; - - /// Get [`QuoteTTL`] - async fn get_quote_ttl(&self) -> Result; } + +/// Type alias for Mint Database +pub type DynMintDatabase = std::sync::Arc + Send + Sync>; diff --git a/crates/cdk-common/src/database/mint/test.rs b/crates/cdk-common/src/database/mint/test.rs deleted file mode 100644 index 7ad65e7ae..000000000 --- a/crates/cdk-common/src/database/mint/test.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Macro with default tests -//! -//! This set is generic and checks the default and expected behaviour for a mint database -//! implementation -use std::str::FromStr; - -use cashu::secret::Secret; -use cashu::{Amount, CurrencyUnit, SecretKey}; - -use super::*; -use crate::database; -use crate::mint::MintKeySetInfo; - -#[inline] -async fn setup_keyset(db: &DB) -> Id -where - DB: KeysDatabase, -{ - let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); - let keyset_info = MintKeySetInfo { - id: keyset_id, - unit: CurrencyUnit::Sat, - active: true, - valid_from: 0, - final_expiry: None, - derivation_path: bitcoin::bip32::DerivationPath::from_str("m/0'/0'/0'").unwrap(), - derivation_path_index: Some(0), - max_order: 32, - input_fee_ppk: 0, - }; - let mut writer = db.begin_transaction().await.expect("db.begin()"); - writer.add_keyset_info(keyset_info).await.unwrap(); - writer.commit().await.expect("commit()"); - keyset_id -} - -/// State transition test -pub async fn state_transition(db: DB) -where - DB: Database + KeysDatabase, -{ - let keyset_id = setup_keyset(&db).await; - - let proofs = vec![ - Proof { - amount: Amount::from(100), - keyset_id, - secret: Secret::generate(), - c: SecretKey::generate().public_key(), - witness: None, - dleq: None, - }, - Proof { - amount: Amount::from(200), - keyset_id, - secret: Secret::generate(), - c: SecretKey::generate().public_key(), - witness: None, - dleq: None, - }, - ]; - - // Add proofs to database - let mut tx = Database::begin_transaction(&db).await.unwrap(); - tx.add_proofs(proofs.clone(), None).await.unwrap(); - - // Mark one proof as `pending` - assert!(tx - .update_proofs_states(&[proofs[0].y().unwrap()], State::Pending) - .await - .is_ok()); - - // Attempt to select the `pending` proof, as `pending` again (which should fail) - assert!(tx - .update_proofs_states(&[proofs[0].y().unwrap()], State::Pending) - .await - .is_err()); - tx.commit().await.unwrap(); -} - -/// Unit test that is expected to be passed for a correct database implementation -#[macro_export] -macro_rules! mint_db_test { - ($make_db_fn:ident) => { - mint_db_test!(state_transition, $make_db_fn); - }; - ($name:ident, $make_db_fn:ident) => { - #[tokio::test] - async fn $name() { - cdk_common::database::mint::test::$name($make_db_fn().await).await; - } - }; -} diff --git a/crates/cdk-common/src/database/mint/test/kvstore.rs b/crates/cdk-common/src/database/mint/test/kvstore.rs new file mode 100644 index 000000000..6c516d91e --- /dev/null +++ b/crates/cdk-common/src/database/mint/test/kvstore.rs @@ -0,0 +1,207 @@ +//! Tests for KV store validation requirements + +#[cfg(test)] +mod tests { + use crate::database::mint::{ + validate_kvstore_params, validate_kvstore_string, KVSTORE_NAMESPACE_KEY_ALPHABET, + KVSTORE_NAMESPACE_KEY_MAX_LEN, + }; + + #[test] + fn test_validate_kvstore_string_valid_inputs() { + // Test valid strings + assert!(validate_kvstore_string("").is_ok()); + assert!(validate_kvstore_string("abc").is_ok()); + assert!(validate_kvstore_string("ABC").is_ok()); + assert!(validate_kvstore_string("123").is_ok()); + assert!(validate_kvstore_string("test_key").is_ok()); + assert!(validate_kvstore_string("test-key").is_ok()); + assert!(validate_kvstore_string("test_KEY-123").is_ok()); + + // Test max length string + let max_length_str = "a".repeat(KVSTORE_NAMESPACE_KEY_MAX_LEN); + assert!(validate_kvstore_string(&max_length_str).is_ok()); + } + + #[test] + fn test_validate_kvstore_string_invalid_length() { + // Test string too long + let too_long_str = "a".repeat(KVSTORE_NAMESPACE_KEY_MAX_LEN + 1); + let result = validate_kvstore_string(&too_long_str); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("exceeds maximum length")); + } + + #[test] + fn test_validate_kvstore_string_invalid_characters() { + // Test invalid characters + let invalid_chars = vec![ + "test@key", // @ + "test key", // space + "test.key", // . + "test/key", // / + "test\\key", // \ + "test+key", // + + "test=key", // = + "test!key", // ! + "test#key", // # + "test$key", // $ + "test%key", // % + "test&key", // & + "test*key", // * + "test(key", // ( + "test)key", // ) + "test[key", // [ + "test]key", // ] + "test{key", // { + "test}key", // } + "test|key", // | + "test;key", // ; + "test:key", // : + "test'key", // ' + "test\"key", // " + "testkey", // > + "test,key", // , + "test?key", // ? + "test~key", // ~ + "test`key", // ` + ]; + + for invalid_str in invalid_chars { + let result = validate_kvstore_string(invalid_str); + assert!(result.is_err(), "Expected '{}' to be invalid", invalid_str); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid characters")); + } + } + + #[test] + fn test_validate_kvstore_params_valid() { + // Test valid parameter combinations + assert!(validate_kvstore_params("primary", "secondary", "key").is_ok()); + assert!(validate_kvstore_params("primary", "", "key").is_ok()); + assert!(validate_kvstore_params("", "", "key").is_ok()); + assert!(validate_kvstore_params("p1", "s1", "different_key").is_ok()); + } + + #[test] + fn test_validate_kvstore_params_empty_namespace_rules() { + // Test empty namespace rules: if primary is empty, secondary must be empty too + let result = validate_kvstore_params("", "secondary", "key"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("If primary_namespace is empty")); + } + + #[test] + fn test_validate_kvstore_params_collision_prevention() { + // Test collision prevention between keys and namespaces + let test_cases = vec![ + ("primary", "secondary", "primary"), // key matches primary namespace + ("primary", "secondary", "secondary"), // key matches secondary namespace + ]; + + for (primary, secondary, key) in test_cases { + let result = validate_kvstore_params(primary, secondary, key); + assert!( + result.is_err(), + "Expected collision for key '{}' with namespaces '{}'/'{}'", + key, + primary, + secondary + ); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("conflicts with namespace")); + } + + // Test that a combined namespace string would be invalid due to the slash character + let result = validate_kvstore_params("primary", "secondary", "primary_secondary"); + assert!(result.is_ok(), "This should be valid - no actual collision"); + } + + #[test] + fn test_validate_kvstore_params_invalid_strings() { + // Test invalid characters in any parameter + let result = validate_kvstore_params("primary@", "secondary", "key"); + assert!(result.is_err()); + + let result = validate_kvstore_params("primary", "secondary!", "key"); + assert!(result.is_err()); + + let result = validate_kvstore_params("primary", "secondary", "key with space"); + assert!(result.is_err()); + } + + #[test] + fn test_alphabet_constants() { + // Verify the alphabet constant is as expected + assert_eq!( + KVSTORE_NAMESPACE_KEY_ALPHABET, + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" + ); + assert_eq!(KVSTORE_NAMESPACE_KEY_MAX_LEN, 120); + } + + #[test] + fn test_alphabet_coverage() { + // Test that all valid characters are actually accepted + for ch in KVSTORE_NAMESPACE_KEY_ALPHABET.chars() { + let test_str = ch.to_string(); + assert!( + validate_kvstore_string(&test_str).is_ok(), + "Character '{}' should be valid", + ch + ); + } + } + + #[test] + fn test_namespace_segmentation_examples() { + // Test realistic namespace segmentation scenarios + + // Valid segmentation examples + let valid_examples = vec![ + ("wallets", "user123", "balance"), + ("quotes", "mint", "quote_12345"), + ("keysets", "", "active_keyset"), + ("", "", "global_config"), + ("auth", "session_456", "token"), + ("mint_info", "", "version"), + ]; + + for (primary, secondary, key) in valid_examples { + assert!( + validate_kvstore_params(primary, secondary, key).is_ok(), + "Valid example should pass: '{}'/'{}'/'{}'", + primary, + secondary, + key + ); + } + } + + #[test] + fn test_per_namespace_uniqueness() { + // This test documents the requirement that implementations should ensure + // per-namespace key uniqueness. The validation function doesn't enforce + // database-level uniqueness (that's handled by the database schema), + // but ensures naming conflicts don't occur between keys and namespaces. + + // These should be valid (different namespaces) + assert!(validate_kvstore_params("ns1", "sub1", "key1").is_ok()); + assert!(validate_kvstore_params("ns2", "sub1", "key1").is_ok()); // same key, different primary namespace + assert!(validate_kvstore_params("ns1", "sub2", "key1").is_ok()); // same key, different secondary namespace + + // These should fail (collision within namespace) + assert!(validate_kvstore_params("ns1", "sub1", "ns1").is_err()); // key conflicts with primary namespace + assert!(validate_kvstore_params("ns1", "sub1", "sub1").is_err()); // key conflicts with secondary namespace + } +} diff --git a/crates/cdk-common/src/database/mint/test/mint.rs b/crates/cdk-common/src/database/mint/test/mint.rs new file mode 100644 index 000000000..b04713414 --- /dev/null +++ b/crates/cdk-common/src/database/mint/test/mint.rs @@ -0,0 +1,602 @@ +//! Payments + +use std::str::FromStr; + +use cashu::quote_id::QuoteId; +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, Operation}; +use crate::payment::PaymentIdentifier; + +/// Add a mint quote +pub async fn add_mint_quote(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + assert!(tx.add_mint_quote(mint_quote.clone()).await.is_ok()); + tx.commit().await.unwrap(); +} + +/// Dup mint quotes fails +pub async fn add_mint_quote_only_once(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + let mut tx = Database::begin_transaction(&db).await.unwrap(); + assert!(tx.add_mint_quote(mint_quote.clone()).await.is_ok()); + tx.commit().await.unwrap(); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + assert!(tx.add_mint_quote(mint_quote).await.is_err()); + tx.commit().await.unwrap(); +} + +/// Register payments +pub async fn register_payments(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + assert!(tx.add_mint_quote(mint_quote.clone()).await.is_ok()); + + let p1 = unique_string(); + let p2 = unique_string(); + + let new_paid_amount = tx + .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone()) + .await + .unwrap(); + + assert_eq!(new_paid_amount, 100.into()); + + let new_paid_amount = tx + .increment_mint_quote_amount_paid(&mint_quote.id, 250.into(), p2.clone()) + .await + .unwrap(); + + assert_eq!(new_paid_amount, 350.into()); + + tx.commit().await.unwrap(); + + let mint_quote_from_db = db + .get_mint_quote(&mint_quote.id) + .await + .unwrap() + .expect("mint_quote_from_db"); + assert_eq!(mint_quote_from_db.amount_paid(), 350.into()); + assert_eq!( + mint_quote_from_db + .payments + .iter() + .map(|x| (x.payment_id.clone(), x.amount)) + .collect::>(), + vec![(p1, 100.into()), (p2, 250.into())] + ); +} + +/// Read mint and payments from db and tx objects +pub async fn read_mint_from_db_and_tx(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let p1 = unique_string(); + let p2 = unique_string(); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + let new_paid_amount = tx + .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone()) + .await + .unwrap(); + + assert_eq!(new_paid_amount, 100.into()); + + let new_paid_amount = tx + .increment_mint_quote_amount_paid(&mint_quote.id, 250.into(), p2.clone()) + .await + .unwrap(); + assert_eq!(new_paid_amount, 350.into()); + tx.commit().await.unwrap(); + + let mint_quote_from_db = db + .get_mint_quote(&mint_quote.id) + .await + .unwrap() + .expect("mint_quote_from_db"); + assert_eq!(mint_quote_from_db.amount_paid(), 350.into()); + assert_eq!( + mint_quote_from_db + .payments + .iter() + .map(|x| (x.payment_id.clone(), x.amount)) + .collect::>(), + vec![(p1, 100.into()), (p2, 250.into())] + ); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let mint_quote_from_tx = tx + .get_mint_quote(&mint_quote.id) + .await + .unwrap() + .expect("mint_quote_from_tx"); + assert_eq!(mint_quote_from_db, mint_quote_from_tx); +} + +/// Reject duplicate payments in the same txs +pub async fn reject_duplicate_payments_same_tx(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let p1 = unique_string(); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + let amount_paid = tx + .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone()) + .await + .unwrap(); + + assert!(tx + .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1) + .await + .is_err()); + tx.commit().await.unwrap(); + + let mint_quote_from_db = db + .get_mint_quote(&mint_quote.id) + .await + .unwrap() + .expect("mint_from_db"); + assert_eq!(mint_quote_from_db.amount_paid(), amount_paid); + assert_eq!(mint_quote_from_db.payments.len(), 1); +} + +/// Reject duplicate payments in different txs +pub async fn reject_duplicate_payments_diff_tx(db: DB) +where + DB: Database + KeysDatabase, +{ + let p1 = unique_string(); + + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + let amount_paid = tx + .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone()) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + assert!(tx + .increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1) + .await + .is_err()); + tx.commit().await.unwrap(); // although in theory nothing has changed, let's try it out + + let mint_quote_from_db = db + .get_mint_quote(&mint_quote.id) + .await + .unwrap() + .expect("mint_from_db"); + assert_eq!(mint_quote_from_db.amount_paid(), amount_paid); + assert_eq!(mint_quote_from_db.payments.len(), 1); +} + +/// Reject over issue in same tx +pub async fn reject_over_issue_same_tx(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + assert!(tx + .increment_mint_quote_amount_issued(&mint_quote.id, 100.into()) + .await + .is_err()); +} + +/// Reject over issue +pub async fn reject_over_issue_different_tx(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + tx.commit().await.unwrap(); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + assert!(tx + .increment_mint_quote_amount_issued(&mint_quote.id, 100.into()) + .await + .is_err()); +} + +/// Reject over issue with payment +pub async fn reject_over_issue_with_payment(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let p1 = unique_string(); + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + tx.increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone()) + .await + .unwrap(); + assert!(tx + .increment_mint_quote_amount_issued(&mint_quote.id, 101.into()) + .await + .is_err()); +} + +/// Reject over issue with payment +pub async fn reject_over_issue_with_payment_different_tx(db: DB) +where + DB: Database + KeysDatabase, +{ + let mint_quote = MintQuote::new( + None, + "".to_owned(), + cashu::CurrencyUnit::Sat, + None, + 0, + PaymentIdentifier::CustomId(unique_string()), + None, + 0.into(), + 0.into(), + cashu::PaymentMethod::Bolt12, + 0, + vec![], + vec![], + ); + + let p1 = unique_string(); + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + tx.increment_mint_quote_amount_paid(&mint_quote.id, 100.into(), p1.clone()) + .await + .unwrap(); + tx.commit().await.unwrap(); + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + assert!(tx + .increment_mint_quote_amount_issued(&mint_quote.id, 101.into()) + .await + .is_err()); +} +/// Successful melt with unique blinded messages +pub async fn add_melt_request_unique_blinded_messages(db: DB) +where + DB: Database + KeysDatabase + MintSignaturesDatabase, +{ + let inputs_amount = Amount::from(100u64); + let inputs_fee = Amount::from(1u64); + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + // Create a dummy blinded message + let blinded_secret = SecretKey::generate().public_key(); + let blinded_message = cashu::BlindedMessage { + blinded_secret, + keyset_id, + amount: Amount::from(100u64), + witness: None, + }; + let blinded_messages = vec![blinded_message]; + + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx.add_melt_quote(quote.clone()).await.unwrap(); + tx.add_melt_request("e.id, inputs_amount, inputs_fee) + .await + .unwrap(); + tx.add_blinded_messages(Some("e.id), &blinded_messages, &Operation::new_melt()) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // Verify retrieval + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let retrieved = tx + .get_melt_request_and_blinded_messages("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!(retrieved.inputs_amount, inputs_amount); + assert_eq!(retrieved.inputs_fee, inputs_fee); + assert_eq!(retrieved.change_outputs.len(), 1); + assert_eq!(retrieved.change_outputs[0].amount, Amount::from(100u64)); + tx.commit().await.unwrap(); +} + +/// Reject melt with duplicate blinded message (already signed) +pub async fn reject_melt_duplicate_blinded_signature(db: DB) +where + DB: Database + KeysDatabase + MintSignaturesDatabase, +{ + let quote_id1 = QuoteId::new_uuid(); + let inputs_amount = Amount::from(100u64); + let inputs_fee = Amount::from(1u64); + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + // Create a dummy blinded message + let blinded_secret = SecretKey::generate().public_key(); + let blinded_message = cashu::BlindedMessage { + blinded_secret, + keyset_id, + amount: Amount::from(100u64), + witness: None, + }; + let blinded_messages = vec![blinded_message.clone()]; + + // First, "sign" it by adding to blind_signature (simulate successful mint) + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let c = SecretKey::generate().public_key(); + let blind_sig = cashu::BlindSignature { + amount: Amount::from(100u64), + keyset_id, + c, + dleq: None, + }; + let blinded_secrets = vec![blinded_message.blinded_secret]; + tx.add_blind_signatures(&blinded_secrets, &[blind_sig], Some(quote_id1)) + .await + .unwrap(); + tx.commit().await.unwrap(); + + // Now try to add melt request with the same blinded message - should fail due to constraint + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let quote2 = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx.add_melt_quote(quote2.clone()).await.unwrap(); + tx.add_melt_request("e2.id, inputs_amount, inputs_fee) + .await + .unwrap(); + let result = tx + .add_blinded_messages(Some("e2.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 +} + +/// Reject duplicate blinded message insert via DB constraint (different quotes) +pub async fn reject_duplicate_blinded_message_db_constraint(db: DB) +where + DB: Database + KeysDatabase, +{ + let inputs_amount = Amount::from(100u64); + let inputs_fee = Amount::from(1u64); + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + // Create a dummy blinded message + let blinded_secret = SecretKey::generate().public_key(); + let blinded_message = cashu::BlindedMessage { + blinded_secret, + keyset_id, + amount: Amount::from(100u64), + witness: None, + }; + let blinded_messages = vec![blinded_message]; + + // First insert succeeds + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx.add_melt_quote(quote.clone()).await.unwrap(); + tx.add_melt_request("e.id, inputs_amount, inputs_fee) + .await + .unwrap(); + assert!(tx + .add_blinded_messages(Some("e.id), &blinded_messages, &Operation::new_melt()) + .await + .is_ok()); + tx.commit().await.unwrap(); + + // Second insert with same blinded_message but different quote_id should fail due to unique constraint on blinded_message + let mut tx = Database::begin_transaction(&db).await.unwrap(); + let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx.add_melt_quote(quote.clone()).await.unwrap(); + tx.add_melt_request("e.id, inputs_amount, inputs_fee) + .await + .unwrap(); + let result = tx + .add_blinded_messages(Some("e.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 + tx.rollback().await.unwrap(); +} + +/// Cleanup of melt request after processing +pub async fn cleanup_melt_request_after_processing(db: DB) +where + DB: Database + KeysDatabase, +{ + let inputs_amount = Amount::from(100u64); + let inputs_fee = Amount::from(1u64); + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + // Create dummy blinded message + let blinded_secret = SecretKey::generate().public_key(); + let blinded_message = cashu::BlindedMessage { + blinded_secret, + keyset_id, + amount: Amount::from(100u64), + witness: None, + }; + let blinded_messages = vec![blinded_message]; + + // Insert melt request + let mut tx1 = Database::begin_transaction(&db).await.unwrap(); + let quote = MeltQuote::new(MeltPaymentRequest::Bolt11 { bolt11: "lnbc330n1p5d85skpp5344v3ktclujsjl3h09wgsfm7zytumr7h7zhrl857f5w8nv0a52zqdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5j3rrg8kvpemqxtf86j8tjm90wq77c7ende4e5qmrerq4xsg02vhq9qxpqysgqjltywgyk6uc5qcgwh8xnzmawl2tjlhz8d28tgp3yx8xwtz76x0jqkfh6mmq70hervjxs0keun7ur0spldgll29l0dnz3md50d65sfqqqwrwpsu".parse().unwrap() }, cashu::CurrencyUnit::Sat, 33.into(), Amount::ZERO, 0, None, None, cashu::PaymentMethod::Bolt11); + tx1.add_melt_quote(quote.clone()).await.unwrap(); + tx1.add_melt_request("e.id, inputs_amount, inputs_fee) + .await + .unwrap(); + tx1.add_blinded_messages(Some("e.id), &blinded_messages, &Operation::new_melt()) + .await + .unwrap(); + tx1.commit().await.unwrap(); + + // Simulate processing: get and delete + let mut tx2 = Database::begin_transaction(&db).await.unwrap(); + let _retrieved = tx2 + .get_melt_request_and_blinded_messages("e.id) + .await + .unwrap() + .unwrap(); + tx2.delete_melt_request("e.id).await.unwrap(); + tx2.commit().await.unwrap(); + + // Verify melt_request is deleted + let mut tx3 = Database::begin_transaction(&db).await.unwrap(); + let retrieved = tx3 + .get_melt_request_and_blinded_messages("e.id) + .await + .unwrap(); + assert!(retrieved.is_none()); + tx3.commit().await.unwrap(); +} diff --git a/crates/cdk-common/src/database/mint/test/mod.rs b/crates/cdk-common/src/database/mint/test/mod.rs new file mode 100644 index 000000000..1b6b99334 --- /dev/null +++ b/crates/cdk-common/src/database/mint/test/mod.rs @@ -0,0 +1,265 @@ +//! Macro with default tests +//! +//! This set is generic and checks the default and expected behaviour for a mint database +//! implementation +use std::str::FromStr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +// For derivation path parsing +use bitcoin::bip32::DerivationPath; +use cashu::secret::Secret; +use cashu::{Amount, CurrencyUnit, SecretKey}; + +use super::*; +use crate::database::MintKVStoreDatabase; +use crate::mint::MintKeySetInfo; + +mod kvstore; +mod mint; +mod proofs; + +pub use self::mint::*; +pub use self::proofs::*; + +/// Generate standard keyset amounts as powers of 2 +#[inline] +fn standard_keyset_amounts(max_order: u32) -> Vec { + (0..max_order).map(|n| 2u64.pow(n)).collect() +} + +#[inline] +async fn setup_keyset(db: &DB) -> Id +where + DB: KeysDatabase, +{ + let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); + let keyset_info = MintKeySetInfo { + id: keyset_id, + unit: CurrencyUnit::Sat, + active: true, + valid_from: 0, + final_expiry: None, + derivation_path: DerivationPath::from_str("m/0'/0'/0'").unwrap(), + derivation_path_index: Some(0), + input_fee_ppk: 0, + amounts: standard_keyset_amounts(32), + }; + let mut writer = db.begin_transaction().await.expect("db.begin()"); + writer.add_keyset_info(keyset_info).await.unwrap(); + writer.commit().await.expect("commit()"); + keyset_id +} + +/// State transition test +pub async fn state_transition(db: DB) +where + DB: Database + KeysDatabase, +{ + let keyset_id = setup_keyset(&db).await; + + let proofs = vec![ + Proof { + amount: Amount::from(100), + keyset_id, + secret: Secret::generate(), + c: SecretKey::generate().public_key(), + witness: None, + dleq: None, + }, + Proof { + amount: Amount::from(200), + keyset_id, + secret: Secret::generate(), + c: SecretKey::generate().public_key(), + witness: None, + dleq: None, + }, + ]; + + // Add proofs to database + let mut tx = Database::begin_transaction(&db).await.unwrap(); + tx.add_proofs(proofs.clone(), None, &Operation::new_swap()) + .await + .unwrap(); + + // Mark one proof as `pending` + assert!(tx + .update_proofs_states(&[proofs[0].y().unwrap()], State::Pending) + .await + .is_ok()); + + // Attempt to select the `pending` proof, as `pending` again (which should fail) + assert!(tx + .update_proofs_states(&[proofs[0].y().unwrap()], State::Pending) + .await + .is_err()); + tx.commit().await.unwrap(); +} + +/// Test KV store functionality including write, read, list, update, and remove operations +pub async fn kvstore_functionality(db: DB) +where + DB: Database + MintKVStoreDatabase, +{ + // Test basic read/write operations in transaction + { + let mut tx = Database::begin_transaction(&db).await.unwrap(); + + // Write some test data + tx.kv_write("test_namespace", "sub_namespace", "key1", b"value1") + .await + .unwrap(); + tx.kv_write("test_namespace", "sub_namespace", "key2", b"value2") + .await + .unwrap(); + tx.kv_write("test_namespace", "other_sub", "key3", b"value3") + .await + .unwrap(); + + // Read back the data in the transaction + let value1 = tx + .kv_read("test_namespace", "sub_namespace", "key1") + .await + .unwrap(); + assert_eq!(value1, Some(b"value1".to_vec())); + + // List keys in namespace + let keys = tx.kv_list("test_namespace", "sub_namespace").await.unwrap(); + assert_eq!(keys, vec!["key1", "key2"]); + + // Commit transaction + tx.commit().await.unwrap(); + } + + // Test read operations after commit + { + let value1 = db + .kv_read("test_namespace", "sub_namespace", "key1") + .await + .unwrap(); + assert_eq!(value1, Some(b"value1".to_vec())); + + let keys = db.kv_list("test_namespace", "sub_namespace").await.unwrap(); + assert_eq!(keys, vec!["key1", "key2"]); + + let other_keys = db.kv_list("test_namespace", "other_sub").await.unwrap(); + assert_eq!(other_keys, vec!["key3"]); + } + + // Test update and remove operations + { + let mut tx = Database::begin_transaction(&db).await.unwrap(); + + // Update existing key + tx.kv_write("test_namespace", "sub_namespace", "key1", b"updated_value1") + .await + .unwrap(); + + // Remove a key + tx.kv_remove("test_namespace", "sub_namespace", "key2") + .await + .unwrap(); + + tx.commit().await.unwrap(); + } + + // Verify updates + { + let value1 = db + .kv_read("test_namespace", "sub_namespace", "key1") + .await + .unwrap(); + assert_eq!(value1, Some(b"updated_value1".to_vec())); + + let value2 = db + .kv_read("test_namespace", "sub_namespace", "key2") + .await + .unwrap(); + assert_eq!(value2, None); + + let keys = db.kv_list("test_namespace", "sub_namespace").await.unwrap(); + assert_eq!(keys, vec!["key1"]); + } +} + +static COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Returns a unique, random-looking Base62 string (no external crates). +/// Not cryptographically secure, but great for ids, keys, temp names, etc. +fn unique_string() -> String { + // 1) high-res timestamp (nanos since epoch) + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + + // 2) per-process monotonic counter to avoid collisions in the same instant + let n = COUNTER.fetch_add(1, Ordering::Relaxed) as u128; + + // 3) process id to reduce collision chance across processes + let pid = std::process::id() as u128; + + // Mix the components (simple XOR/shift mix; good enough for "random-looking") + let mixed = now ^ (pid << 64) ^ (n << 32); + + base62_encode(mixed) +} + +fn base62_encode(mut x: u128) -> String { + const ALPHABET: &[u8; 62] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + if x == 0 { + return "0".to_string(); + } + let mut buf = [0u8; 26]; // enough for base62(u128) + let mut i = buf.len(); + while x > 0 { + let rem = (x % 62) as usize; + x /= 62; + i -= 1; + buf[i] = ALPHABET[rem]; + } + String::from_utf8_lossy(&buf[i..]).into_owned() +} + +/// Unit test that is expected to be passed for a correct database implementation +#[macro_export] +macro_rules! mint_db_test { + ($make_db_fn:ident) => { + mint_db_test!( + $make_db_fn, + state_transition, + add_and_find_proofs, + add_duplicate_proofs, + kvstore_functionality, + add_mint_quote, + add_mint_quote_only_once, + register_payments, + read_mint_from_db_and_tx, + get_proofs_by_keyset_id, + reject_duplicate_payments_same_tx, + reject_duplicate_payments_diff_tx, + reject_over_issue_same_tx, + reject_over_issue_different_tx, + reject_over_issue_with_payment, + reject_over_issue_with_payment_different_tx, + add_melt_request_unique_blinded_messages, + reject_melt_duplicate_blinded_signature, + reject_duplicate_blinded_message_db_constraint, + cleanup_melt_request_after_processing + ); + }; + ($make_db_fn:ident, $($name:ident),+ $(,)?) => { + $( + #[tokio::test] + async fn $name() { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Time went backwards"); + + cdk_common::database::mint::test::$name($make_db_fn(format!("test_{}_{}", now.as_nanos(), stringify!($name))).await).await; + } + )+ + }; +} diff --git a/crates/cdk-common/src/database/mint/test/proofs.rs b/crates/cdk-common/src/database/mint/test/proofs.rs new file mode 100644 index 000000000..43154eb75 --- /dev/null +++ b/crates/cdk-common/src/database/mint/test/proofs.rs @@ -0,0 +1,164 @@ +//! Proofs tests + +use std::str::FromStr; + +use cashu::secret::Secret; +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) +where + DB: Database + KeysDatabase, +{ + let keyset_id = setup_keyset(&db).await; + let quote_id = QuoteId::new_uuid(); + let proofs = vec![ + Proof { + amount: Amount::from(100), + keyset_id, + secret: Secret::generate(), + c: SecretKey::generate().public_key(), + witness: None, + dleq: None, + }, + Proof { + amount: Amount::from(200), + keyset_id, + secret: Secret::generate(), + c: SecretKey::generate().public_key(), + witness: None, + dleq: None, + }, + ]; + + // Add proofs to database + let mut tx = Database::begin_transaction(&db).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(); + assert_eq!(proofs.len(), 2); + assert_eq!(proofs.len(), states.len()); + assert_eq!( + states + .into_iter() + .map(|s| s.map(|x| x.to_string()).unwrap_or_default()) + .collect::>(), + vec!["UNSPENT".to_owned(), "UNSPENT".to_owned()] + ); + + let keyset_id = Id::from_str("00916bbf7ef91a34").unwrap(); + let (proofs, states) = db.get_proofs_by_keyset_id(&keyset_id).await.unwrap(); + assert_eq!(proofs.len(), 0); + assert_eq!(proofs.len(), states.len()); +} + +/// Test the basic storing and retrieving proofs from the database. Probably the database would use +/// binary/`Vec` to store data, that's why this test would quickly identify issues before running +/// other tests +pub async fn add_and_find_proofs(db: DB) +where + DB: Database + KeysDatabase, +{ + let keyset_id = setup_keyset(&db).await; + + let quote_id = QuoteId::new_uuid(); + + let proofs = vec![ + Proof { + amount: Amount::from(100), + keyset_id, + secret: Secret::generate(), + c: SecretKey::generate().public_key(), + witness: None, + dleq: None, + }, + Proof { + amount: Amount::from(200), + keyset_id, + secret: Secret::generate(), + c: SecretKey::generate().public_key(), + witness: None, + dleq: None, + }, + ]; + + // Add proofs to database + let mut tx = Database::begin_transaction(&db).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; + assert!(proofs_from_db.is_ok()); + assert_eq!(proofs_from_db.unwrap().len(), 2); + + let proofs_from_db = db.get_proof_ys_by_quote_id("e_id).await; + assert!(proofs_from_db.is_ok()); + assert_eq!(proofs_from_db.unwrap().len(), 2); +} + +/// Test to add duplicate proofs +pub async fn add_duplicate_proofs(db: DB) +where + DB: Database + KeysDatabase, +{ + let keyset_id = setup_keyset(&db).await; + + let quote_id = QuoteId::new_uuid(); + + let proofs = vec![ + Proof { + amount: Amount::from(100), + keyset_id, + secret: Secret::generate(), + c: SecretKey::generate().public_key(), + witness: None, + dleq: None, + }, + Proof { + amount: Amount::from(200), + keyset_id, + secret: Secret::generate(), + c: SecretKey::generate().public_key(), + witness: None, + dleq: None, + }, + ]; + + // Add proofs to database + let mut tx = Database::begin_transaction(&db).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()), + &Operation::new_swap(), + ) + .await; + + assert!( + matches!(result.unwrap_err(), Error::Duplicate), + "Duplicate entry" + ); +} diff --git a/crates/cdk-common/src/database/mod.rs b/crates/cdk-common/src/database/mod.rs index 8fad4132d..4b808a11f 100644 --- a/crates/cdk-common/src/database/mod.rs +++ b/crates/cdk-common/src/database/mod.rs @@ -7,18 +7,93 @@ mod wallet; #[cfg(feature = "mint")] pub use mint::{ - Database as MintDatabase, DbTransactionFinalizer as MintDbWriterFinalizer, - KeysDatabase as MintKeysDatabase, KeysDatabaseTransaction as MintKeyDatabaseTransaction, - ProofsDatabase as MintProofsDatabase, ProofsTransaction as MintProofsTransaction, - QuotesDatabase as MintQuotesDatabase, QuotesTransaction as MintQuotesTransaction, - SignaturesDatabase as MintSignaturesDatabase, + Database as MintDatabase, DbTransactionFinalizer as MintDbWriterFinalizer, DynMintDatabase, + KVStore as MintKVStore, KVStoreDatabase as MintKVStoreDatabase, + KVStoreTransaction as MintKVStoreTransaction, KeysDatabase as MintKeysDatabase, + KeysDatabaseTransaction as MintKeyDatabaseTransaction, ProofsDatabase as MintProofsDatabase, + ProofsTransaction as MintProofsTransaction, QuotesDatabase as MintQuotesDatabase, + QuotesTransaction as MintQuotesTransaction, SignaturesDatabase as MintSignaturesDatabase, SignaturesTransaction as MintSignatureTransaction, Transaction as MintTransaction, }; #[cfg(all(feature = "mint", feature = "auth"))] -pub use mint::{MintAuthDatabase, MintAuthTransaction}; +pub use mint::{DynMintAuthDatabase, MintAuthDatabase, MintAuthTransaction}; #[cfg(feature = "wallet")] pub use wallet::Database as WalletDatabase; +/// Data conversion error +#[derive(thiserror::Error, Debug)] +pub enum ConversionError { + /// Missing columns + #[error("Not enough elements: expected {0}, got {1}")] + MissingColumn(usize, usize), + + /// Missing parameter + #[error("Missing parameter {0}")] + MissingParameter(String), + + /// Invalid db type + #[error("Invalid type from db, expected {0} got {1}")] + InvalidType(String, String), + + /// Invalid data conversion in column + #[error("Error converting {1}, expecting type {0}")] + InvalidConversion(String, String), + + /// Mint Url Error + #[error(transparent)] + MintUrl(#[from] crate::mint_url::Error), + + /// NUT00 Error + #[error(transparent)] + CDKNUT00(#[from] crate::nuts::nut00::Error), + + /// NUT01 Error + #[error(transparent)] + CDKNUT01(#[from] crate::nuts::nut01::Error), + + /// NUT02 Error + #[error(transparent)] + CDKNUT02(#[from] crate::nuts::nut02::Error), + + /// NUT04 Error + #[error(transparent)] + CDKNUT04(#[from] crate::nuts::nut04::Error), + + /// NUT05 Error + #[error(transparent)] + CDKNUT05(#[from] crate::nuts::nut05::Error), + + /// NUT07 Error + #[error(transparent)] + CDKNUT07(#[from] crate::nuts::nut07::Error), + + /// NUT23 Error + #[error(transparent)] + CDKNUT23(#[from] crate::nuts::nut23::Error), + + /// Secret Error + #[error(transparent)] + CDKSECRET(#[from] crate::secret::Error), + + /// Serde Error + #[error(transparent)] + Serde(#[from] serde_json::Error), + + /// BIP32 Error + #[error(transparent)] + BIP32(#[from] bitcoin::bip32::Error), + + /// Generic error + #[error(transparent)] + Generic(#[from] Box), +} + +impl From for ConversionError { + fn from(err: crate::Error) -> Self { + ConversionError::Generic(Box::new(err)) + } +} + /// CDK_database error #[derive(Debug, thiserror::Error)] pub enum Error { @@ -29,6 +104,12 @@ pub enum Error { /// Duplicate entry #[error("Duplicate entry")] Duplicate, + /// Amount overflow + #[error("Amount overflow")] + AmountOverflow, + /// Amount zero + #[error("Amount zero")] + AmountZero, /// DHKE error #[error(transparent)] @@ -36,6 +117,9 @@ pub enum Error { /// NUT00 Error #[error(transparent)] NUT00(#[from] crate::nuts::nut00::Error), + /// NUT01 Error + #[error(transparent)] + NUT01(#[from] crate::nuts::nut01::Error), /// NUT02 Error #[error(transparent)] NUT02(#[from] crate::nuts::nut02::Error), @@ -43,6 +127,13 @@ pub enum Error { #[error(transparent)] #[cfg(feature = "auth")] NUT22(#[from] crate::nuts::nut22::Error), + /// NUT04 Error + #[error(transparent)] + NUT04(#[from] crate::nuts::nut04::Error), + /// Quote ID Error + #[error(transparent)] + #[cfg(feature = "mint")] + QuoteId(#[from] crate::quote_id::QuoteIdError), /// Serde Error #[error(transparent)] Serde(#[from] serde_json::Error), @@ -65,6 +156,42 @@ pub enum Error { /// Invalid state transition #[error("Invalid state transition")] InvalidStateTransition(crate::state::Error), + + /// Invalid connection settings + #[error("Invalid credentials {0}")] + InvalidConnectionSettings(String), + + /// Unexpected database response + #[error("Invalid database response")] + InvalidDbResponse, + + /// Internal error + #[error("Internal {0}")] + Internal(String), + + /// Data conversion error + #[error(transparent)] + Conversion(#[from] ConversionError), + + /// Missing Placeholder value + #[error("Missing placeholder value {0}")] + MissingPlaceholder(String), + + /// Unknown quote ttl + #[error("Unknown quote ttl")] + UnknownQuoteTTL, + + /// Invalid UUID + #[error("Invalid UUID: {0}")] + InvalidUuid(String), + + /// QuoteNotFound + #[error("Quote not found")] + QuoteNotFound, + + /// KV Store invalid key or namespace + #[error("Invalid KV store key or namespace: {0}")] + KVStoreInvalidKey(String), } #[cfg(feature = "mint")] diff --git a/crates/cdk-common/src/database/wallet.rs b/crates/cdk-common/src/database/wallet.rs index 1195a858d..af4f25dce 100644 --- a/crates/cdk-common/src/database/wallet.rs +++ b/crates/cdk-common/src/database/wallet.rs @@ -69,6 +69,8 @@ pub trait Database: Debug { async fn add_melt_quote(&self, quote: wallet::MeltQuote) -> Result<(), Self::Err>; /// Get melt quote from storage async fn get_melt_quote(&self, quote_id: &str) -> Result, Self::Err>; + /// Get melt quotes from storage + async fn get_melt_quotes(&self) -> Result, Self::Err>; /// Remove melt quote from storage async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err>; @@ -94,13 +96,20 @@ pub trait Database: Debug { state: Option>, spending_conditions: Option>, ) -> Result, Self::Err>; + /// Get proofs by Y values + async fn get_proofs_by_ys(&self, ys: Vec) -> Result, Self::Err>; + /// Get balance + async fn get_balance( + &self, + mint_url: Option, + unit: Option, + state: Option>, + ) -> Result; /// Update proofs state in storage async fn update_proofs_state(&self, ys: Vec, state: State) -> Result<(), Self::Err>; - /// Increment Keyset counter - async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err>; - /// Get current Keyset counter - async fn get_keyset_counter(&self, keyset_id: &Id) -> Result, Self::Err>; + /// Atomically increment Keyset counter and return new value + async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result; /// Add transaction to storage async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err>; diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index d0264401c..eddf5d341 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -1,5 +1,6 @@ //! Errors +use std::array::TryFromSliceError; use std::fmt; use cashu::{CurrencyUnit, PaymentMethod}; @@ -67,6 +68,9 @@ pub enum Error { /// Clear Auth Failed #[error("Clear Auth Failed")] ClearAuthFailed, + /// Static Auth Token Mismatch + #[error("Static Auth Token Mismatch")] + StaticAuthTokenMismatch, /// Blind Auth Failed #[error("Blind Auth Failed")] BlindAuthFailed, @@ -91,6 +95,49 @@ pub enum Error { /// Multi-Part Payment not supported for unit and method #[error("Amountless invoices are not supported for unit `{0}` and method `{1}`")] AmountlessInvoiceNotSupported(CurrencyUnit, PaymentMethod), + /// Duplicate Payment id + #[error("Payment id seen for mint")] + DuplicatePaymentId, + /// Pubkey required + #[error("Pubkey required")] + PubkeyRequired, + /// Invalid payment method + #[error("Invalid payment method")] + InvalidPaymentMethod, + /// Amount undefined + #[error("Amount undefined")] + AmountUndefined, + /// Unsupported payment method + #[error("Payment method unsupported")] + UnsupportedPaymentMethod, + /// Could not parse bolt12 + #[error("Could not parse bolt12")] + Bolt12parse, + /// Could not parse invoice (bolt11 or bolt12) + #[error("Could not parse invoice")] + InvalidInvoice, + + /// BIP353 address parsing error + #[error("Failed to parse BIP353 address: {0}")] + Bip353Parse(String), + + /// Operation timeout + #[error("Operation timeout")] + Timeout, + + /// BIP353 address resolution error + #[error("Failed to resolve BIP353 address: {0}")] + Bip353Resolve(String), + /// BIP353 no Lightning offer found + #[error("No Lightning offer found in BIP353 payment instructions")] + Bip353NoLightningOffer, + + /// Lightning Address parsing error + #[error("Failed to parse Lightning address: {0}")] + LightningAddressParse(String), + /// Lightning Address request error + #[error("Failed to request invoice from Lightning address service: {0}")] + LightningAddressRequest(String), /// Internal Error - Send error #[error("Internal send error: {0}")] @@ -175,6 +222,9 @@ pub enum Error { /// P2PK spending conditions not met #[error("P2PK condition not met `{0}`")] P2PKConditionsNotMet(String), + /// Duplicate signature from same pubkey in P2PK + #[error("Duplicate signature from same pubkey in P2PK")] + DuplicateSignatureError, /// Spending Locktime not provided #[error("Spending condition locktime not provided")] LocktimeNotProvided, @@ -213,6 +263,32 @@ pub enum Error { /// Preimage not provided #[error("Preimage not provided")] PreimageNotProvided, + + // MultiMint Wallet Errors + /// Currency unit mismatch in MultiMintWallet + #[error("Currency unit mismatch: wallet uses {expected}, but {found} provided")] + MultiMintCurrencyUnitMismatch { + /// Expected currency unit + expected: CurrencyUnit, + /// Found currency unit + found: CurrencyUnit, + }, + /// Unknown mint in MultiMintWallet + #[error("Unknown mint: {mint_url}")] + UnknownMint { + /// URL of the unknown mint + mint_url: String, + }, + /// Transfer between mints timed out + #[error("Transfer timeout: failed to transfer {amount} from {source_mint} to {target_mint}")] + TransferTimeout { + /// Source mint URL + source_mint: String, + /// Target mint URL + target_mint: String, + /// Amount that failed to transfer + amount: Amount, + }, /// Insufficient Funds #[error("Insufficient funds")] InsufficientFunds, @@ -237,6 +313,9 @@ pub enum Error { /// Transaction not found #[error("Transaction not found")] TransactionNotFound, + /// KV Store invalid key or namespace + #[error("Invalid KV store key or namespace: {0}")] + KVStoreInvalidKey(String), /// Custom Error #[error("`{0}`")] Custom(String), @@ -267,9 +346,12 @@ pub enum Error { #[error(transparent)] HexError(#[from] hex::Error), /// Http transport error - #[error("Http transport error: {0}")] - HttpError(String), - #[cfg(feature = "wallet")] + #[error("Http transport error {0:?}: {1}")] + HttpError(Option, String), + /// Parse invoice error + #[cfg(feature = "mint")] + #[error(transparent)] + Uuid(#[from] uuid::Error), // Crate error conversions /// Cashu Url Error #[error(transparent)] @@ -322,13 +404,22 @@ pub enum Error { NUT20(#[from] crate::nuts::nut20::Error), /// NUT21 Error #[error(transparent)] + #[cfg(feature = "auth")] NUT21(#[from] crate::nuts::nut21::Error), /// NUT22 Error #[error(transparent)] + #[cfg(feature = "auth")] NUT22(#[from] crate::nuts::nut22::Error), /// NUT23 Error #[error(transparent)] NUT23(#[from] crate::nuts::nut23::Error), + /// Quote ID Error + #[error(transparent)] + #[cfg(feature = "mint")] + QuoteId(#[from] crate::quote_id::QuoteIdError), + /// From slice error + #[error(transparent)] + TryFromSliceError(#[from] TryFromSliceError), /// Database Error #[error(transparent)] Database(crate::database::Error), @@ -346,32 +437,21 @@ pub enum Error { pub struct ErrorResponse { /// Error Code pub code: ErrorCode, - /// Human readable Text - pub error: Option, - /// Longer human readable description - pub detail: Option, + /// Human readable description + #[serde(default)] + pub detail: String, } impl fmt::Display for ErrorResponse { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "code: {}, error: {}, detail: {}", - self.code, - self.error.clone().unwrap_or_default(), - self.detail.clone().unwrap_or_default() - ) + write!(f, "code: {}, detail: {}", self.code, self.detail) } } impl ErrorResponse { /// Create new [`ErrorResponse`] - pub fn new(code: ErrorCode, error: Option, detail: Option) -> Self { - Self { - code, - error, - detail, - } + pub fn new(code: ErrorCode, detail: String) -> Self { + Self { code, detail } } /// Error response from json @@ -387,129 +467,143 @@ impl ErrorResponse { Ok(res) => Ok(res), Err(_) => Ok(Self { code: ErrorCode::Unknown(999), - error: Some(value.to_string()), - detail: None, + detail: value.to_string(), }), } } } +/// Maps NUT11 errors to appropriate error codes +fn map_nut11_error(nut11_error: &crate::nuts::nut11::Error) -> ErrorCode { + match nut11_error { + crate::nuts::nut11::Error::SignaturesNotProvided => ErrorCode::WitnessMissingOrInvalid, + crate::nuts::nut11::Error::InvalidSignature => ErrorCode::WitnessMissingOrInvalid, + crate::nuts::nut11::Error::DuplicateSignature => ErrorCode::DuplicateSignature, + _ => ErrorCode::Unknown(9999), // Parsing/validation errors + } +} + impl From for ErrorResponse { fn from(err: Error) -> ErrorResponse { match err { Error::TokenAlreadySpent => ErrorResponse { code: ErrorCode::TokenAlreadySpent, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::UnsupportedUnit => ErrorResponse { code: ErrorCode::UnsupportedUnit, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::PaymentFailed => ErrorResponse { code: ErrorCode::LightningError, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::RequestAlreadyPaid => ErrorResponse { code: ErrorCode::InvoiceAlreadyPaid, - error: Some("Invoice already paid.".to_string()), - detail: None, + detail: "Invoice already paid.".to_string(), }, Error::TransactionUnbalanced(inputs_total, outputs_total, fee_expected) => { ErrorResponse { code: ErrorCode::TransactionUnbalanced, - error: Some(format!( - "Inputs: {inputs_total}, Outputs: {outputs_total}, expected_fee: {fee_expected}", - )), - detail: Some("Transaction inputs should equal outputs less fee".to_string()), + detail: format!( + "Inputs: {inputs_total}, Outputs: {outputs_total}, expected_fee: {fee_expected}. Transaction inputs should equal outputs less fee" + ), } } Error::MintingDisabled => ErrorResponse { code: ErrorCode::MintingDisabled, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::BlindedMessageAlreadySigned => ErrorResponse { code: ErrorCode::BlindedMessageAlreadySigned, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::InsufficientFunds => ErrorResponse { code: ErrorCode::TransactionUnbalanced, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::AmountOutofLimitRange(_min, _max, _amount) => ErrorResponse { code: ErrorCode::AmountOutofLimitRange, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::ExpiredQuote(_, _) => ErrorResponse { code: ErrorCode::QuoteExpired, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::PendingQuote => ErrorResponse { code: ErrorCode::QuotePending, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::TokenPending => ErrorResponse { code: ErrorCode::TokenPending, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::ClearAuthRequired => ErrorResponse { code: ErrorCode::ClearAuthRequired, - error: None, - detail: None, + detail: Error::ClearAuthRequired.to_string(), }, Error::ClearAuthFailed => ErrorResponse { code: ErrorCode::ClearAuthFailed, - error: None, - detail: None, + detail: Error::ClearAuthFailed.to_string(), + }, + Error::StaticAuthTokenMismatch => ErrorResponse { + code: ErrorCode::StaticAuthTokenMismatch, + detail: Error::StaticAuthTokenMismatch.to_string(), }, Error::BlindAuthRequired => ErrorResponse { code: ErrorCode::BlindAuthRequired, - error: None, - detail: None, + detail: Error::BlindAuthRequired.to_string(), }, Error::BlindAuthFailed => ErrorResponse { code: ErrorCode::BlindAuthFailed, - error: None, - detail: None, + detail: Error::BlindAuthFailed.to_string(), }, Error::NUT20(err) => ErrorResponse { code: ErrorCode::WitnessMissingOrInvalid, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::DuplicateInputs => ErrorResponse { code: ErrorCode::DuplicateInputs, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::DuplicateOutputs => ErrorResponse { code: ErrorCode::DuplicateOutputs, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::MultipleUnits => ErrorResponse { code: ErrorCode::MultipleUnits, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, Error::UnitMismatch => ErrorResponse { code: ErrorCode::UnitMismatch, - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), + }, + Error::UnpaidQuote => ErrorResponse { + code: ErrorCode::QuoteNotPaid, + detail: Error::UnpaidQuote.to_string(), + }, + Error::NUT11(err) => { + let code = map_nut11_error(&err); + let extra = if matches!(err, crate::nuts::nut11::Error::SignaturesNotProvided) { + Some("P2PK signatures are required but not provided".to_string()) + } else { + None + }; + ErrorResponse { + code, + detail: match extra { + Some(extra) => format!("{err}. {extra}"), + None => err.to_string(), + }, + } + }, + Error::DuplicateSignatureError => ErrorResponse { + code: ErrorCode::DuplicateSignature, + detail: err.to_string(), }, _ => ErrorResponse { code: ErrorCode::Unknown(9999), - error: Some(err.to_string()), - detail: None, + detail: err.to_string(), }, } } @@ -563,6 +657,7 @@ impl From for Error { ErrorCode::UnitMismatch => Self::UnitMismatch, ErrorCode::ClearAuthRequired => Self::ClearAuthRequired, ErrorCode::BlindAuthRequired => Self::BlindAuthRequired, + ErrorCode::DuplicateSignature => Self::DuplicateSignatureError, _ => Self::UnknownErrorResponse(err.to_string()), } } @@ -618,10 +713,14 @@ pub enum ErrorCode { ClearAuthRequired, /// Clear Auth Failed ClearAuthFailed, + /// Static Auth Token Mismatch + StaticAuthTokenMismatch, /// Blind Auth Required BlindAuthRequired, /// Blind Auth Failed BlindAuthFailed, + /// Duplicate signature from same pubkey + DuplicateSignature, /// Unknown error code Unknown(u16), } @@ -651,8 +750,10 @@ impl ErrorCode { 20006 => Self::InvoiceAlreadyPaid, 20007 => Self::QuoteExpired, 20008 => Self::WitnessMissingOrInvalid, + 20009 => Self::DuplicateSignature, 30001 => Self::ClearAuthRequired, 30002 => Self::ClearAuthFailed, + 30003 => Self::StaticAuthTokenMismatch, 31001 => Self::BlindAuthRequired, 31002 => Self::BlindAuthFailed, _ => Self::Unknown(code), @@ -683,8 +784,10 @@ impl ErrorCode { Self::InvoiceAlreadyPaid => 20006, Self::QuoteExpired => 20007, Self::WitnessMissingOrInvalid => 20008, + Self::DuplicateSignature => 20009, Self::ClearAuthRequired => 30001, Self::ClearAuthFailed => 30002, + Self::StaticAuthTokenMismatch => 30003, Self::BlindAuthRequired => 31001, Self::BlindAuthFailed => 31002, Self::Unknown(code) => *code, diff --git a/crates/cdk-common/src/lib.rs b/crates/cdk-common/src/lib.rs index 3dec0a8dc..dabc4f93b 100644 --- a/crates/cdk-common/src/lib.rs +++ b/crates/cdk-common/src/lib.rs @@ -8,10 +8,14 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] +pub mod task; + pub mod common; pub mod database; pub mod error; #[cfg(feature = "mint")] +pub mod melt; +#[cfg(feature = "mint")] pub mod mint; #[cfg(feature = "mint")] pub mod payment; @@ -22,10 +26,15 @@ pub mod subscription; #[cfg(feature = "wallet")] pub mod wallet; pub mod ws; + // re-exporting external crates pub use bitcoin; pub use cashu::amount::{self, Amount}; pub use cashu::lightning_invoice::{self, Bolt11Invoice}; pub use cashu::nuts::{self, *}; +#[cfg(feature = "mint")] +pub use cashu::quote_id::{self, *}; pub use cashu::{dhke, ensure_cdk, mint_url, secret, util, SECP256K1}; pub use error::Error; +/// Re-export parking_lot for reuse +pub use parking_lot; diff --git a/crates/cdk-common/src/melt.rs b/crates/cdk-common/src/melt.rs new file mode 100644 index 000000000..d744b3882 --- /dev/null +++ b/crates/cdk-common/src/melt.rs @@ -0,0 +1,26 @@ +//! Melt types +use cashu::{MeltQuoteBolt11Request, MeltQuoteBolt12Request}; + +/// Melt quote request enum for different types of quotes +/// +/// This enum represents the different types of melt quote requests +/// that can be made, either BOLT11 or BOLT12. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MeltQuoteRequest { + /// Lightning Network BOLT11 invoice request + Bolt11(MeltQuoteBolt11Request), + /// Lightning Network BOLT12 offer request + Bolt12(MeltQuoteBolt12Request), +} + +impl From for MeltQuoteRequest { + fn from(request: MeltQuoteBolt11Request) -> Self { + MeltQuoteRequest::Bolt11(request) + } +} + +impl From for MeltQuoteRequest { + fn from(request: MeltQuoteBolt12Request) -> Self { + MeltQuoteRequest::Bolt12(request) + } +} diff --git a/crates/cdk-common/src/mint.rs b/crates/cdk-common/src/mint.rs index 089c36549..b0e5d5cc8 100644 --- a/crates/cdk-common/src/mint.rs +++ b/crates/cdk-common/src/mint.rs @@ -1,81 +1,521 @@ //! Mint types +use std::fmt; +use std::str::FromStr; + use bitcoin::bip32::DerivationPath; +use cashu::quote_id::QuoteId; use cashu::util::unix_time; -use cashu::{MeltQuoteBolt11Response, MintQuoteBolt11Response}; +use cashu::{ + Bolt11Invoice, MeltOptions, MeltQuoteBolt11Response, MintQuoteBolt11Response, + MintQuoteBolt12Response, PaymentMethod, +}; +use lightning::offers::offer::Offer; use serde::{Deserialize, Serialize}; +use tracing::instrument; use uuid::Uuid; use crate::nuts::{MeltQuoteState, MintQuoteState}; -use crate::{Amount, CurrencyUnit, Id, KeySetInfo, PublicKey}; +use crate::payment::PaymentIdentifier; +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 { + 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 { + 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}"))), + } + } +} + +/// States specific to melt saga +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MeltSagaState { + /// Setup complete (proofs reserved, quote verified) + SetupComplete, + /// Payment attempted to Lightning network (may or may not have succeeded) + PaymentAttempted, +} + +impl fmt::Display for MeltSagaState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MeltSagaState::SetupComplete => write!(f, "setup_complete"), + MeltSagaState::PaymentAttempted => write!(f, "payment_attempted"), + } + } +} + +impl FromStr for MeltSagaState { + type Err = Error; + fn from_str(value: &str) -> Result { + let value = value.to_lowercase(); + match value.as_str() { + "setup_complete" => Ok(MeltSagaState::SetupComplete), + "payment_attempted" => Ok(MeltSagaState::PaymentAttempted), + _ => Err(Error::Custom(format!("Invalid melt saga state: {}", value))), + } + } +} + +/// 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), + /// Melt saga states + Melt(MeltSagaState), + // Future: Mint saga states + // Mint(MintSagaState), +} + +impl SagaStateEnum { + /// Create from string given operation kind + pub fn new(operation_kind: OperationKind, s: &str) -> Result { + match operation_kind { + OperationKind::Swap => Ok(SagaStateEnum::Swap(SwapSagaState::from_str(s)?)), + OperationKind::Melt => Ok(SagaStateEnum::Melt(MeltSagaState::from_str(s)?)), + OperationKind::Mint => Err(Error::Custom("Mint saga not implemented yet".to_string())), + } + } + + /// 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", + }, + SagaStateEnum::Melt(state) => match state { + MeltSagaState::SetupComplete => "setup_complete", + MeltSagaState::PaymentAttempted => "payment_attempted", + }, + } + } +} + +/// 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, + /// Y values (public keys) from input proofs + pub input_ys: Vec, + /// Quote ID for melt operations (used for payment status lookup during recovery) + /// None for swap operations + pub quote_id: Option, + /// 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, + input_ys: Vec, + ) -> Self { + let now = unix_time(); + Self { + operation_id, + operation_kind: OperationKind::Swap, + state: SagaStateEnum::Swap(state), + blinded_secrets, + input_ys, + quote_id: None, + 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(); + } + + /// Create new melt saga + pub fn new_melt( + operation_id: Uuid, + state: MeltSagaState, + input_ys: Vec, + blinded_secrets: Vec, + quote_id: String, + ) -> Self { + let now = unix_time(); + Self { + operation_id, + operation_kind: OperationKind::Melt, + state: SagaStateEnum::Melt(state), + blinded_secrets, + input_ys, + quote_id: Some(quote_id), + created_at: now, + updated_at: now, + } + } + + /// Update melt saga state + pub fn update_melt_state(&mut self, new_state: MeltSagaState) { + self.state = SagaStateEnum::Melt(new_state); + self.updated_at = unix_time(); + } +} + +/// Operation +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 { + 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)] pub struct MintQuote { /// Quote id - pub id: Uuid, + pub id: QuoteId, /// Amount of quote - pub amount: Amount, + pub amount: Option, /// Unit of quote pub unit: CurrencyUnit, /// Quote payment request e.g. bolt11 pub request: String, - /// Quote state - pub state: MintQuoteState, /// Expiration time of quote pub expiry: u64, /// Value used by ln backend to look up state of request - pub request_lookup_id: String, + pub request_lookup_id: PaymentIdentifier, /// Pubkey pub pubkey: Option, /// Unix time quote was created #[serde(default)] pub created_time: u64, - /// Unix time quote was paid - pub paid_time: Option, - /// Unix time quote was issued - pub issued_time: Option, + /// Amount paid + #[serde(default)] + amount_paid: Amount, + /// Amount issued + #[serde(default)] + amount_issued: Amount, + /// Payment of payment(s) that filled quote + #[serde(default)] + pub payments: Vec, + /// Payment Method + #[serde(default)] + pub payment_method: PaymentMethod, + /// Payment of payment(s) that filled quote + #[serde(default)] + pub issuance: Vec, } impl MintQuote { /// Create new [`MintQuote`] + #[allow(clippy::too_many_arguments)] pub fn new( + id: Option, request: String, unit: CurrencyUnit, - amount: Amount, + amount: Option, expiry: u64, - request_lookup_id: String, + request_lookup_id: PaymentIdentifier, pubkey: Option, + amount_paid: Amount, + amount_issued: Amount, + payment_method: PaymentMethod, + created_time: u64, + payments: Vec, + issuance: Vec, ) -> Self { - let id = Uuid::new_v4(); + let id = id.unwrap_or_else(QuoteId::new_uuid); Self { id, amount, unit, request, - state: MintQuoteState::Unpaid, expiry, request_lookup_id, pubkey, - created_time: unix_time(), - paid_time: None, - issued_time: None, + created_time, + amount_paid, + amount_issued, + payment_method, + payments, + issuance, + } + } + + /// Increment the amount paid on the mint quote by a given amount + #[instrument(skip(self))] + pub fn increment_amount_paid( + &mut self, + additional_amount: Amount, + ) -> Result { + self.amount_paid = self + .amount_paid + .checked_add(additional_amount) + .ok_or(crate::Error::AmountOverflow)?; + Ok(self.amount_paid) + } + + /// Amount paid + #[instrument(skip(self))] + pub fn amount_paid(&self) -> Amount { + self.amount_paid + } + + /// Increment the amount issued on the mint quote by a given amount + #[instrument(skip(self))] + pub fn increment_amount_issued( + &mut self, + additional_amount: Amount, + ) -> Result { + self.amount_issued = self + .amount_issued + .checked_add(additional_amount) + .ok_or(crate::Error::AmountOverflow)?; + Ok(self.amount_issued) + } + + /// Amount issued + #[instrument(skip(self))] + pub fn amount_issued(&self) -> Amount { + self.amount_issued + } + + /// Get state of mint quote + #[instrument(skip(self))] + pub fn state(&self) -> MintQuoteState { + self.compute_quote_state() + } + + /// Existing payment ids of a mint quote + pub fn payment_ids(&self) -> Vec<&String> { + self.payments.iter().map(|a| &a.payment_id).collect() + } + + /// Amount mintable + /// Returns the amount that is still available for minting. + /// + /// The value is computed as the difference between the total amount that + /// has been paid for this issuance (`self.amount_paid`) and the amount + /// that has already been issued (`self.amount_issued`). In other words, + pub fn amount_mintable(&self) -> Amount { + self.amount_paid - self.amount_issued + } + + /// Add a payment ID to the list of payment IDs + /// + /// Returns an error if the payment ID is already in the list + #[instrument(skip(self))] + pub fn add_payment( + &mut self, + amount: Amount, + payment_id: String, + time: u64, + ) -> Result<(), crate::Error> { + let payment_ids = self.payment_ids(); + if payment_ids.contains(&&payment_id) { + return Err(crate::Error::DuplicatePaymentId); + } + + let payment = IncomingPayment::new(amount, payment_id, time); + + self.payments.push(payment); + Ok(()) + } + + /// Compute quote state + #[instrument(skip(self))] + fn compute_quote_state(&self) -> MintQuoteState { + if self.amount_paid == Amount::ZERO && self.amount_issued == Amount::ZERO { + return MintQuoteState::Unpaid; + } + + match self.amount_paid.cmp(&self.amount_issued) { + std::cmp::Ordering::Less => { + // self.amount_paid is less than other (amount issued) + // Handle case where paid amount is insufficient + tracing::error!("We should not have issued more then has been paid"); + MintQuoteState::Issued + } + std::cmp::Ordering::Equal => { + // We do this extra check for backwards compatibility for quotes where amount paid/issed was not tracked + // self.amount_paid equals other (amount issued) + // Handle case where paid amount exactly matches + MintQuoteState::Issued + } + std::cmp::Ordering::Greater => { + // self.amount_paid is greater than other (amount issued) + // Handle case where paid amount exceeds required amount + MintQuoteState::Paid + } + } + } +} + +/// Mint Payments +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct IncomingPayment { + /// Amount + pub amount: Amount, + /// Pyament unix time + pub time: u64, + /// Payment id + pub payment_id: String, +} + +impl IncomingPayment { + /// New [`IncomingPayment`] + pub fn new(amount: Amount, payment_id: String, time: u64) -> Self { + Self { + payment_id, + time, + amount, } } } +/// Informattion about issued quote +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct Issuance { + /// Amount + pub amount: Amount, + /// Time + pub time: u64, +} + +impl Issuance { + /// Create new [`Issuance`] + pub fn new(amount: Amount, time: u64) -> Self { + Self { amount, time } + } +} + /// Melt Quote Info #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct MeltQuote { /// Quote id - pub id: Uuid, + pub id: QuoteId, /// Quote unit pub unit: CurrencyUnit, /// Quote amount pub amount: Amount, /// Quote Payment request e.g. bolt11 - pub request: String, + pub request: MeltPaymentRequest, /// Quote fee reserve pub fee_reserve: Amount, /// Quote state @@ -85,33 +525,38 @@ pub struct MeltQuote { /// Payment preimage pub payment_preimage: Option, /// Value used by ln backend to look up state of request - pub request_lookup_id: String, - /// Msat to pay + pub request_lookup_id: Option, + /// Payment options /// - /// Used for an amountless invoice - pub msat_to_pay: Option, + /// Used for amountless invoices and MPP payments + pub options: Option, /// Unix time quote was created #[serde(default)] pub created_time: u64, /// Unix time quote was paid pub paid_time: Option, + /// Payment method + #[serde(default)] + pub payment_method: PaymentMethod, } impl MeltQuote { /// Create new [`MeltQuote`] + #[allow(clippy::too_many_arguments)] pub fn new( - request: String, + request: MeltPaymentRequest, unit: CurrencyUnit, amount: Amount, fee_reserve: Amount, expiry: u64, - request_lookup_id: String, - msat_to_pay: Option, + request_lookup_id: Option, + options: Option, + payment_method: PaymentMethod, ) -> Self { let id = Uuid::new_v4(); Self { - id, + id: QuoteId::UUID(id), amount, unit, request, @@ -120,9 +565,10 @@ impl MeltQuote { expiry, payment_preimage: None, request_lookup_id, - msat_to_pay, + options, created_time: unix_time(), paid_time: None, + payment_method, } } } @@ -143,8 +589,8 @@ pub struct MintKeySetInfo { pub derivation_path: DerivationPath, /// DerivationPath index of Keyset pub derivation_path_index: Option, - /// Max order of keyset - pub max_order: u8, + /// Supported amounts + pub amounts: Vec, /// Input Fee ppk #[serde(default = "default_fee")] pub input_fee_ppk: u64, @@ -169,28 +615,62 @@ impl From for KeySetInfo { } } -impl From for MintQuoteBolt11Response { - fn from(mint_quote: crate::mint::MintQuote) -> MintQuoteBolt11Response { +impl From for MintQuoteBolt11Response { + fn from(mint_quote: crate::mint::MintQuote) -> MintQuoteBolt11Response { MintQuoteBolt11Response { - quote: mint_quote.id, + quote: mint_quote.id.clone(), + state: mint_quote.state(), request: mint_quote.request, - state: mint_quote.state, expiry: Some(mint_quote.expiry), pubkey: mint_quote.pubkey, - amount: Some(mint_quote.amount), + amount: mint_quote.amount, unit: Some(mint_quote.unit.clone()), } } } -impl From<&MeltQuote> for MeltQuoteBolt11Response { - fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response { +impl From for MintQuoteBolt11Response { + fn from(quote: MintQuote) -> Self { + let quote: MintQuoteBolt11Response = quote.into(); + + quote.into() + } +} + +impl TryFrom for MintQuoteBolt12Response { + type Error = crate::Error; + + fn try_from(mint_quote: crate::mint::MintQuote) -> Result { + Ok(MintQuoteBolt12Response { + quote: mint_quote.id.clone(), + request: mint_quote.request, + expiry: Some(mint_quote.expiry), + amount_paid: mint_quote.amount_paid, + amount_issued: mint_quote.amount_issued, + pubkey: mint_quote.pubkey.ok_or(crate::Error::PubkeyRequired)?, + amount: mint_quote.amount, + unit: mint_quote.unit, + }) + } +} + +impl TryFrom for MintQuoteBolt12Response { + type Error = crate::Error; + + fn try_from(quote: MintQuote) -> Result { + let quote: MintQuoteBolt12Response = quote.try_into()?; + + Ok(quote.into()) + } +} + +impl From<&MeltQuote> for MeltQuoteBolt11Response { + fn from(melt_quote: &MeltQuote) -> MeltQuoteBolt11Response { MeltQuoteBolt11Response { - quote: melt_quote.id, + quote: melt_quote.id.clone(), payment_preimage: None, change: None, state: melt_quote.state, - paid: Some(melt_quote.state == MeltQuoteState::Paid), expiry: melt_quote.expiry, amount: melt_quote.amount, fee_reserve: melt_quote.fee_reserve, @@ -200,20 +680,69 @@ impl From<&MeltQuote> for MeltQuoteBolt11Response { } } -impl From for MeltQuoteBolt11Response { - fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response { - let paid = melt_quote.state == MeltQuoteState::Paid; +impl From for MeltQuoteBolt11Response { + fn from(melt_quote: MeltQuote) -> MeltQuoteBolt11Response { MeltQuoteBolt11Response { - quote: melt_quote.id, + quote: melt_quote.id.clone(), amount: melt_quote.amount, fee_reserve: melt_quote.fee_reserve, - paid: Some(paid), state: melt_quote.state, expiry: melt_quote.expiry, payment_preimage: melt_quote.payment_preimage, change: None, - request: Some(melt_quote.request.clone()), + request: Some(melt_quote.request.to_string()), unit: Some(melt_quote.unit.clone()), } } } + +/// Payment request +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub enum MeltPaymentRequest { + /// Bolt11 Payment + Bolt11 { + /// Bolt11 invoice + bolt11: Bolt11Invoice, + }, + /// Bolt12 Payment + Bolt12 { + /// Offer + #[serde(with = "offer_serde")] + offer: Box, + }, +} + +impl std::fmt::Display for MeltPaymentRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MeltPaymentRequest::Bolt11 { bolt11 } => write!(f, "{bolt11}"), + MeltPaymentRequest::Bolt12 { offer } => write!(f, "{offer}"), + } + } +} + +mod offer_serde { + use std::str::FromStr; + + use serde::{self, Deserialize, Deserializer, Serializer}; + + use super::Offer; + + pub fn serialize(offer: &Offer, serializer: S) -> Result + where + S: Serializer, + { + let s = offer.to_string(); + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Box::new(Offer::from_str(&s).map_err(|_| { + serde::de::Error::custom("Invalid Bolt12 Offer") + })?)) + } +} diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index fabbabaca..8ee371d69 100644 --- a/crates/cdk-common/src/payment.rs +++ b/crates/cdk-common/src/payment.rs @@ -1,17 +1,23 @@ //! CDK Mint Lightning +use std::convert::Infallible; use std::pin::Pin; use async_trait::async_trait; -use cashu::MeltOptions; +use cashu::util::hex; +use cashu::{Bolt11Invoice, MeltOptions}; +#[cfg(feature = "prometheus")] +use cdk_prometheus::METRICS; use futures::Stream; +use lightning::offers::offer::Offer; use lightning_invoice::ParseOrSemanticError; use serde::{Deserialize, Serialize}; use serde_json::Value; use thiserror::Error; -use crate::nuts::{CurrencyUnit, MeltQuoteState, MintQuoteState}; -use crate::{mint, Amount}; +use crate::mint::MeltPaymentRequest; +use crate::nuts::{CurrencyUnit, MeltQuoteState}; +use crate::Amount; /// CDK Lightning Error #[derive(Debug, Error)] @@ -31,6 +37,9 @@ pub enum Error { /// Payment state is unknown #[error("Payment state is unknown")] UnknownPaymentState, + /// Amount mismatch + #[error("Amount is not what is expected")] + AmountMismatch, /// Lightning Error #[error(transparent)] Lightning(Box), @@ -55,51 +64,242 @@ pub enum Error { /// NUT23 Error #[error(transparent)] NUT23(#[from] crate::nuts::nut23::Error), + /// Hex error + #[error("Hex error")] + Hex(#[from] hex::Error), + /// Invalid hash + #[error("Invalid hash")] + InvalidHash, /// Custom #[error("`{0}`")] Custom(String), } +impl From for Error { + fn from(_: Infallible) -> Self { + unreachable!("Infallible cannot be constructed") + } +} + +/// Payment identifier types +#[derive(Debug, Clone, Hash, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "type", content = "value")] +pub enum PaymentIdentifier { + /// Label identifier + Label(String), + /// Offer ID identifier + OfferId(String), + /// Payment hash identifier + PaymentHash([u8; 32]), + /// Bolt12 payment hash + Bolt12PaymentHash([u8; 32]), + /// Payment id + PaymentId([u8; 32]), + /// Custom Payment ID + CustomId(String), +} + +impl PaymentIdentifier { + /// Create new [`PaymentIdentifier`] + pub fn new(kind: &str, identifier: &str) -> Result { + match kind.to_lowercase().as_str() { + "label" => Ok(Self::Label(identifier.to_string())), + "offer_id" => Ok(Self::OfferId(identifier.to_string())), + "payment_hash" => Ok(Self::PaymentHash( + hex::decode(identifier)? + .try_into() + .map_err(|_| Error::InvalidHash)?, + )), + "bolt12_payment_hash" => Ok(Self::Bolt12PaymentHash( + hex::decode(identifier)? + .try_into() + .map_err(|_| Error::InvalidHash)?, + )), + "custom" => Ok(Self::CustomId(identifier.to_string())), + "payment_id" => Ok(Self::PaymentId( + hex::decode(identifier)? + .try_into() + .map_err(|_| Error::InvalidHash)?, + )), + _ => Err(Error::UnsupportedPaymentOption), + } + } + + /// Payment id kind + pub fn kind(&self) -> String { + match self { + Self::Label(_) => "label".to_string(), + Self::OfferId(_) => "offer_id".to_string(), + Self::PaymentHash(_) => "payment_hash".to_string(), + Self::Bolt12PaymentHash(_) => "bolt12_payment_hash".to_string(), + Self::PaymentId(_) => "payment_id".to_string(), + Self::CustomId(_) => "custom".to_string(), + } + } +} + +impl std::fmt::Display for PaymentIdentifier { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Label(l) => write!(f, "{l}"), + Self::OfferId(o) => write!(f, "{o}"), + Self::PaymentHash(h) => write!(f, "{}", hex::encode(h)), + Self::Bolt12PaymentHash(h) => write!(f, "{}", hex::encode(h)), + Self::PaymentId(h) => write!(f, "{}", hex::encode(h)), + Self::CustomId(c) => write!(f, "{c}"), + } + } +} + +/// Options for creating a BOLT11 incoming payment request +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct Bolt11IncomingPaymentOptions { + /// Optional description for the payment request + pub description: Option, + /// Amount for the payment request in sats + pub amount: Amount, + /// Optional expiry time as Unix timestamp in seconds + pub unix_expiry: Option, +} + +/// Options for creating a BOLT12 incoming payment request +#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] +pub struct Bolt12IncomingPaymentOptions { + /// Optional description for the payment request + pub description: Option, + /// Optional amount for the payment request in sats + pub amount: Option, + /// Optional expiry time as Unix timestamp in seconds + pub unix_expiry: Option, +} + +/// Options for creating an incoming payment request +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum IncomingPaymentOptions { + /// BOLT11 payment request options + Bolt11(Bolt11IncomingPaymentOptions), + /// BOLT12 payment request options + Bolt12(Box), +} + +/// Options for BOLT11 outgoing payments +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Bolt11OutgoingPaymentOptions { + /// Bolt11 + pub bolt11: Bolt11Invoice, + /// Maximum fee amount allowed for the payment + pub max_fee_amount: Option, + /// Optional timeout in seconds + pub timeout_secs: Option, + /// Melt options + pub melt_options: Option, +} + +/// Options for BOLT12 outgoing payments +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Bolt12OutgoingPaymentOptions { + /// Offer + pub offer: Offer, + /// Maximum fee amount allowed for the payment + pub max_fee_amount: Option, + /// Optional timeout in seconds + pub timeout_secs: Option, + /// Melt options + pub melt_options: Option, +} + +/// Options for creating an outgoing payment +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum OutgoingPaymentOptions { + /// BOLT11 payment options + Bolt11(Box), + /// BOLT12 payment options + Bolt12(Box), +} + +impl TryFrom for OutgoingPaymentOptions { + type Error = Error; + + fn try_from(melt_quote: crate::mint::MeltQuote) -> Result { + match melt_quote.request { + MeltPaymentRequest::Bolt11 { bolt11 } => Ok(OutgoingPaymentOptions::Bolt11(Box::new( + Bolt11OutgoingPaymentOptions { + max_fee_amount: Some(melt_quote.fee_reserve), + timeout_secs: None, + bolt11, + melt_options: melt_quote.options, + }, + ))), + MeltPaymentRequest::Bolt12 { offer } => { + let melt_options = match melt_quote.options { + None => None, + Some(MeltOptions::Mpp { mpp: _ }) => return Err(Error::UnsupportedUnit), + Some(options) => Some(options), + }; + + Ok(OutgoingPaymentOptions::Bolt12(Box::new( + Bolt12OutgoingPaymentOptions { + max_fee_amount: Some(melt_quote.fee_reserve), + timeout_secs: None, + offer: *offer, + melt_options, + }, + ))) + } + } + } +} + /// Mint payment trait #[async_trait] pub trait MintPayment { /// Mint Lightning Error type Err: Into + From; + /// Start the payment processor + /// Called when the mint starts up to initialize the payment processor + async fn start(&self) -> Result<(), Self::Err> { + // Default implementation - do nothing + Ok(()) + } + + /// Stop the payment processor + /// Called when the mint shuts down to gracefully stop the payment processor + async fn stop(&self) -> Result<(), Self::Err> { + // Default implementation - do nothing + Ok(()) + } + /// Base Settings async fn get_settings(&self) -> Result; /// Create a new invoice async fn create_incoming_payment_request( &self, - amount: Amount, unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + options: IncomingPaymentOptions, ) -> Result; /// Get payment quote /// Used to get fee and amount required for a payment request async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result; /// Pay request async fn make_payment( &self, - melt_quote: mint::MeltQuote, - partial_amount: Option, - max_fee_amount: Option, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result; /// Listen for invoices to be paid to the mint /// Returns a stream of request_lookup_id once invoices are paid - async fn wait_any_incoming_payment( + async fn wait_payment_event( &self, - ) -> Result + Send>>, Self::Err>; + ) -> Result + Send>>, Self::Err>; /// Is wait invoice active fn is_wait_invoice_active(&self) -> bool; @@ -110,21 +310,56 @@ pub trait MintPayment { /// Check the status of an incoming payment async fn check_incoming_payment_status( &self, - request_lookup_id: &str, - ) -> Result; + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err>; /// Check the status of an outgoing payment async fn check_outgoing_payment( &self, - request_lookup_id: &str, + payment_identifier: &PaymentIdentifier, ) -> Result; } +/// An event emitted which should be handled by the mint +#[derive(Debug, Clone, Hash)] +pub enum Event { + /// A payment has been received. + PaymentReceived(WaitPaymentResponse), +} + +impl Default for Event { + fn default() -> Self { + // We use this as a sentinel value for no-op events + // The actual processing will filter these out + Event::PaymentReceived(WaitPaymentResponse { + payment_identifier: PaymentIdentifier::CustomId("default".to_string()), + payment_amount: Amount::from(0), + unit: CurrencyUnit::Msat, + payment_id: "default".to_string(), + }) + } +} + +/// Wait any invoice response +#[derive(Debug, Clone, Hash, Serialize, Deserialize)] +pub struct WaitPaymentResponse { + /// Request look up id + /// Id that relates the quote and payment request + pub payment_identifier: PaymentIdentifier, + /// Payment amount + pub payment_amount: Amount, + /// Unit + pub unit: CurrencyUnit, + /// Unique id of payment + // Payment hash + pub payment_id: String, +} + /// Create incoming payment response #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct CreateIncomingPaymentResponse { /// Id that is used to look up the payment from the ln backend - pub request_lookup_id: String, + pub request_lookup_id: PaymentIdentifier, /// Payment request pub request: String, /// Unix Expiry of Invoice @@ -135,7 +370,7 @@ pub struct CreateIncomingPaymentResponse { #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct MakePaymentResponse { /// Payment hash - pub payment_lookup_id: String, + pub payment_lookup_id: PaymentIdentifier, /// Payment proof pub payment_proof: Option, /// Status @@ -150,7 +385,7 @@ pub struct MakePaymentResponse { #[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] pub struct PaymentQuoteResponse { /// Request look up id - pub request_lookup_id: String, + pub request_lookup_id: Option, /// Amount pub amount: Amount, /// Fee required for melt @@ -172,6 +407,8 @@ pub struct Bolt11Settings { pub invoice_description: bool, /// Paying amountless invoices supported pub amountless: bool, + /// Bolt12 supported + pub bolt12: bool, } impl TryFrom for Value { @@ -189,3 +426,192 @@ impl TryFrom for Bolt11Settings { serde_json::from_value(value).map_err(|err| err.into()) } } + +/// Metrics wrapper for MintPayment implementations +/// +/// This wrapper implements the Decorator pattern to collect metrics on all +/// MintPayment trait methods. It wraps any existing MintPayment implementation +/// and automatically records timing and operation metrics. +#[derive(Clone)] +#[cfg(feature = "prometheus")] +pub struct MetricsMintPayment { + inner: T, +} +#[cfg(feature = "prometheus")] +impl MetricsMintPayment +where + T: MintPayment, +{ + /// Create a new metrics wrapper around a MintPayment implementation + pub fn new(inner: T) -> Self { + Self { inner } + } + + /// Get reference to the underlying implementation + pub fn inner(&self) -> &T { + &self.inner + } + + /// Consume the wrapper and return the inner implementation + pub fn into_inner(self) -> T { + self.inner + } +} + +#[async_trait] +#[cfg(feature = "prometheus")] +impl MintPayment for MetricsMintPayment +where + T: MintPayment + Send + Sync, +{ + type Err = T::Err; + + async fn get_settings(&self) -> Result { + let start = std::time::Instant::now(); + METRICS.inc_in_flight_requests("get_settings"); + + let result = self.inner.get_settings().await; + + let duration = start.elapsed().as_secs_f64(); + METRICS.record_mint_operation_histogram("get_settings", result.is_ok(), duration); + METRICS.dec_in_flight_requests("get_settings"); + + result + } + + async fn create_incoming_payment_request( + &self, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + let start = std::time::Instant::now(); + METRICS.inc_in_flight_requests("create_incoming_payment_request"); + + let result = self + .inner + .create_incoming_payment_request(unit, options) + .await; + + let duration = start.elapsed().as_secs_f64(); + METRICS.record_mint_operation_histogram( + "create_incoming_payment_request", + result.is_ok(), + duration, + ); + METRICS.dec_in_flight_requests("create_incoming_payment_request"); + + result + } + + async fn get_payment_quote( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let start = std::time::Instant::now(); + METRICS.inc_in_flight_requests("get_payment_quote"); + + let result = self.inner.get_payment_quote(unit, options).await; + + let duration = start.elapsed().as_secs_f64(); + let success = result.is_ok(); + + if let Ok(ref quote) = result { + let amount: f64 = u64::from(quote.amount) as f64; + let fee: f64 = u64::from(quote.fee) as f64; + METRICS.record_lightning_payment(amount, fee); + } + + METRICS.record_mint_operation_histogram("get_payment_quote", success, duration); + METRICS.dec_in_flight_requests("get_payment_quote"); + + result + } + async fn wait_payment_event( + &self, + ) -> Result + Send>>, Self::Err> { + let start = std::time::Instant::now(); + METRICS.inc_in_flight_requests("wait_payment_event"); + + let result = self.inner.wait_payment_event().await; + + let duration = start.elapsed().as_secs_f64(); + let success = result.is_ok(); + + METRICS.record_mint_operation_histogram("wait_payment_event", success, duration); + METRICS.dec_in_flight_requests("wait_payment_event"); + + result + } + + async fn make_payment( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let start = std::time::Instant::now(); + METRICS.inc_in_flight_requests("make_payment"); + + let result = self.inner.make_payment(unit, options).await; + + let duration = start.elapsed().as_secs_f64(); + let success = result.is_ok(); + + METRICS.record_mint_operation_histogram("make_payment", success, duration); + METRICS.dec_in_flight_requests("make_payment"); + + result + } + + fn is_wait_invoice_active(&self) -> bool { + self.inner.is_wait_invoice_active() + } + + fn cancel_wait_invoice(&self) { + self.inner.cancel_wait_invoice() + } + + async fn check_incoming_payment_status( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let start = std::time::Instant::now(); + METRICS.inc_in_flight_requests("check_incoming_payment_status"); + + let result = self + .inner + .check_incoming_payment_status(payment_identifier) + .await; + + let duration = start.elapsed().as_secs_f64(); + METRICS.record_mint_operation_histogram( + "check_incoming_payment_status", + result.is_ok(), + duration, + ); + METRICS.dec_in_flight_requests("check_incoming_payment_status"); + + result + } + + async fn check_outgoing_payment( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + let start = std::time::Instant::now(); + METRICS.inc_in_flight_requests("check_outgoing_payment"); + + let result = self.inner.check_outgoing_payment(payment_identifier).await; + + let duration = start.elapsed().as_secs_f64(); + let success = result.is_ok(); + + METRICS.record_mint_operation_histogram("check_outgoing_payment", success, duration); + METRICS.dec_in_flight_requests("check_outgoing_payment"); + + result + } +} + +/// Type alias for Mint Payment trait +pub type DynMintPayment = std::sync::Arc + Send + Sync>; diff --git a/crates/cdk-common/src/pub_sub/error.rs b/crates/cdk-common/src/pub_sub/error.rs new file mode 100644 index 000000000..c4845d3f9 --- /dev/null +++ b/crates/cdk-common/src/pub_sub/error.rs @@ -0,0 +1,44 @@ +//! Error types for the pub-sub module. + +use tokio::sync::mpsc::error::TrySendError; + +#[derive(thiserror::Error, Debug)] +/// Error +pub enum Error { + /// No subscription found + #[error("Subscription not found")] + NoSubscription, + + /// Parsing error + #[error("Parsing Error {0}")] + ParsingError(String), + + /// Internal error + #[error("Internal")] + Internal(Box), + + /// Internal error + #[error("Internal error {0}")] + InternalStr(String), + + /// Not supported + #[error("Not supported")] + NotSupported, + + /// Channel is full + #[error("Channel is full")] + ChannelFull, + + /// Channel is closed + #[error("Channel is close")] + ChannelClosed, +} + +impl From> for Error { + fn from(value: TrySendError) -> Self { + match value { + TrySendError::Closed(_) => Error::ChannelClosed, + TrySendError::Full(_) => Error::ChannelFull, + } + } +} diff --git a/crates/cdk-common/src/pub_sub/index.rs b/crates/cdk-common/src/pub_sub/index.rs deleted file mode 100644 index 15b11b445..000000000 --- a/crates/cdk-common/src/pub_sub/index.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! WS Index - -use std::fmt::Debug; -use std::ops::Deref; -use std::sync::atomic::{AtomicUsize, Ordering}; - -use super::SubId; - -/// Indexable trait -pub trait Indexable { - /// The type of the index, it is unknown and it is up to the Manager's - /// generic type - type Type: PartialOrd + Ord + Send + Sync + Debug; - - /// To indexes - fn to_indexes(&self) -> Vec>; -} - -#[derive(Debug, Ord, PartialOrd, PartialEq, Eq, Clone)] -/// Index -/// -/// The Index is a sorted structure that is used to quickly find matches -/// -/// The counter is used to make sure each Index is unique, even if the prefix -/// are the same, and also to make sure that earlier indexes matches first -pub struct Index -where - T: PartialOrd + Ord + Send + Sync + Debug, -{ - prefix: T, - counter: SubscriptionGlobalId, - id: super::SubId, -} - -impl From<&Index> for super::SubId -where - T: PartialOrd + Ord + Send + Sync + Debug, -{ - fn from(val: &Index) -> Self { - val.id.clone() - } -} - -impl Deref for Index -where - T: PartialOrd + Ord + Send + Sync + Debug, -{ - type Target = T; - - fn deref(&self) -> &Self::Target { - &self.prefix - } -} - -impl Index -where - T: PartialOrd + Ord + Send + Sync + Debug, -{ - /// Compare the - pub fn cmp_prefix(&self, other: &Index) -> std::cmp::Ordering { - self.prefix.cmp(&other.prefix) - } - - /// Returns a globally unique id for the Index - pub fn unique_id(&self) -> usize { - self.counter.0 - } -} - -impl From<(T, SubId, SubscriptionGlobalId)> for Index -where - T: PartialOrd + Ord + Send + Sync + Debug, -{ - fn from((prefix, id, counter): (T, SubId, SubscriptionGlobalId)) -> Self { - Self { - prefix, - id, - counter, - } - } -} - -impl From<(T, SubId)> for Index -where - T: PartialOrd + Ord + Send + Sync + Debug, -{ - fn from((prefix, id): (T, SubId)) -> Self { - Self { - prefix, - id, - counter: Default::default(), - } - } -} - -impl From for Index -where - T: PartialOrd + Ord + Send + Sync + Debug, -{ - fn from(prefix: T) -> Self { - Self { - prefix, - id: Default::default(), - counter: SubscriptionGlobalId(0), - } - } -} - -static COUNTER: AtomicUsize = AtomicUsize::new(0); - -/// Dummy type -/// -/// This is only use so each Index is unique, with the same prefix. -/// -/// The prefix is used to leverage the BTree to find things quickly, but each -/// entry/key must be unique, so we use this dummy type to make sure each Index -/// is unique. -/// -/// Unique is also used to make sure that the indexes are sorted by creation order -#[derive(Debug, Ord, PartialOrd, PartialEq, Eq, Clone, Copy)] -pub struct SubscriptionGlobalId(usize); - -impl Default for SubscriptionGlobalId { - fn default() -> Self { - Self(COUNTER.fetch_add(1, Ordering::Relaxed)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_index_from_tuple() { - let sub_id = SubId::from("test_sub_id"); - let prefix = "test_prefix"; - let index: Index<&str> = Index::from((prefix, sub_id.clone())); - assert_eq!(index.prefix, "test_prefix"); - assert_eq!(index.id, sub_id); - } - - #[test] - fn test_index_cmp_prefix() { - let sub_id = SubId::from("test_sub_id"); - let index1: Index<&str> = Index::from(("a", sub_id.clone())); - let index2: Index<&str> = Index::from(("b", sub_id.clone())); - assert_eq!(index1.cmp_prefix(&index2), std::cmp::Ordering::Less); - } - - #[test] - fn test_sub_id_from_str() { - let sub_id = SubId::from("test_sub_id"); - assert_eq!(sub_id.0, "test_sub_id"); - } - - #[test] - fn test_sub_id_deref() { - let sub_id = SubId::from("test_sub_id"); - assert_eq!(&*sub_id, "test_sub_id"); - } -} diff --git a/crates/cdk-common/src/pub_sub/mod.rs b/crates/cdk-common/src/pub_sub/mod.rs index 61123ca04..7a0b0e08c 100644 --- a/crates/cdk-common/src/pub_sub/mod.rs +++ b/crates/cdk-common/src/pub_sub/mod.rs @@ -1,77 +1,180 @@ -//! Publish–subscribe pattern. +//! Publish/Subscribe core //! -//! This is a generic implementation for -//! [NUT-17() with a type -//! agnostic Publish-subscribe manager. +//! This module defines the transport-agnostic pub/sub primitives used by both +//! mint and wallet components. The design prioritizes: //! -//! The manager has a method for subscribers to subscribe to events with a -//! generic type that must be converted to a vector of indexes. +//! - **Request coalescing**: multiple local subscribers to the same remote topic +//! result in a single upstream subscription, with local fan‑out. +//! - **Latest-on-subscribe** (NUT-17): on (re)subscription, the most recent event +//! is fetched and delivered before streaming new ones. +//! - **Backpressure-aware delivery**: bounded channels + drop policies prevent +//! a slow consumer from stalling the whole pipeline. +//! - **Resilience**: automatic reconnect with exponential backoff; WebSocket +//! streaming when available, HTTP long-poll fallback otherwise. //! -//! Events are also generic that should implement the `Indexable` trait. -use std::fmt::Debug; -use std::ops::Deref; -use std::str::FromStr; - -use serde::{Deserialize, Serialize}; - -pub mod index; - -/// Default size of the remove channel -pub const DEFAULT_REMOVE_SIZE: usize = 10_000; - -/// Default channel size for subscription buffering -pub const DEFAULT_CHANNEL_SIZE: usize = 10; - -#[async_trait::async_trait] -/// On New Subscription trait -/// -/// This trait is optional and it is used to notify the application when a new -/// subscription is created. This is useful when the application needs to send -/// the initial state to the subscriber upon subscription -pub trait OnNewSubscription { - /// Index type - type Index; - /// Subscription event type - type Event; - - /// Called when a new subscription is created - async fn on_new_subscription( - &self, - request: &[&Self::Index], - ) -> Result, String>; -} +//! Terms used throughout the module: +//! - **Event**: a domain object that maps to one or more `Topic`s via `Event::get_topics`. +//! - **Topic**: an index/type that defines storage and matching semantics. +//! - **SubscriptionRequest**: a domain-specific filter that can be converted into +//! low-level transport messages (e.g., WebSocket subscribe frames). +//! - **Spec**: type bundle tying `Event`, `Topic`, `SubscriptionId`, and serialization. + +mod error; +mod pubsub; +pub mod remote_consumer; +mod subscriber; +mod types; + +pub use self::error::Error; +pub use self::pubsub::Pubsub; +pub use self::subscriber::{Subscriber, SubscriptionRequest}; +pub use self::types::*; + +#[cfg(test)] +mod test { + use std::collections::HashMap; + use std::sync::{Arc, RwLock}; + + use serde::{Deserialize, Serialize}; -/// Subscription Id wrapper -/// -/// This is the place to add some sane default (like a max length) to the -/// subscription ID -#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] -pub struct SubId(String); + use super::subscriber::SubscriptionRequest; + use super::{Error, Event, Pubsub, Spec, Subscriber}; -impl From<&str> for SubId { - fn from(s: &str) -> Self { - Self(s.to_string()) + #[derive(Clone, Debug, Serialize, Eq, PartialEq, Deserialize)] + pub struct Message { + pub foo: u64, + pub bar: u64, } -} -impl From for SubId { - fn from(s: String) -> Self { - Self(s) + #[derive(Debug, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize, Serialize)] + pub enum IndexTest { + Foo(u64), + Bar(u64), } -} -impl FromStr for SubId { - type Err = (); + impl Event for Message { + type Topic = IndexTest; - fn from_str(s: &str) -> Result { - Ok(Self(s.to_string())) + fn get_topics(&self) -> Vec { + vec![IndexTest::Foo(self.foo), IndexTest::Bar(self.bar)] + } } -} -impl Deref for SubId { - type Target = String; + pub struct CustomPubSub { + pub storage: Arc>>, + } + + #[async_trait::async_trait] + impl Spec for CustomPubSub { + type Topic = IndexTest; + + type Event = Message; + + type SubscriptionId = String; + + type Context = (); + + fn new_instance(_context: Self::Context) -> Arc + where + Self: Sized, + { + Arc::new(Self { + storage: Default::default(), + }) + } + + async fn fetch_events( + self: &Arc, + topics: Vec<::Topic>, + reply_to: Subscriber, + ) where + Self: Sized, + { + let storage = self.storage.read().unwrap(); + + for index in topics { + if let Some(value) = storage.get(&index) { + let _ = reply_to.send(value.clone()); + } + } + } + } + + #[derive(Debug, Clone)] + pub enum SubscriptionReq { + Foo(u64), + Bar(u64), + } + + impl SubscriptionRequest for SubscriptionReq { + type Topic = IndexTest; + + type SubscriptionId = String; + + fn try_get_topics(&self) -> Result, Error> { + Ok(vec![match self { + SubscriptionReq::Bar(n) => IndexTest::Bar(*n), + SubscriptionReq::Foo(n) => IndexTest::Foo(*n), + }]) + } + + fn subscription_name(&self) -> Arc { + Arc::new("test".to_owned()) + } + } + + #[tokio::test] + async fn delivery_twice_realtime() { + let pubsub = Pubsub::new(CustomPubSub::new_instance(())); + + assert_eq!(pubsub.active_subscribers(), 0); + + let mut subscriber = pubsub.subscribe(SubscriptionReq::Foo(2)).unwrap(); + + assert_eq!(pubsub.active_subscribers(), 1); + + let _ = pubsub.publish_now(Message { foo: 2, bar: 1 }); + let _ = pubsub.publish_now(Message { foo: 2, bar: 2 }); + + assert_eq!(subscriber.recv().await.map(|x| x.bar), Some(1)); + assert_eq!(subscriber.recv().await.map(|x| x.bar), Some(2)); + assert!(subscriber.try_recv().is_none()); + + drop(subscriber); + + assert_eq!(pubsub.active_subscribers(), 0); + } + + #[tokio::test] + async fn read_from_storage() { + let x = CustomPubSub::new_instance(()); + let storage = x.storage.clone(); + + let pubsub = Pubsub::new(x); + + { + // set previous value + let mut s = storage.write().unwrap(); + s.insert(IndexTest::Bar(2), Message { foo: 3, bar: 2 }); + } + + let mut subscriber = pubsub.subscribe(SubscriptionReq::Bar(2)).unwrap(); + + // Just should receive the latest + assert_eq!(subscriber.recv().await.map(|x| x.foo), Some(3)); + + // realtime delivery test + let _ = pubsub.publish_now(Message { foo: 1, bar: 2 }); + assert_eq!(subscriber.recv().await.map(|x| x.foo), Some(1)); + + { + // set previous value + let mut s = storage.write().unwrap(); + s.insert(IndexTest::Bar(2), Message { foo: 1, bar: 2 }); + } - fn deref(&self) -> &Self::Target { - &self.0 + // new subscription should only get the latest state (it is up to the Topic trait) + let mut y = pubsub.subscribe(SubscriptionReq::Bar(2)).unwrap(); + assert_eq!(y.recv().await.map(|x| x.foo), Some(1)); } } diff --git a/crates/cdk-common/src/pub_sub/pubsub.rs b/crates/cdk-common/src/pub_sub/pubsub.rs new file mode 100644 index 000000000..45f81d55e --- /dev/null +++ b/crates/cdk-common/src/pub_sub/pubsub.rs @@ -0,0 +1,174 @@ +//! Pub-sub producer + +use std::cmp::Ordering; +use std::collections::{BTreeMap, HashSet}; +use std::sync::atomic::AtomicUsize; +use std::sync::Arc; + +use parking_lot::RwLock; +use tokio::sync::mpsc; + +use super::subscriber::{ActiveSubscription, SubscriptionRequest}; +use super::{Error, Event, Spec, Subscriber}; +use crate::task::spawn; + +/// Default channel size for subscription buffering +pub const DEFAULT_CHANNEL_SIZE: usize = 10_000; + +/// Subscriber Receiver +pub type SubReceiver = mpsc::Receiver<(Arc<::SubscriptionId>, ::Event)>; + +/// Internal Index Tree +pub type TopicTree = Arc< + RwLock< + BTreeMap< + // Index with a subscription unique ID + (::Topic, usize), + Subscriber, + >, + >, +>; + +/// Manager +pub struct Pubsub +where + S: Spec + 'static, +{ + inner: Arc, + listeners_topics: TopicTree, + unique_subscription_counter: AtomicUsize, + active_subscribers: Arc, +} + +impl Pubsub +where + S: Spec + 'static, +{ + /// Create a new instance + pub fn new(inner: Arc) -> Self { + Self { + inner, + listeners_topics: Default::default(), + unique_subscription_counter: 0.into(), + active_subscribers: Arc::new(0.into()), + } + } + + /// Total number of active subscribers, it is not the number of active topics being subscribed + pub fn active_subscribers(&self) -> usize { + self.active_subscribers + .load(std::sync::atomic::Ordering::Relaxed) + } + + /// Publish an event to all listenrs + #[inline(always)] + fn publish_internal(event: S::Event, listeners_index: &TopicTree) -> Result<(), Error> { + let index_storage = listeners_index.read(); + + let mut sent = HashSet::new(); + for topic in event.get_topics() { + for ((subscription_index, unique_id), sender) in + index_storage.range((topic.clone(), 0)..) + { + if subscription_index.cmp(&topic) != Ordering::Equal { + break; + } + if sent.contains(&unique_id) { + continue; + } + sent.insert(unique_id); + sender.send(event.clone()); + } + } + + Ok(()) + } + + /// Broadcast an event to all listeners + #[inline(always)] + pub fn publish(&self, event: E) + where + E: Into, + { + let topics = self.listeners_topics.clone(); + let event = event.into(); + + spawn(async move { + let _ = Self::publish_internal(event, &topics); + }); + } + + /// Broadcast an event to all listeners right away, blocking the current thread + /// + /// This function takes an Arc to the storage struct, the event_id, the kind + /// and the vent to broadcast + #[inline(always)] + pub fn publish_now(&self, event: E) -> Result<(), Error> + where + E: Into, + { + let event = event.into(); + Self::publish_internal(event, &self.listeners_topics) + } + + /// Subscribe proving custom sender/receiver mpsc + #[inline(always)] + pub fn subscribe_with( + &self, + request: I, + sender: &mpsc::Sender<(Arc, S::Event)>, + receiver: Option>, + ) -> Result, Error> + where + I: SubscriptionRequest< + Topic = ::Topic, + SubscriptionId = S::SubscriptionId, + >, + { + let subscription_name = request.subscription_name(); + let sender = Subscriber::new(subscription_name.clone(), sender); + let mut index_storage = self.listeners_topics.write(); + let subscription_internal_id = self + .unique_subscription_counter + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + self.active_subscribers + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + + let subscribed_to = request.try_get_topics()?; + + for index in subscribed_to.iter() { + index_storage.insert((index.clone(), subscription_internal_id), sender.clone()); + } + drop(index_storage); + + let inner = self.inner.clone(); + let subscribed_to_for_spawn = subscribed_to.clone(); + + spawn(async move { + // TODO: Ignore topics broadcasted from fetch_events _if_ any real time has been broadcasted already. + inner.fetch_events(subscribed_to_for_spawn, sender).await; + }); + + Ok(ActiveSubscription::new( + subscription_internal_id, + subscription_name, + self.active_subscribers.clone(), + self.listeners_topics.clone(), + subscribed_to, + receiver, + )) + } + + /// Subscribe + pub fn subscribe(&self, request: I) -> Result, Error> + where + I: SubscriptionRequest< + Topic = ::Topic, + SubscriptionId = S::SubscriptionId, + >, + { + let (sender, receiver) = mpsc::channel(DEFAULT_CHANNEL_SIZE); + self.subscribe_with(request, &sender, Some(receiver)) + } +} diff --git a/crates/cdk-common/src/pub_sub/remote_consumer.rs b/crates/cdk-common/src/pub_sub/remote_consumer.rs new file mode 100644 index 000000000..597915ba5 --- /dev/null +++ b/crates/cdk-common/src/pub_sub/remote_consumer.rs @@ -0,0 +1,879 @@ +//! Pub-sub consumer +//! +//! Consumers are designed to connect to a producer, through a transport, and subscribe to events. +use std::collections::{HashMap, VecDeque}; +use std::sync::atomic::AtomicBool; +use std::sync::Arc; +use std::time::Duration; + +use parking_lot::RwLock; +use tokio::sync::mpsc; +use tokio::time::{sleep, Instant}; + +use super::subscriber::{ActiveSubscription, SubscriptionRequest}; +use super::{Error, Event, Pubsub, Spec}; +use crate::task::spawn; + +const STREAM_CONNECTION_BACKOFF: Duration = Duration::from_millis(2_000); + +const STREAM_CONNECTION_MAX_BACKOFF: Duration = Duration::from_millis(30_000); + +const INTERNAL_POLL_SIZE: usize = 1_000; + +const POLL_SLEEP: Duration = Duration::from_millis(2_000); + +struct UniqueSubscription +where + S: Spec, +{ + name: S::SubscriptionId, + total_subscribers: usize, +} + +type UniqueSubscriptions = RwLock::Topic, UniqueSubscription>>; + +type ActiveSubscriptions = + RwLock::SubscriptionId>, Vec<::Topic>>>; + +type CacheEvent = HashMap<<::Event as Event>::Topic, ::Event>; + +/// Subscription consumer +pub struct Consumer +where + T: Transport + 'static, +{ + transport: T, + inner_pubsub: Arc>, + remote_subscriptions: UniqueSubscriptions, + subscriptions: ActiveSubscriptions, + stream_ctrl: RwLock>>>, + still_running: AtomicBool, + prefer_polling: bool, + /// Cached events + /// + /// The cached events are useful to share events. The cache is automatically evicted it is + /// disconnected from the remote source, meaning the cache is only active while there is an + /// active subscription to the remote source, and it remembers the latest event. + cached_events: Arc>>, +} + +/// Remote consumer +pub struct RemoteActiveConsumer +where + T: Transport + 'static, +{ + inner: ActiveSubscription, + previous_messages: VecDeque<::Event>, + consumer: Arc>, +} + +impl RemoteActiveConsumer +where + T: Transport + 'static, +{ + /// Receives the next event + pub async fn recv(&mut self) -> Option<::Event> { + if let Some(event) = self.previous_messages.pop_front() { + Some(event) + } else { + self.inner.recv().await + } + } + + /// Try receive an event or return Noen right away + pub fn try_recv(&mut self) -> Option<::Event> { + if let Some(event) = self.previous_messages.pop_front() { + Some(event) + } else { + self.inner.try_recv() + } + } + + /// Get the subscription name + pub fn name(&self) -> &::SubscriptionId { + self.inner.name() + } +} + +impl Drop for RemoteActiveConsumer +where + T: Transport + 'static, +{ + fn drop(&mut self) { + let _ = self.consumer.unsubscribe(self.name().clone()); + } +} + +/// Struct to relay events from Poll and Streams from the external subscription to the local +/// subscribers +pub struct InternalRelay +where + S: Spec + 'static, +{ + inner: Arc>, + cached_events: Arc>>, +} + +impl InternalRelay +where + S: Spec + 'static, +{ + /// Relay a remote event locally + pub fn send(&self, event: X) + where + X: Into, + { + let event = event.into(); + let mut cached_events = self.cached_events.write(); + + for topic in event.get_topics() { + cached_events.insert(topic, event.clone()); + } + + self.inner.publish(event); + } +} + +impl Consumer +where + T: Transport + 'static, +{ + /// Creates a new instance + pub fn new( + transport: T, + prefer_polling: bool, + context: ::Context, + ) -> Arc { + let this = Arc::new(Self { + transport, + prefer_polling, + inner_pubsub: Arc::new(Pubsub::new(T::Spec::new_instance(context))), + subscriptions: Default::default(), + remote_subscriptions: Default::default(), + stream_ctrl: RwLock::new(None), + cached_events: Default::default(), + still_running: true.into(), + }); + + spawn(Self::stream(this.clone())); + + this + } + + async fn stream(instance: Arc) { + let mut stream_supported = true; + let mut poll_supported = true; + + let mut backoff = STREAM_CONNECTION_BACKOFF; + let mut retry_at = None; + + loop { + if (!stream_supported && !poll_supported) + || !instance + .still_running + .load(std::sync::atomic::Ordering::Relaxed) + { + break; + } + + if instance.remote_subscriptions.read().is_empty() { + sleep(Duration::from_millis(100)).await; + continue; + } + + if stream_supported + && !instance.prefer_polling + && retry_at + .map(|retry_at| retry_at < Instant::now()) + .unwrap_or(true) + { + let (sender, receiver) = mpsc::channel(INTERNAL_POLL_SIZE); + + { + *instance.stream_ctrl.write() = Some(sender); + } + + let current_subscriptions = { + instance + .remote_subscriptions + .read() + .iter() + .map(|(key, name)| (name.name.clone(), key.clone())) + .collect::>() + }; + + if let Err(err) = instance + .transport + .stream( + receiver, + current_subscriptions, + InternalRelay { + inner: instance.inner_pubsub.clone(), + cached_events: instance.cached_events.clone(), + }, + ) + .await + { + retry_at = Some(Instant::now() + backoff); + backoff = + (backoff + STREAM_CONNECTION_BACKOFF).min(STREAM_CONNECTION_MAX_BACKOFF); + + if matches!(err, Error::NotSupported) { + stream_supported = false; + } + tracing::error!("Long connection failed with error {:?}", err); + } else { + backoff = STREAM_CONNECTION_BACKOFF; + } + + // remove sender to stream, as there is no stream + let _ = instance.stream_ctrl.write().take(); + } + + if poll_supported { + let current_subscriptions = { + instance + .remote_subscriptions + .read() + .iter() + .map(|(key, name)| (name.name.clone(), key.clone())) + .collect::>() + }; + + if let Err(err) = instance + .transport + .poll( + current_subscriptions, + InternalRelay { + inner: instance.inner_pubsub.clone(), + cached_events: instance.cached_events.clone(), + }, + ) + .await + { + if matches!(err, Error::NotSupported) { + poll_supported = false; + } + tracing::error!("Polling failed with error {:?}", err); + } + + sleep(POLL_SLEEP).await; + } + } + } + + /// Unsubscribe from a topic, this is called automatically when RemoteActiveSubscription goes + /// out of scope + fn unsubscribe( + self: &Arc, + subscription_name: ::SubscriptionId, + ) -> Result<(), Error> { + let topics = self + .subscriptions + .write() + .remove(&subscription_name) + .ok_or(Error::NoSubscription)?; + + let mut remote_subscriptions = self.remote_subscriptions.write(); + + for topic in topics { + let mut remote_subscription = + if let Some(remote_subscription) = remote_subscriptions.remove(&topic) { + remote_subscription + } else { + continue; + }; + + remote_subscription.total_subscribers = remote_subscription + .total_subscribers + .checked_sub(1) + .unwrap_or_default(); + + if remote_subscription.total_subscribers == 0 { + let mut cached_events = self.cached_events.write(); + + cached_events.remove(&topic); + + self.message_to_stream(StreamCtrl::Unsubscribe(remote_subscription.name.clone()))?; + } else { + remote_subscriptions.insert(topic, remote_subscription); + } + } + + if remote_subscriptions.is_empty() { + self.message_to_stream(StreamCtrl::Stop)?; + } + + Ok(()) + } + + #[inline(always)] + fn message_to_stream(&self, message: StreamCtrl) -> Result<(), Error> { + let to_stream = self.stream_ctrl.read(); + + if let Some(to_stream) = to_stream.as_ref() { + Ok(to_stream.try_send(message)?) + } else { + Ok(()) + } + } + + /// Creates a subscription + /// + /// The subscriptions have two parts: + /// + /// 1. Will create the subscription to the remote Pubsub service, Any events will be moved to + /// the internal pubsub + /// + /// 2. The internal subscription to the inner Pubsub. Because all subscriptions are going the + /// transport, once events matches subscriptions, the inner_pubsub will receive the message and + /// broadcasat the event. + pub fn subscribe(self: &Arc, request: I) -> Result, Error> + where + I: SubscriptionRequest< + Topic = ::Topic, + SubscriptionId = ::SubscriptionId, + >, + { + let subscription_name = request.subscription_name(); + let topics = request.try_get_topics()?; + + let mut remote_subscriptions = self.remote_subscriptions.write(); + let mut subscriptions = self.subscriptions.write(); + + if subscriptions.get(&subscription_name).is_some() { + return Err(Error::NoSubscription); + } + + let mut previous_messages = Vec::new(); + let cached_events = self.cached_events.read(); + + for topic in topics.iter() { + if let Some(subscription) = remote_subscriptions.get_mut(topic) { + subscription.total_subscribers += 1; + + if let Some(v) = cached_events.get(topic).cloned() { + previous_messages.push(v); + } + } else { + let internal_sub_name = self.transport.new_name(); + remote_subscriptions.insert( + topic.clone(), + UniqueSubscription { + total_subscribers: 1, + name: internal_sub_name.clone(), + }, + ); + + // new subscription is created, so the connection worker should be notified + self.message_to_stream(StreamCtrl::Subscribe((internal_sub_name, topic.clone())))?; + } + } + + subscriptions.insert(subscription_name, topics); + drop(subscriptions); + + Ok(RemoteActiveConsumer { + inner: self.inner_pubsub.subscribe(request)?, + previous_messages: previous_messages.into(), + consumer: self.clone(), + }) + } +} + +impl Drop for Consumer +where + T: Transport + 'static, +{ + fn drop(&mut self) { + self.still_running + .store(false, std::sync::atomic::Ordering::Release); + if let Some(to_stream) = self.stream_ctrl.read().as_ref() { + let _ = to_stream.try_send(StreamCtrl::Stop).inspect_err(|err| { + tracing::error!("Failed to send message LongPoll::Stop due to {err:?}") + }); + } + } +} + +/// Subscribe Message +pub type SubscribeMessage = (::SubscriptionId, ::Topic); + +/// Messages sent from the [`Consumer`] to the [`Transport`] background loop. +pub enum StreamCtrl +where + S: Spec + 'static, +{ + /// Add a subscription + Subscribe(SubscribeMessage), + /// Desuscribe + Unsubscribe(S::SubscriptionId), + /// Exit the loop + Stop, +} + +impl Clone for StreamCtrl +where + S: Spec + 'static, +{ + fn clone(&self) -> Self { + match self { + Self::Subscribe(s) => Self::Subscribe(s.clone()), + Self::Unsubscribe(u) => Self::Unsubscribe(u.clone()), + Self::Stop => Self::Stop, + } + } +} + +/// Transport abstracts how the consumer talks to the remote pubsub. +/// +/// Implement this on your HTTP/WebSocket client. The transport is responsible for: +/// - creating unique subscription names, +/// - keeping a long connection via `stream` **or** performing on-demand `poll`, +/// - forwarding remote events to `InternalRelay`. +/// +/// ```ignore +/// struct WsTransport { /* ... */ } +/// #[async_trait::async_trait] +/// impl Transport for WsTransport { +/// type Topic = MyTopic; +/// fn new_name(&self) -> ::SubscriptionName { 0 } +/// async fn stream(/* ... */) -> Result<(), Error> { Ok(()) } +/// async fn poll(/* ... */) -> Result<(), Error> { Ok(()) } +/// } +/// ``` +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +pub trait Transport: Send + Sync { + /// Spec + type Spec: Spec; + + /// Create a new subscription name + fn new_name(&self) -> ::SubscriptionId; + + /// Opens a persistent connection and continuously streams events. + /// For protocols that support server push (e.g. WebSocket, SSE). + async fn stream( + &self, + subscribe_changes: mpsc::Receiver>, + topics: Vec>, + reply_to: InternalRelay, + ) -> Result<(), Error>; + + /// Performs a one-shot fetch of any currently available events. + /// Called repeatedly by the consumer when streaming is not available. + async fn poll( + &self, + topics: Vec>, + reply_to: InternalRelay, + ) -> Result<(), Error>; +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + use tokio::sync::{mpsc, Mutex}; + use tokio::time::{timeout, Duration}; + + use super::{ + InternalRelay, RemoteActiveConsumer, StreamCtrl, SubscribeMessage, Transport, + INTERNAL_POLL_SIZE, + }; + use crate::pub_sub::remote_consumer::Consumer; + use crate::pub_sub::test::{CustomPubSub, IndexTest, Message}; + use crate::pub_sub::{Error, Spec, SubscriptionRequest}; + + // ===== Test Event/Topic types ===== + + #[derive(Clone, Debug)] + enum SubscriptionReq { + Foo(String, u64), + Bar(String, u64), + } + + impl SubscriptionRequest for SubscriptionReq { + type Topic = IndexTest; + + type SubscriptionId = String; + + fn try_get_topics(&self) -> Result, Error> { + Ok(vec![match self { + SubscriptionReq::Foo(_, n) => IndexTest::Foo(*n), + SubscriptionReq::Bar(_, n) => IndexTest::Bar(*n), + }]) + } + + fn subscription_name(&self) -> Arc { + Arc::new(match self { + SubscriptionReq::Foo(n, _) => n.to_string(), + SubscriptionReq::Bar(n, _) => n.to_string(), + }) + } + } + + // ===== A controllable in-memory Transport used by tests ===== + + /// TestTransport relays messages from a broadcast channel to the Consumer via `InternalRelay`. + /// It also forwards Subscribe/Unsubscribe/Stop signals to an observer channel so tests can assert them. + struct TestTransport { + name_ctr: AtomicUsize, + // We forward all transport-loop control messages here so tests can observe them. + observe_ctrl_tx: mpsc::Sender>, + // Whether stream / poll are supported. + support_long: bool, + support_poll: bool, + rx: Mutex>, + } + + impl TestTransport { + fn new( + support_long: bool, + support_poll: bool, + ) -> ( + Self, + mpsc::Sender, + mpsc::Receiver>, + ) { + let (events_tx, rx) = mpsc::channel::(INTERNAL_POLL_SIZE); + let (observe_ctrl_tx, observe_ctrl_rx) = + mpsc::channel::>(INTERNAL_POLL_SIZE); + + let t = TestTransport { + name_ctr: AtomicUsize::new(1), + rx: Mutex::new(rx), + observe_ctrl_tx, + support_long, + support_poll, + }; + + (t, events_tx, observe_ctrl_rx) + } + } + + #[async_trait::async_trait] + impl Transport for TestTransport { + type Spec = CustomPubSub; + + fn new_name(&self) -> ::SubscriptionId { + format!("sub-{}", self.name_ctr.fetch_add(1, Ordering::Relaxed)) + } + + async fn stream( + &self, + mut subscribe_changes: mpsc::Receiver>, + topics: Vec>, + reply_to: InternalRelay, + ) -> Result<(), Error> { + if !self.support_long { + return Err(Error::NotSupported); + } + + // Each invocation creates a fresh broadcast receiver + let mut rx = self.rx.lock().await; + let observe = self.observe_ctrl_tx.clone(); + + for topic in topics { + observe.try_send(StreamCtrl::Subscribe(topic)).unwrap(); + } + + loop { + tokio::select! { + // Forward any control (Subscribe/Unsubscribe/Stop) messages so the test can assert them. + Some(ctrl) = subscribe_changes.recv() => { + observe.try_send(ctrl.clone()).unwrap(); + if matches!(ctrl, StreamCtrl::Stop) { + break; + } + } + // Relay external events into the inner pubsub + Some(msg) = rx.recv() => { + reply_to.send(msg); + } + } + } + + Ok(()) + } + + async fn poll( + &self, + _topics: Vec>, + reply_to: InternalRelay, + ) -> Result<(), Error> { + if !self.support_poll { + return Err(Error::NotSupported); + } + + // On each poll call, drain anything currently pending and return. + // (The Consumer calls this repeatedly; first call happens immediately.) + let mut rx = self.rx.lock().await; + // Non-blocking drain pass: try a few times without sleeping to keep tests snappy + for _ in 0..32 { + match rx.try_recv() { + Ok(msg) => reply_to.send(msg), + Err(mpsc::error::TryRecvError::Empty) => continue, + Err(mpsc::error::TryRecvError::Disconnected) => break, + } + } + Ok(()) + } + } + + // ===== Helpers ===== + + async fn recv_next( + sub: &mut RemoteActiveConsumer, + dur_ms: u64, + ) -> Option<::Event> { + timeout(Duration::from_millis(dur_ms), sub.recv()) + .await + .ok() + .flatten() + } + + async fn expect_ctrl( + rx: &mut mpsc::Receiver>, + dur_ms: u64, + pred: impl Fn(&StreamCtrl) -> bool, + ) -> StreamCtrl { + timeout(Duration::from_millis(dur_ms), async { + loop { + if let Some(msg) = rx.recv().await { + if pred(&msg) { + break msg; + } + } + } + }) + .await + .expect("timed out waiting for control message") + } + + // ===== Tests ===== + + #[tokio::test] + async fn stream_delivery_and_unsubscribe_on_drop() { + // stream supported, poll supported (doesn't matter; prefer long) + let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true); + + // prefer_polling = false so connection loop will try stream first. + let consumer = Consumer::new(transport, false, ()); + + // Subscribe to Foo(7) + let mut sub = consumer + .subscribe(SubscriptionReq::Foo("t".to_owned(), 7)) + .expect("subscribe ok"); + + // We should see a Subscribe(name, topic) forwarded to transport + let ctrl = expect_ctrl( + &mut ctrl_rx, + 1000, + |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if *idx == IndexTest::Foo(7)), + ) + .await; + match ctrl { + StreamCtrl::Subscribe((name, idx)) => { + assert_ne!(name, "t".to_owned()); + assert_eq!(idx, IndexTest::Foo(7)); + } + _ => unreachable!(), + } + + // Send an event that matches Foo(7) + events_tx.send(Message { foo: 7, bar: 1 }).await.unwrap(); + let got = recv_next::(&mut sub, 1000) + .await + .expect("got event"); + assert_eq!(got, Message { foo: 7, bar: 1 }); + + // Dropping the RemoteActiveConsumer should trigger an Unsubscribe(name) + drop(sub); + let _ctrl = expect_ctrl(&mut ctrl_rx, 1000, |m| { + matches!(m, StreamCtrl::Unsubscribe(_)) + }) + .await; + + // Drop the Consumer -> Stop is sent so the transport loop exits cleanly + drop(consumer); + let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| matches!(m, StreamCtrl::Stop)).await; + } + + #[tokio::test] + async fn test_cache_and_invalation() { + // stream supported, poll supported (doesn't matter; prefer long) + let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true); + + // prefer_polling = false so connection loop will try stream first. + let consumer = Consumer::new(transport, false, ()); + + // Subscribe to Foo(7) + let mut sub_1 = consumer + .subscribe(SubscriptionReq::Foo("t".to_owned(), 7)) + .expect("subscribe ok"); + + // We should see a Subscribe(name, topic) forwarded to transport + let ctrl = expect_ctrl( + &mut ctrl_rx, + 1000, + |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if *idx == IndexTest::Foo(7)), + ) + .await; + match ctrl { + StreamCtrl::Subscribe((name, idx)) => { + assert_ne!(name, "t1".to_owned()); + assert_eq!(idx, IndexTest::Foo(7)); + } + _ => unreachable!(), + } + + // Send an event that matches Foo(7) + events_tx.send(Message { foo: 7, bar: 1 }).await.unwrap(); + let got = recv_next::(&mut sub_1, 1000) + .await + .expect("got event"); + assert_eq!(got, Message { foo: 7, bar: 1 }); + + // Subscribe to Foo(7), should receive the latest message and future messages + let mut sub_2 = consumer + .subscribe(SubscriptionReq::Foo("t2".to_owned(), 7)) + .expect("subscribe ok"); + + let got = recv_next::(&mut sub_2, 1000) + .await + .expect("got event"); + assert_eq!(got, Message { foo: 7, bar: 1 }); + + // Dropping the RemoteActiveConsumer but not unsubscribe, since sub_2 is still active + drop(sub_1); + + // Subscribe to Foo(7), should receive the latest message and future messages + let mut sub_3 = consumer + .subscribe(SubscriptionReq::Foo("t3".to_owned(), 7)) + .expect("subscribe ok"); + + // receive cache message + let got = recv_next::(&mut sub_3, 1000) + .await + .expect("got event"); + assert_eq!(got, Message { foo: 7, bar: 1 }); + + // Send an event that matches Foo(7) + events_tx.send(Message { foo: 7, bar: 2 }).await.unwrap(); + + // receive new message + let got = recv_next::(&mut sub_2, 1000) + .await + .expect("got event"); + assert_eq!(got, Message { foo: 7, bar: 2 }); + + let got = recv_next::(&mut sub_3, 1000) + .await + .expect("got event"); + assert_eq!(got, Message { foo: 7, bar: 2 }); + + drop(sub_2); + drop(sub_3); + + let _ctrl = expect_ctrl(&mut ctrl_rx, 1000, |m| { + matches!(m, StreamCtrl::Unsubscribe(_)) + }) + .await; + + // The cache should be dropped, so no new messages + let mut sub_4 = consumer + .subscribe(SubscriptionReq::Foo("t4".to_owned(), 7)) + .expect("subscribe ok"); + + assert!( + recv_next::(&mut sub_4, 1000).await.is_none(), + "Should have not receive any update" + ); + + drop(sub_4); + + // Drop the Consumer -> Stop is sent so the transport loop exits cleanly + let _ = expect_ctrl(&mut ctrl_rx, 2000, |m| matches!(m, StreamCtrl::Stop)).await; + } + + #[tokio::test] + async fn falls_back_to_poll_when_stream_not_supported() { + // stream NOT supported, poll supported + let (transport, events_tx, _) = TestTransport::new(false, true); + // prefer_polling = true nudges the connection loop to poll first, but even if it + // tried stream, our transport returns NotSupported and the loop will use poll. + let consumer = Consumer::new(transport, true, ()); + + // Subscribe to Bar(5) + let mut sub = consumer + .subscribe(SubscriptionReq::Bar("t".to_owned(), 5)) + .expect("subscribe ok"); + + // Inject an event; the poll path should relay it on the first poll iteration + events_tx.send(Message { foo: 9, bar: 5 }).await.unwrap(); + let got = recv_next::(&mut sub, 1500) + .await + .expect("event relayed via polling"); + assert_eq!(got, Message { foo: 9, bar: 5 }); + } + + #[tokio::test] + async fn multiple_subscribers_share_single_remote_subscription() { + // This validates the "coalescing" behavior in Consumer::subscribe where multiple local + // subscribers to the same Topic should only create one remote subscription. + let (transport, events_tx, mut ctrl_rx) = TestTransport::new(true, true); + let consumer = Consumer::new(transport, false, ()); + + // Two local subscriptions to the SAME topic/name pair (different names) + let mut a = consumer + .subscribe(SubscriptionReq::Foo("t".to_owned(), 1)) + .expect("subscribe A"); + let _ = expect_ctrl( + &mut ctrl_rx, + 1000, + |m| matches!(m, StreamCtrl::Subscribe((_, idx)) if *idx == IndexTest::Foo(1)), + ) + .await; + + let mut b = consumer + .subscribe(SubscriptionReq::Foo("b".to_owned(), 1)) + .expect("subscribe B"); + + // No second Subscribe should be forwarded for the same topic (coalesced). + // Give a little time; if one appears, we'll fail explicitly. + if let Ok(Some(StreamCtrl::Subscribe((_, idx)))) = + timeout(Duration::from_millis(400), ctrl_rx.recv()).await + { + assert_ne!(idx, IndexTest::Foo(1), "should not resubscribe same topic"); + } + + // Send one event and ensure BOTH local subscribers receive it. + events_tx.send(Message { foo: 1, bar: 42 }).await.unwrap(); + let got_a = recv_next::(&mut a, 1000) + .await + .expect("A got"); + let got_b = recv_next::(&mut b, 1000) + .await + .expect("B got"); + assert_eq!(got_a, Message { foo: 1, bar: 42 }); + assert_eq!(got_b, Message { foo: 1, bar: 42 }); + + // Drop B: no Unsubscribe should be sent yet (still one local subscriber). + drop(b); + if let Ok(Some(StreamCtrl::Unsubscribe(_))) = + timeout(Duration::from_millis(400), ctrl_rx.recv()).await + { + panic!("Should NOT unsubscribe while another local subscriber exists"); + } + + // Drop A: now remote unsubscribe should occur. + drop(a); + let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| { + matches!(m, StreamCtrl::Unsubscribe(_)) + }) + .await; + + let _ = expect_ctrl(&mut ctrl_rx, 1000, |m| matches!(m, StreamCtrl::Stop)).await; + } +} diff --git a/crates/cdk-common/src/pub_sub/subscriber.rs b/crates/cdk-common/src/pub_sub/subscriber.rs new file mode 100644 index 000000000..9c46e6aef --- /dev/null +++ b/crates/cdk-common/src/pub_sub/subscriber.rs @@ -0,0 +1,159 @@ +//! Active subscription +use std::fmt::Debug; +use std::sync::atomic::AtomicUsize; +use std::sync::{Arc, Mutex}; + +use tokio::sync::mpsc; + +use super::pubsub::{SubReceiver, TopicTree}; +use super::{Error, Spec}; + +/// Subscription request +pub trait SubscriptionRequest { + /// Topics + type Topic; + + /// Subscription Id + type SubscriptionId; + + /// Try to get topics from the request + fn try_get_topics(&self) -> Result, Error>; + + /// Get the subscription name + fn subscription_name(&self) -> Arc; +} + +/// Active Subscription +pub struct ActiveSubscription +where + S: Spec + 'static, +{ + id: usize, + name: Arc, + active_subscribers: Arc, + topics: TopicTree, + subscribed_to: Vec, + receiver: Option>, +} + +impl ActiveSubscription +where + S: Spec + 'static, +{ + /// Creates a new instance + pub fn new( + id: usize, + name: Arc, + active_subscribers: Arc, + topics: TopicTree, + subscribed_to: Vec, + receiver: Option>, + ) -> Self { + Self { + id, + name, + active_subscribers, + subscribed_to, + topics, + receiver, + } + } + + /// Receives the next event + pub async fn recv(&mut self) -> Option { + self.receiver.as_mut()?.recv().await.map(|(_, event)| event) + } + + /// Try receive an event or return Noen right away + pub fn try_recv(&mut self) -> Option { + self.receiver + .as_mut()? + .try_recv() + .ok() + .map(|(_, event)| event) + } + + /// Get the subscription name + pub fn name(&self) -> &S::SubscriptionId { + &self.name + } +} + +impl Drop for ActiveSubscription +where + S: Spec + 'static, +{ + fn drop(&mut self) { + // remove the listener + let mut topics = self.topics.write(); + for index in self.subscribed_to.drain(..) { + topics.remove(&(index, self.id)); + } + + // decrement the number of active subscribers + self.active_subscribers + .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); + } +} + +/// Lightweight sink used by producers to send events to subscribers. +/// +/// You usually do not construct a `Subscriber` directly — it is provided to you in +/// the [`Spec::fetch_events`] callback so you can backfill a new subscription. +#[derive(Debug)] +pub struct Subscriber +where + S: Spec + 'static, +{ + subscription: Arc, + inner: mpsc::Sender<(Arc, S::Event)>, + latest: Arc>>, +} + +impl Clone for Subscriber +where + S: Spec + 'static, +{ + fn clone(&self) -> Self { + Self { + subscription: self.subscription.clone(), + inner: self.inner.clone(), + latest: self.latest.clone(), + } + } +} + +impl Subscriber +where + S: Spec + 'static, +{ + /// Create a new instance + pub fn new( + subscription: Arc, + inner: &mpsc::Sender<(Arc, S::Event)>, + ) -> Self { + Self { + inner: inner.clone(), + subscription, + latest: Arc::new(Mutex::new(None)), + } + } + + /// Send a message + pub fn send(&self, event: S::Event) { + let mut latest = if let Ok(reader) = self.latest.lock() { + reader + } else { + let _ = self.inner.try_send((self.subscription.to_owned(), event)); + return; + }; + + if let Some(last_event) = latest.replace(event.clone()) { + if last_event == event { + return; + } + } + + let _ = self.inner.try_send((self.subscription.to_owned(), event)); + } +} diff --git a/crates/cdk-common/src/pub_sub/types.rs b/crates/cdk-common/src/pub_sub/types.rs new file mode 100644 index 000000000..7ceb169a7 --- /dev/null +++ b/crates/cdk-common/src/pub_sub/types.rs @@ -0,0 +1,80 @@ +//! Pubsub Event definition +//! +//! The Pubsub Event defines the Topic struct and how an event can be converted to Topics. + +use std::hash::Hash; +use std::sync::Arc; + +use serde::de::DeserializeOwned; +use serde::Serialize; + +use super::Subscriber; + +/// Pubsub settings +#[async_trait::async_trait] +pub trait Spec: Send + Sync { + /// Topic + type Topic: Send + + Sync + + Clone + + Eq + + PartialEq + + Ord + + PartialOrd + + Hash + + Send + + Sync + + DeserializeOwned + + Serialize; + + /// Event + type Event: Event + + Send + + Sync + + Eq + + PartialEq + + DeserializeOwned + + Serialize; + + /// Subscription Id + type SubscriptionId: Clone + + Default + + Eq + + PartialEq + + Ord + + PartialOrd + + Hash + + Send + + Sync + + DeserializeOwned + + Serialize; + + /// Create a new context + type Context; + + /// Create a new instance from a given context + fn new_instance(context: Self::Context) -> Arc + where + Self: Sized; + + /// Callback function that is called on new subscriptions, to back-fill optionally the previous + /// events + async fn fetch_events( + self: &Arc, + topics: Vec<::Topic>, + reply_to: Subscriber, + ) where + Self: Sized; +} + +/// Event trait +pub trait Event: Clone + Send + Sync + Eq + PartialEq + DeserializeOwned + Serialize { + /// Generic Topic + /// + /// It should be serializable/deserializable to be stored in the database layer and it should + /// also be sorted in a BTree for in-memory matching + type Topic; + + /// To topics + fn get_topics(&self) -> Vec; +} diff --git a/crates/cdk-common/src/state.rs b/crates/cdk-common/src/state.rs index be080001e..de6d3cb4f 100644 --- a/crates/cdk-common/src/state.rs +++ b/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(()) + } +} diff --git a/crates/cdk-common/src/subscription.rs b/crates/cdk-common/src/subscription.rs index ba14de1b0..3588beb2b 100644 --- a/crates/cdk-common/src/subscription.rs +++ b/crates/cdk-common/src/subscription.rs @@ -1,86 +1,115 @@ //! Subscription types and traits -#[cfg(feature = "mint")] +use std::ops::Deref; use std::str::FromStr; +use std::sync::Arc; -use cashu::nut17::{self}; -#[cfg(feature = "mint")] -use cashu::nut17::{Error, Kind, Notification}; -#[cfg(feature = "mint")] -use cashu::{NotificationPayload, PublicKey}; -#[cfg(feature = "mint")] +use cashu::nut17::{self, Kind, NotificationId}; +use cashu::quote_id::QuoteId; +use cashu::PublicKey; use serde::{Deserialize, Serialize}; -#[cfg(feature = "mint")] -use uuid::Uuid; -#[cfg(feature = "mint")] -use crate::pub_sub::index::{Index, Indexable, SubscriptionGlobalId}; -use crate::pub_sub::SubId; +use crate::pub_sub::{Error, SubscriptionRequest}; -/// Subscription parameters. +/// CDK/Mint Subscription parameters. /// /// This is a concrete type alias for `nut17::Params`. -pub type Params = nut17::Params; +pub type Params = nut17::Params>; -/// Wrapper around `nut17::Params` to implement `Indexable` for `Notification`. -#[cfg(feature = "mint")] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IndexableParams(Params); +impl SubscriptionRequest for Params { + type Topic = NotificationId; -#[cfg(feature = "mint")] -impl From for IndexableParams { - fn from(params: Params) -> Self { - Self(params) + type SubscriptionId = SubId; + + fn subscription_name(&self) -> Arc { + self.id.clone() + } + + fn try_get_topics(&self) -> Result, Error> { + self.filters + .iter() + .map(|filter| match self.kind { + Kind::Bolt11MeltQuote => QuoteId::from_str(filter) + .map(NotificationId::MeltQuoteBolt11) + .map_err(|_| Error::ParsingError(filter.to_owned())), + Kind::Bolt11MintQuote => QuoteId::from_str(filter) + .map(NotificationId::MintQuoteBolt11) + .map_err(|_| Error::ParsingError(filter.to_owned())), + Kind::ProofState => PublicKey::from_str(filter) + .map(NotificationId::ProofState) + .map_err(|_| Error::ParsingError(filter.to_owned())), + + Kind::Bolt12MintQuote => QuoteId::from_str(filter) + .map(NotificationId::MintQuoteBolt12) + .map_err(|_| Error::ParsingError(filter.to_owned())), + }) + .collect::, _>>() } } -#[cfg(feature = "mint")] -impl TryFrom for Vec> { - type Error = Error; - fn try_from(params: IndexableParams) -> Result { - let sub_id: SubscriptionGlobalId = Default::default(); - let params = params.0; - params - .filters - .into_iter() +/// Subscriptions parameters for the wallet +/// +/// This is because the Wallet can subscribe to non CDK quotes, where IDs are not constraint to +/// QuoteId +pub type WalletParams = nut17::Params>; + +impl SubscriptionRequest for WalletParams { + type Topic = NotificationId; + + type SubscriptionId = String; + + fn subscription_name(&self) -> Arc { + self.id.clone() + } + + fn try_get_topics(&self) -> Result, Error> { + self.filters + .iter() .map(|filter| { - let idx = match params.kind { - Kind::Bolt11MeltQuote => { - Notification::MeltQuoteBolt11(Uuid::from_str(&filter)?) - } - Kind::Bolt11MintQuote => { - Notification::MintQuoteBolt11(Uuid::from_str(&filter)?) - } - Kind::ProofState => Notification::ProofState(PublicKey::from_str(&filter)?), - }; - - Ok(Index::from((idx, params.id.clone(), sub_id))) + Ok(match self.kind { + Kind::Bolt11MeltQuote => NotificationId::MeltQuoteBolt11(filter.to_owned()), + Kind::Bolt11MintQuote => NotificationId::MintQuoteBolt11(filter.to_owned()), + Kind::ProofState => PublicKey::from_str(filter) + .map(NotificationId::ProofState) + .map_err(|_| Error::ParsingError(filter.to_owned()))?, + + Kind::Bolt12MintQuote => NotificationId::MintQuoteBolt12(filter.to_owned()), + }) }) - .collect::>() + .collect::, _>>() + } +} + +/// Subscription Id wrapper +/// +/// This is the place to add some sane default (like a max length) to the +/// subscription ID +#[derive(Debug, Clone, Default, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub struct SubId(String); + +impl From<&str> for SubId { + fn from(s: &str) -> Self { + Self(s.to_string()) } } -#[cfg(feature = "mint")] -impl AsRef for IndexableParams { - fn as_ref(&self) -> &SubId { - &self.0.id +impl From for SubId { + fn from(s: String) -> Self { + Self(s) } } -#[cfg(feature = "mint")] -impl Indexable for NotificationPayload { - type Type = Notification; - - fn to_indexes(&self) -> Vec> { - match self { - NotificationPayload::ProofState(proof_state) => { - vec![Index::from(Notification::ProofState(proof_state.y))] - } - NotificationPayload::MeltQuoteBolt11Response(melt_quote) => { - vec![Index::from(Notification::MeltQuoteBolt11(melt_quote.quote))] - } - NotificationPayload::MintQuoteBolt11Response(mint_quote) => { - vec![Index::from(Notification::MintQuoteBolt11(mint_quote.quote))] - } - } +impl FromStr for SubId { + type Err = (); + + fn from_str(s: &str) -> Result { + Ok(Self(s.to_string())) + } +} + +impl Deref for SubId { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 } } diff --git a/crates/cdk-common/src/task.rs b/crates/cdk-common/src/task.rs new file mode 100644 index 000000000..8efe51324 --- /dev/null +++ b/crates/cdk-common/src/task.rs @@ -0,0 +1,25 @@ +//! Thin wrapper for spawn and spawn_local for native and wasm. + +use std::future::Future; + +use tokio::task::JoinHandle; + +/// Spawns a new asynchronous task returning nothing +#[cfg(not(target_arch = "wasm32"))] +pub fn spawn(future: F) -> JoinHandle +where + F: Future + Send + 'static, + F::Output: Send + 'static, +{ + tokio::spawn(future) +} + +/// Spawns a new asynchronous task returning nothing +#[cfg(target_arch = "wasm32")] +pub fn spawn(future: F) -> JoinHandle +where + F: Future + 'static, + F::Output: 'static, +{ + tokio::task::spawn_local(future) +} diff --git a/crates/cdk-common/src/wallet.rs b/crates/cdk-common/src/wallet.rs index 5634d6de2..cf2a5a17f 100644 --- a/crates/cdk-common/src/wallet.rs +++ b/crates/cdk-common/src/wallet.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use bitcoin::hashes::{sha256, Hash, HashEngine}; use cashu::util::hex; -use cashu::{nut00, Proofs, PublicKey}; +use cashu::{nut00, PaymentMethod, Proofs, PublicKey}; use serde::{Deserialize, Serialize}; use crate::mint_url::MintUrl; @@ -42,8 +42,11 @@ pub struct MintQuote { pub id: String, /// Mint Url pub mint_url: MintUrl, + /// Payment method + #[serde(default)] + pub payment_method: PaymentMethod, /// Amount of quote - pub amount: Amount, + pub amount: Option, /// Unit of quote pub unit: CurrencyUnit, /// Quote payment request e.g. bolt11 @@ -54,6 +57,12 @@ pub struct MintQuote { pub expiry: u64, /// Secretkey for signing mint quotes [NUT-20] pub secret_key: Option, + /// Amount minted + #[serde(default)] + pub amount_issued: Amount, + /// Amount paid to the mint for the quote + #[serde(default)] + pub amount_paid: Amount, } /// Melt Quote Info @@ -75,6 +84,65 @@ pub struct MeltQuote { pub expiry: u64, /// Payment preimage pub payment_preimage: Option, + /// Payment method + #[serde(default)] + pub payment_method: PaymentMethod, +} + +impl MintQuote { + /// Create a new MintQuote + #[allow(clippy::too_many_arguments)] + pub fn new( + id: String, + mint_url: MintUrl, + payment_method: PaymentMethod, + amount: Option, + unit: CurrencyUnit, + request: String, + expiry: u64, + secret_key: Option, + ) -> Self { + Self { + id, + mint_url, + payment_method, + amount, + unit, + request, + state: MintQuoteState::Unpaid, + expiry, + secret_key, + amount_issued: Amount::ZERO, + amount_paid: Amount::ZERO, + } + } + + /// Calculate the total amount including any fees + pub fn total_amount(&self) -> Amount { + self.amount_paid + } + + /// Check if the quote has expired + pub fn is_expired(&self, current_time: u64) -> bool { + current_time > self.expiry + } + + /// Amount that can be minted + pub fn amount_mintable(&self) -> Amount { + if self.amount_issued > self.amount_paid { + return Amount::ZERO; + } + + let difference = self.amount_paid - self.amount_issued; + + if difference == Amount::ZERO && self.state != MintQuoteState::Issued { + if let Some(amount) = self.amount { + return amount; + } + } + + difference + } } /// Send Kind @@ -134,6 +202,12 @@ pub struct Transaction { pub memo: Option, /// User-defined metadata pub metadata: HashMap, + /// Quote ID if this is a mint or melt transaction + pub quote_id: Option, + /// Payment request (e.g., BOLT11 invoice, BOLT12 offer) + pub payment_request: Option, + /// Payment proof (e.g., preimage for Lightning melt transactions) + pub payment_proof: Option, } impl Transaction { @@ -176,7 +250,10 @@ impl PartialOrd for Transaction { impl Ord for Transaction { fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.timestamp.cmp(&other.timestamp).reverse() + self.timestamp + .cmp(&other.timestamp) + .reverse() + .then_with(|| self.id().cmp(&other.id())) } } @@ -245,6 +322,9 @@ impl TransactionId { /// From hex string pub fn from_hex(value: &str) -> Result { let bytes = hex::decode(value)?; + if bytes.len() != 32 { + return Err(Error::InvalidTransactionId); + } let mut array = [0u8; 32]; array.copy_from_slice(&bytes); Ok(Self(array)) @@ -292,3 +372,29 @@ impl TryFrom for TransactionId { Self::from_proofs(proofs) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transaction_id_from_hex() { + let hex_str = "a1b2c3d4e5f60718293a0b1c2d3e4f506172839a0b1c2d3e4f506172839a0b1c"; + let transaction_id = TransactionId::from_hex(hex_str).unwrap(); + assert_eq!(transaction_id.to_string(), hex_str); + } + + #[test] + fn test_transaction_id_from_hex_empty_string() { + let hex_str = ""; + let res = TransactionId::from_hex(hex_str); + assert!(matches!(res, Err(Error::InvalidTransactionId))); + } + + #[test] + fn test_transaction_id_from_hex_longer_string() { + let hex_str = "a1b2c3d4e5f60718293a0b1c2d3e4f506172839a0b1c2d3e4f506172839a0b1ca1b2"; + let res = TransactionId::from_hex(hex_str); + assert!(matches!(res, Err(Error::InvalidTransactionId))); + } +} diff --git a/crates/cdk-common/src/ws.rs b/crates/cdk-common/src/ws.rs index 91ca5d228..6c01f3274 100644 --- a/crates/cdk-common/src/ws.rs +++ b/crates/cdk-common/src/ws.rs @@ -2,15 +2,17 @@ //! //! This module extends the `cashu` crate with types and functions for the CDK, using the correct //! expected ID types. +use std::sync::Arc; + #[cfg(feature = "mint")] use cashu::nut17::ws::JSON_RPC_VERSION; use cashu::nut17::{self}; #[cfg(feature = "mint")] -use cashu::NotificationPayload; +use cashu::quote_id::QuoteId; #[cfg(feature = "mint")] -use uuid::Uuid; +use cashu::NotificationPayload; -use crate::pub_sub::SubId; +type SubId = Arc; /// Request to unsubscribe from a websocket subscription pub type WsUnsubscribeRequest = nut17::ws::WsUnsubscribeRequest; @@ -48,7 +50,7 @@ pub type NotificationInner = nut17::ws::NotificationInner; #[cfg(feature = "mint")] /// Converts a notification with UUID identifiers to a notification with string identifiers pub fn notification_uuid_to_notification_string( - notification: NotificationInner, + notification: NotificationInner, ) -> NotificationInner { nut17::ws::NotificationInner { sub_id: notification.sub_id, @@ -60,13 +62,16 @@ pub fn notification_uuid_to_notification_string( NotificationPayload::MintQuoteBolt11Response(quote) => { NotificationPayload::MintQuoteBolt11Response(quote.to_string_id()) } + NotificationPayload::MintQuoteBolt12Response(quote) => { + NotificationPayload::MintQuoteBolt12Response(quote.to_string_id()) + } }, } } #[cfg(feature = "mint")] /// Converts a notification to a websocket message that can be sent to clients -pub fn notification_to_ws_message(notification: NotificationInner) -> WsMessageOrResponse { +pub fn notification_to_ws_message(notification: NotificationInner) -> WsMessageOrResponse { nut17::ws::WsMessageOrResponse::Notification(nut17::ws::WsNotification { jsonrpc: JSON_RPC_VERSION.to_owned(), method: "subscribe".to_string(), diff --git a/crates/cdk-fake-wallet/Cargo.toml b/crates/cdk-fake-wallet/Cargo.toml index 5f89a6770..a9b365a8e 100644 --- a/crates/cdk-fake-wallet/Cargo.toml +++ b/crates/cdk-fake-wallet/Cargo.toml @@ -13,7 +13,7 @@ readme = "README.md" [dependencies] async-trait.workspace = true bitcoin.workspace = true -cdk = { workspace = true, features = ["mint"] } +cdk-common = { workspace = true, features = ["mint"] } futures.workspace = true tokio.workspace = true tokio-util.workspace = true @@ -22,5 +22,7 @@ thiserror.workspace = true serde.workspace = true serde_json.workspace = true lightning-invoice.workspace = true +lightning.workspace = true tokio-stream.workspace = true reqwest.workspace = true +uuid.workspace = true diff --git a/crates/cdk-fake-wallet/src/error.rs b/crates/cdk-fake-wallet/src/error.rs index 69ee03219..f52dbc6e5 100644 --- a/crates/cdk-fake-wallet/src/error.rs +++ b/crates/cdk-fake-wallet/src/error.rs @@ -16,7 +16,7 @@ pub enum Error { NoReceiver, } -impl From for cdk::cdk_payment::Error { +impl From for cdk_common::payment::Error { fn from(e: Error) -> Self { Self::Lightning(Box::new(e)) } diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index fa9ef562f..aeb80790e 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -1,67 +1,376 @@ //! CDK Fake LN Backend //! -//! Used for testing where quotes are auto filled +//! Used for testing where quotes are auto filled. +//! +//! The fake wallet now includes a secondary repayment system that continuously repays any-amount +//! invoices (amount = 0) at random intervals between 30 seconds and 3 minutes to simulate +//! real-world behavior where invoices might get multiple payments. Payments continue to be +//! processed until they are evicted from the queue when the queue reaches its maximum size +//! (default 100 items). This is in addition to the original immediate payment processing +//! which is maintained for all invoice types. #![doc = include_str!("../README.md")] #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] use std::cmp::max; -use std::collections::{HashMap, HashSet}; +use std::collections::{HashMap, HashSet, VecDeque}; use std::pin::Pin; -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::time::{Duration, Instant}; use async_trait::async_trait; use bitcoin::hashes::{sha256, Hash}; -use bitcoin::secp256k1::rand::{thread_rng, Rng}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; -use cdk::amount::{to_unit, Amount}; -use cdk::cdk_payment::{ - self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment, - PaymentQuoteResponse, +use cdk_common::amount::{to_unit, Amount}; +use cdk_common::common::FeeReserve; +use cdk_common::ensure_cdk; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; +use cdk_common::payment::{ + self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, }; -use cdk::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState}; -use cdk::types::FeeReserve; -use cdk::{ensure_cdk, mint}; use error::Error; use futures::stream::StreamExt; use futures::Stream; +use lightning::offers::offer::OfferBuilder; use lightning_invoice::{Bolt11Invoice, Currency, InvoiceBuilder, PaymentSecret}; -use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; -use tokio::sync::Mutex; +use tokio::sync::{Mutex, RwLock}; use tokio::time; use tokio_stream::wrappers::ReceiverStream; use tokio_util::sync::CancellationToken; use tracing::instrument; +use uuid::Uuid; pub mod error; +/// Default maximum size for the secondary repayment queue +const DEFAULT_REPAY_QUEUE_MAX_SIZE: usize = 100; + +/// Cache duration for exchange rate (5 minutes) +const RATE_CACHE_DURATION: Duration = Duration::from_secs(300); + +/// Mempool.space prices API response structure +#[derive(Debug, Deserialize)] +struct MempoolPricesResponse { + #[serde(rename = "USD")] + usd: f64, + #[serde(rename = "EUR")] + eur: f64, +} + +/// Exchange rate cache with built-in fallback rates +#[derive(Debug, Clone)] +struct ExchangeRateCache { + rates: Arc>>, +} + +impl ExchangeRateCache { + fn new() -> Self { + Self { + rates: Arc::new(Mutex::new(None)), + } + } + + /// Get current BTC rate for the specified currency with caching and fallback + async fn get_btc_rate(&self, currency: &CurrencyUnit) -> Result { + // Return cached rate if still valid + { + let cached_rates = self.rates.lock().await; + if let Some((rates, timestamp)) = &*cached_rates { + if timestamp.elapsed() < RATE_CACHE_DURATION { + return Self::rate_for_currency(rates, currency); + } + } + } + + // Try to fetch fresh rates, fallback on error + match self.fetch_fresh_rate(currency).await { + Ok(rate) => Ok(rate), + Err(e) => { + tracing::warn!( + "Failed to fetch exchange rates, using fallback for {:?}: {}", + currency, + e + ); + Self::fallback_rate(currency) + } + } + } + + /// Fetch fresh rate and update cache + async fn fetch_fresh_rate(&self, currency: &CurrencyUnit) -> Result { + let url = "https://mempool.space/api/v1/prices"; + let response = reqwest::get(url) + .await + .map_err(|_| Error::UnknownInvoiceAmount)? + .json::() + .await + .map_err(|_| Error::UnknownInvoiceAmount)?; + + let rate = Self::rate_for_currency(&response, currency)?; + *self.rates.lock().await = Some((response, Instant::now())); + Ok(rate) + } + + fn rate_for_currency( + rates: &MempoolPricesResponse, + currency: &CurrencyUnit, + ) -> Result { + match currency { + CurrencyUnit::Usd => Ok(rates.usd), + CurrencyUnit::Eur => Ok(rates.eur), + _ => Err(Error::UnknownInvoiceAmount), + } + } + + fn fallback_rate(currency: &CurrencyUnit) -> Result { + match currency { + CurrencyUnit::Usd => Ok(110_000.0), // $110k per BTC + CurrencyUnit::Eur => Ok(95_000.0), // €95k per BTC + _ => Err(Error::UnknownInvoiceAmount), + } + } +} + +async fn convert_currency_amount( + amount: u64, + from_unit: &CurrencyUnit, + target_unit: &CurrencyUnit, + rate_cache: &ExchangeRateCache, +) -> Result { + use CurrencyUnit::*; + + // Try basic unit conversion first (handles SAT/MSAT and same-unit conversions) + if let Ok(converted) = to_unit(amount, from_unit, target_unit) { + return Ok(converted); + } + + // Handle fiat <-> bitcoin conversions that require exchange rates + match (from_unit, target_unit) { + // Fiat to Bitcoin conversions + (Usd | Eur, Sat) => { + let rate = rate_cache.get_btc_rate(from_unit).await?; + let fiat_amount = amount as f64 / 100.0; // cents to dollars/euros + Ok(Amount::from( + (fiat_amount / rate * 100_000_000.0).round() as u64 + )) // to sats + } + (Usd | Eur, Msat) => { + let rate = rate_cache.get_btc_rate(from_unit).await?; + let fiat_amount = amount as f64 / 100.0; // cents to dollars/euros + Ok(Amount::from( + (fiat_amount / rate * 100_000_000_000.0).round() as u64, + )) // to msats + } + + // Bitcoin to fiat conversions + (Sat, Usd | Eur) => { + let rate = rate_cache.get_btc_rate(target_unit).await?; + let btc_amount = amount as f64 / 100_000_000.0; // sats to BTC + Ok(Amount::from((btc_amount * rate * 100.0).round() as u64)) // to cents + } + (Msat, Usd | Eur) => { + let rate = rate_cache.get_btc_rate(target_unit).await?; + let btc_amount = amount as f64 / 100_000_000_000.0; // msats to BTC + Ok(Amount::from((btc_amount * rate * 100.0).round() as u64)) // to cents + } + + _ => Err(Error::UnknownInvoiceAmount), // Unsupported conversion + } +} + +/// Secondary repayment queue manager for any-amount invoices +#[derive(Debug, Clone)] +struct SecondaryRepaymentQueue { + queue: Arc>>, + max_size: usize, + sender: tokio::sync::mpsc::Sender, + unit: CurrencyUnit, +} + +impl SecondaryRepaymentQueue { + fn new( + max_size: usize, + sender: tokio::sync::mpsc::Sender, + unit: CurrencyUnit, + ) -> Self { + let queue = Arc::new(Mutex::new(VecDeque::new())); + let repayment_queue = Self { + queue: queue.clone(), + max_size, + sender, + unit, + }; + + // Start the background secondary repayment processor + repayment_queue.start_secondary_repayment_processor(); + + repayment_queue + } + + /// Add a payment to the secondary repayment queue + async fn enqueue_for_repayment(&self, payment: PaymentIdentifier) { + let mut queue = self.queue.lock().await; + + // If queue is at max capacity, remove the oldest item + if queue.len() >= self.max_size { + if let Some(dropped) = queue.pop_front() { + tracing::debug!( + "Secondary repayment queue at capacity, dropping oldest payment: {:?}", + dropped + ); + } + } + + queue.push_back(payment); + tracing::debug!( + "Added payment to secondary repayment queue, current size: {}", + queue.len() + ); + } + + /// Start the background task that randomly processes secondary repayments from the queue + fn start_secondary_repayment_processor(&self) { + let queue = self.queue.clone(); + let sender = self.sender.clone(); + let unit = self.unit.clone(); + + tokio::spawn(async move { + use bitcoin::secp256k1::rand::rngs::OsRng; + use bitcoin::secp256k1::rand::Rng; + let mut rng = OsRng; + + loop { + // Wait for a random interval between 30 seconds and 3 minutes (180 seconds) + let delay_secs = rng.gen_range(1..=3); + time::sleep(time::Duration::from_secs(delay_secs)).await; + + // Try to process a random payment from the queue without removing it + let payment_to_process = { + let q = queue.lock().await; + if q.is_empty() { + None + } else { + // Pick a random index from the queue but don't remove it + let index = rng.gen_range(0..q.len()); + q.get(index).cloned() + } + }; + + if let Some(payment) = payment_to_process { + // Generate a random amount for this secondary payment (same range as initial payment: 1-1000) + let random_amount: u64 = rng.gen_range(1..=1000); + + // Create amount based on unit, ensuring minimum of 1 sat worth + let secondary_amount = match &unit { + CurrencyUnit::Sat => Amount::from(random_amount), + CurrencyUnit::Msat => Amount::from(u64::max(random_amount * 1000, 1000)), + _ => Amount::from(u64::max(random_amount, 1)), // fallback + }; + + // Generate a unique payment identifier for this secondary payment + // We'll create a new payment hash by appending a timestamp and random bytes + use bitcoin::hashes::{sha256, Hash}; + let mut random_bytes = [0u8; 16]; + rng.fill(&mut random_bytes); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64; + + // Create a unique hash combining the original payment identifier, timestamp, and random bytes + let mut hasher_input = Vec::new(); + hasher_input.extend_from_slice(payment.to_string().as_bytes()); + hasher_input.extend_from_slice(×tamp.to_le_bytes()); + hasher_input.extend_from_slice(&random_bytes); + + let unique_hash = sha256::Hash::hash(&hasher_input); + let unique_payment_id = PaymentIdentifier::PaymentHash(*unique_hash.as_ref()); + + tracing::info!( + "Processing secondary repayment: original={:?}, new_id={:?}, amount={}", + payment, + unique_payment_id, + secondary_amount + ); + + // Send the payment notification using the original payment identifier + // The mint will process this through the normal payment stream + let secondary_response = WaitPaymentResponse { + payment_identifier: payment.clone(), + payment_amount: secondary_amount, + unit: unit.clone(), + payment_id: unique_payment_id.to_string(), + }; + + if let Err(e) = sender.send(secondary_response).await { + tracing::error!( + "Failed to send secondary repayment notification for {:?}: {}", + unique_payment_id, + e + ); + } + } + } + }); + } +} + /// Fake Wallet #[derive(Clone)] pub struct FakeWallet { fee_reserve: FeeReserve, - sender: tokio::sync::mpsc::Sender, - receiver: Arc>>>, - payment_states: Arc>>, + sender: tokio::sync::mpsc::Sender, + receiver: Arc>>>, + payment_states: Arc>>, failed_payment_check: Arc>>, payment_delay: u64, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, + incoming_payments: Arc>>>, + unit: CurrencyUnit, + secondary_repayment_queue: SecondaryRepaymentQueue, + exchange_rate_cache: ExchangeRateCache, } impl FakeWallet { /// Create new [`FakeWallet`] pub fn new( fee_reserve: FeeReserve, - payment_states: HashMap, + payment_states: HashMap, + fail_payment_check: HashSet, + payment_delay: u64, + unit: CurrencyUnit, + ) -> Self { + Self::new_with_repay_queue_size( + fee_reserve, + payment_states, + fail_payment_check, + payment_delay, + unit, + DEFAULT_REPAY_QUEUE_MAX_SIZE, + ) + } + + /// Create new [`FakeWallet`] with custom secondary repayment queue size + pub fn new_with_repay_queue_size( + fee_reserve: FeeReserve, + payment_states: HashMap, fail_payment_check: HashSet, payment_delay: u64, + unit: CurrencyUnit, + repay_queue_max_size: usize, ) -> Self { let (sender, receiver) = tokio::sync::mpsc::channel(8); + let incoming_payments = Arc::new(RwLock::new(HashMap::new())); + + let secondary_repayment_queue = + SecondaryRepaymentQueue::new(repay_queue_max_size, sender.clone(), unit.clone()); Self { fee_reserve, @@ -72,6 +381,10 @@ impl FakeWallet { payment_delay, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + incoming_payments, + unit, + secondary_repayment_queue, + exchange_rate_cache: ExchangeRateCache::new(), } } } @@ -102,15 +415,16 @@ impl Default for FakeInvoiceDescription { #[async_trait] impl MintPayment for FakeWallet { - type Err = cdk_payment::Error; + type Err = payment::Error; #[instrument(skip_all)] async fn get_settings(&self) -> Result { Ok(serde_json::to_value(Bolt11Settings { mpp: true, - unit: CurrencyUnit::Msat, + unit: self.unit.clone(), invoice_description: true, amountless: false, + bolt12: true, })?) } @@ -125,53 +439,86 @@ impl MintPayment for FakeWallet { } #[instrument(skip_all)] - async fn wait_any_incoming_payment( + async fn wait_payment_event( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { tracing::info!("Starting stream for fake invoices"); - let receiver = self.receiver.lock().await.take().ok_or(Error::NoReceiver)?; + let receiver = self + .receiver + .lock() + .await + .take() + .ok_or(Error::NoReceiver) + .unwrap(); let receiver_stream = ReceiverStream::new(receiver); - Ok(Box::pin(receiver_stream.map(|label| label))) + Ok(Box::pin(receiver_stream.map(move |wait_response| { + Event::PaymentReceived(wait_response) + }))) } #[instrument(skip_all)] async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(request)?; - - let amount_msat = match options { - Some(amount) => amount.amount_msat(), - None => bolt11 - .amount_milli_satoshis() - .ok_or(Error::UnknownInvoiceAmount)? - .into(), + let (amount_msat, request_lookup_id) = match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + // If we have specific amount options, use those + let amount_msat: u64 = if let Some(melt_options) = bolt11_options.melt_options { + let msats = match melt_options { + MeltOptions::Amountless { amountless } => { + let amount_msat = amountless.amount_msat; + + if let Some(invoice_amount) = + bolt11_options.bolt11.amount_milli_satoshis() + { + ensure_cdk!( + invoice_amount == u64::from(amount_msat), + Error::UnknownInvoiceAmount.into() + ); + } + amount_msat + } + MeltOptions::Mpp { mpp } => mpp.amount, + }; + + u64::from(msats) + } else { + // Fall back to invoice amount + bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + }; + let payment_id = + PaymentIdentifier::PaymentHash(*bolt11_options.bolt11.payment_hash().as_ref()); + (amount_msat, Some(payment_id)) + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = bolt12_options.offer; + + let amount_msat: u64 = if let Some(amount) = bolt12_options.melt_options { + amount.amount_msat().into() + } else { + // Fall back to offer amount + let amount = offer.amount().ok_or(Error::UnknownInvoiceAmount)?; + match amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => amount_msats, + _ => return Err(Error::UnknownInvoiceAmount.into()), + } + }; + (amount_msat, None) + } }; - let amount = if unit != &CurrencyUnit::Sat && unit != &CurrencyUnit::Msat { - let client = Client::new(); - - let response: Value = client - .get("https://mempool.space/api/v1/prices") - .send() - .await - .map_err(|_| Error::UnknownInvoice)? - .json() - .await - .unwrap(); - - let price = response.get(unit.to_string().to_uppercase()).unwrap(); - - let bitcoin_amount = u64::from(amount_msat) as f64 / 100_000_000_000.0; - let total_price = price.as_f64().unwrap() * bitcoin_amount; - - Amount::from((total_price * 100.0).ceil() as u64) - } else { - to_unit(amount_msat, &CurrencyUnit::Msat, unit)? - }; + let amount = convert_currency_amount( + amount_msat, + &CurrencyUnit::Msat, + unit, + &self.exchange_rate_cache, + ) + .await?; let relative_fee_reserve = (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; @@ -181,96 +528,241 @@ impl MintPayment for FakeWallet { let fee = max(relative_fee_reserve, absolute_fee_reserve); Ok(PaymentQuoteResponse { - request_lookup_id: bolt11.payment_hash().to_string(), + request_lookup_id, amount, fee: fee.into(), - unit: unit.clone(), state: MeltQuoteState::Unpaid, + unit: unit.clone(), }) } #[instrument(skip_all)] async fn make_payment( &self, - melt_quote: mint::MeltQuote, - _partial_msats: Option, - _max_fee_msats: Option, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(&melt_quote.request)?; - - let payment_hash = bolt11.payment_hash().to_string(); - - let description = bolt11.description().to_string(); - - let status: Option = serde_json::from_str(&description).ok(); - - let mut payment_states = self.payment_states.lock().await; - let payment_status = status - .clone() - .map(|s| s.pay_invoice_state) - .unwrap_or(MeltQuoteState::Paid); - - let checkout_going_status = status - .clone() - .map(|s| s.check_payment_state) - .unwrap_or(MeltQuoteState::Paid); - - payment_states.insert(payment_hash.clone(), checkout_going_status); - - if let Some(description) = status { - if description.check_err { - let mut fail = self.failed_payment_check.lock().await; - fail.insert(payment_hash.clone()); + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; + let payment_hash = bolt11.payment_hash().to_string(); + + let description = bolt11.description().to_string(); + + let status: Option = + serde_json::from_str(&description).ok(); + + let mut payment_states = self.payment_states.lock().await; + let payment_status = status + .clone() + .map(|s| s.pay_invoice_state) + .unwrap_or(MeltQuoteState::Paid); + + let checkout_going_status = status + .clone() + .map(|s| s.check_payment_state) + .unwrap_or(MeltQuoteState::Paid); + + 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 { + let mut fail = self.failed_payment_check.lock().await; + fail.insert(payment_hash.clone()); + } + + ensure_cdk!(!description.pay_err, Error::UnknownInvoice.into()); + } + + let total_spent = convert_currency_amount( + amount_msat, + &CurrencyUnit::Msat, + unit, + &self.exchange_rate_cache, + ) + .await?; + + Ok(MakePaymentResponse { + payment_proof: Some("".to_string()), + payment_lookup_id: PaymentIdentifier::PaymentHash( + *bolt11.payment_hash().as_ref(), + ), + status: payment_status, + total_spent: total_spent + 1.into(), + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let bolt12 = bolt12_options.offer; + let amount_msat: u64 = if let Some(amount) = bolt12_options.melt_options { + amount.amount_msat().into() + } else { + // Fall back to offer amount + let amount = bolt12.amount().ok_or(Error::UnknownInvoiceAmount)?; + match amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => amount_msats, + _ => return Err(Error::UnknownInvoiceAmount.into()), + } + }; + + let total_spent = convert_currency_amount( + amount_msat, + &CurrencyUnit::Msat, + unit, + &self.exchange_rate_cache, + ) + .await?; + + Ok(MakePaymentResponse { + payment_proof: Some("".to_string()), + payment_lookup_id: PaymentIdentifier::CustomId(Uuid::new_v4().to_string()), + status: MeltQuoteState::Paid, + total_spent: total_spent + 1.into(), + unit: unit.clone(), + }) } - - ensure_cdk!(!description.pay_err, Error::UnknownInvoice.into()); } - - Ok(MakePaymentResponse { - payment_proof: Some("".to_string()), - payment_lookup_id: payment_hash, - status: payment_status, - total_spent: melt_quote.amount + 1.into(), - unit: melt_quote.unit, - }) } #[instrument(skip_all)] async fn create_incoming_payment_request( &self, - amount: Amount, - _unit: &CurrencyUnit, - description: String, - _unix_expiry: Option, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, ) -> Result { - // Since this is fake we just use the amount no matter the unit to create an invoice - let amount_msat = amount; - - let invoice = create_fake_invoice(amount_msat.into(), description); + let (payment_hash, request, amount, expiry) = match options { + IncomingPaymentOptions::Bolt12(bolt12_options) => { + let description = bolt12_options.description.unwrap_or_default(); + let amount = bolt12_options.amount; + let expiry = bolt12_options.unix_expiry; + + let secret_key = SecretKey::new(&mut bitcoin::secp256k1::rand::rngs::OsRng); + let secp_ctx = Secp256k1::new(); + + let offer_builder = OfferBuilder::new(secret_key.public_key(&secp_ctx)) + .description(description.clone()); + + let offer_builder = match amount { + Some(amount) => { + let amount_msat = convert_currency_amount( + u64::from(amount), + unit, + &CurrencyUnit::Msat, + &self.exchange_rate_cache, + ) + .await?; + offer_builder.amount_msats(amount_msat.into()) + } + None => offer_builder, + }; + + let offer = offer_builder.build().unwrap(); + + ( + PaymentIdentifier::OfferId(offer.id().to_string()), + offer.to_string(), + amount.unwrap_or(Amount::ZERO), + expiry, + ) + } + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let description = bolt11_options.description.unwrap_or_default(); + let amount = bolt11_options.amount; + let expiry = bolt11_options.unix_expiry; + + let amount_msat = convert_currency_amount( + u64::from(amount), + unit, + &CurrencyUnit::Msat, + &self.exchange_rate_cache, + ) + .await? + .into(); + + let invoice = create_fake_invoice(amount_msat, description.clone()); + let payment_hash = invoice.payment_hash(); + + ( + PaymentIdentifier::PaymentHash(*payment_hash.as_ref()), + invoice.to_string(), + amount, + expiry, + ) + } + }; + // ALL invoices get immediate payment processing (original behavior) let sender = self.sender.clone(); - - let payment_hash = invoice.payment_hash(); - - let payment_hash_clone = payment_hash.to_string(); - let duration = time::Duration::from_secs(self.payment_delay); + let payment_hash_clone = payment_hash.clone(); + let incoming_payment = self.incoming_payments.clone(); + let unit_clone = self.unit.clone(); + + let final_amount = if amount == Amount::ZERO { + // For any-amount invoices, generate a random amount for the initial payment + use bitcoin::secp256k1::rand::rngs::OsRng; + use bitcoin::secp256k1::rand::Rng; + let mut rng = OsRng; + let random_amount: u64 = rng.gen_range(1000..=10000); + // Use the same unit as the wallet for any-amount invoices + Amount::from(random_amount) + } else { + amount + }; + // Schedule the immediate payment (original behavior maintained) tokio::spawn(async move { // Wait for the random delay to elapse time::sleep(duration).await; + let response = WaitPaymentResponse { + payment_identifier: payment_hash_clone.clone(), + payment_amount: final_amount, + unit: unit_clone, + payment_id: payment_hash_clone.to_string(), + }; + let mut incoming = incoming_payment.write().await; + incoming + .entry(payment_hash_clone.clone()) + .or_insert_with(Vec::new) + .push(response.clone()); + // Send the message after waiting for the specified duration - if sender.send(payment_hash_clone.clone()).await.is_err() { - tracing::error!("Failed to send label: {}", payment_hash_clone); + if sender.send(response.clone()).await.is_err() { + tracing::error!("Failed to send label: {:?}", payment_hash_clone); } }); - let expiry = invoice.expires_at().map(|t| t.as_secs()); + // For any-amount invoices ONLY, also add to the secondary repayment queue + if amount == Amount::ZERO { + tracing::info!( + "Adding any-amount invoice to secondary repayment queue: {:?}", + payment_hash + ); + + self.secondary_repayment_queue + .enqueue_for_repayment(payment_hash.clone()) + .await; + } Ok(CreateIncomingPaymentResponse { - request_lookup_id: payment_hash.to_string(), - request: invoice.to_string(), + request_lookup_id: payment_hash, + request, expiry, }) } @@ -278,33 +770,39 @@ impl MintPayment for FakeWallet { #[instrument(skip_all)] async fn check_incoming_payment_status( &self, - _request_lookup_id: &str, - ) -> Result { - Ok(MintQuoteState::Paid) + request_lookup_id: &PaymentIdentifier, + ) -> Result, Self::Err> { + Ok(self + .incoming_payments + .read() + .await + .get(request_lookup_id) + .cloned() + .unwrap_or(vec![])) } #[instrument(skip_all)] async fn check_outgoing_payment( &self, - request_lookup_id: &str, + request_lookup_id: &PaymentIdentifier, ) -> Result { // For fake wallet if the state is not explicitly set default to paid let states = self.payment_states.lock().await; - let status = states.get(request_lookup_id).cloned(); + 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; - if fail_payments.contains(request_lookup_id) { - return Err(cdk_payment::Error::InvoicePaymentPending); + if fail_payments.contains(&request_lookup_id.to_string()) { + return Err(payment::Error::InvoicePaymentPending); } Ok(MakePaymentResponse { payment_proof: Some("".to_string()), - payment_lookup_id: request_lookup_id.to_string(), + payment_lookup_id: request_lookup_id.clone(), status, - total_spent: Amount::ZERO, + total_spent, unit: CurrencyUnit::Msat, }) } @@ -322,7 +820,9 @@ pub fn create_fake_invoice(amount_msat: u64, description: String) -> Bolt11Invoi ) .unwrap(); - let mut rng = thread_rng(); + use bitcoin::secp256k1::rand::rngs::OsRng; + use bitcoin::secp256k1::rand::Rng; + let mut rng = OsRng; let mut random_bytes = [0u8; 32]; rng.fill(&mut random_bytes); diff --git a/crates/cdk-ffi/.cargo/config.toml b/crates/cdk-ffi/.cargo/config.toml new file mode 100644 index 000000000..6adffde7d --- /dev/null +++ b/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", +] diff --git a/crates/cdk-ffi/Cargo.toml b/crates/cdk-ffi/Cargo.toml new file mode 100644 index 000000000..dd9758b8b --- /dev/null +++ b/crates/cdk-ffi/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "cdk-ffi" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "FFI bindings for cdk wallet" +homepage = "https://github.com/cashubtc/cdk" + +[lib] +crate-type = ["cdylib", "staticlib", "rlib"] +name = "cdk_ffi" + +[dependencies] +async-trait = { workspace = true } +bip39 = { workspace = true } +cdk = { workspace = true, default-features = false, features = ["wallet", "auth", "bip353"] } +cdk-sqlite = { workspace = true } +cdk-postgres = { workspace = true, optional = true } +futures = { workspace = true } +once_cell = { workspace = true } +rand = { workspace = true } +serde = { workspace = true, features = ["derive", "rc"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["sync", "rt", "rt-multi-thread"] } +uniffi = { version = "0.29", features = ["cli", "tokio"] } +url = { workspace = true } +uuid = { workspace = true, features = ["v4"] } + + +[features] +default = ["postgres"] +# Enable Postgres-backed wallet database support in FFI +postgres = ["cdk-postgres"] + +[dev-dependencies] + +[[bin]] +name = "uniffi-bindgen" +path = "src/bin/uniffi-bindgen.rs" diff --git a/crates/cdk-ffi/README.md b/crates/cdk-ffi/README.md new file mode 100644 index 000000000..1d28ccd04 --- /dev/null +++ b/crates/cdk-ffi/README.md @@ -0,0 +1,61 @@ +# CDK FFI Bindings + +UniFFI bindings for the CDK (Cashu Development Kit), providing foreign function interface access to wallet functionality for multiple programming languages. + +## Supported Languages + +- **🐍 Python** - With REPL integration for development +- **🍎 Swift** - iOS and macOS development +- **🎯 Kotlin** - Android and JVM development + +## Development Tasks + +### Build & Check +```bash +just ffi-build # Build FFI library (release) +just ffi-build --debug # Build debug version +just ffi-check # Check compilation +just ffi-clean # Clean build artifacts +``` + +### Generate Bindings +```bash +# Generate for specific languages +just ffi-generate python +just ffi-generate swift +just ffi-generate kotlin + +# Generate all languages +just ffi-generate-all + +# Use --debug for faster development builds +just ffi-generate python --debug +``` + +### Development & Testing +```bash +# Python development with REPL +just ffi-dev-python # Generates bindings and opens Python REPL with cdk_ffi loaded + +# Test bindings +just ffi-test-python # Test Python bindings import +``` + +## Quick Start + +```bash +# Start development +just ffi-dev-python + +# In the Python REPL: +>>> dir(cdk_ffi) # Explore available functions +>>> help(cdk_ffi.generate_mnemonic) # Get help +``` + +## Language Packages + +For production use, see language-specific repositories: + +- [cdk-swift](https://github.com/cashubtc/cdk-swift) - iOS/macOS packages +- [cdk-kotlin](https://github.com/cashubtc/cdk-kotlin) - Android/JVM packages +- [cdk-python](https://github.com/cashubtc/cdk-python) - PyPI packages \ No newline at end of file diff --git a/crates/cdk-ffi/src/bin/uniffi-bindgen.rs b/crates/cdk-ffi/src/bin/uniffi-bindgen.rs new file mode 100644 index 000000000..f6cff6cf1 --- /dev/null +++ b/crates/cdk-ffi/src/bin/uniffi-bindgen.rs @@ -0,0 +1,3 @@ +fn main() { + uniffi::uniffi_bindgen_main() +} diff --git a/crates/cdk-ffi/src/database.rs b/crates/cdk-ffi/src/database.rs new file mode 100644 index 000000000..ec1dadbd4 --- /dev/null +++ b/crates/cdk-ffi/src/database.rs @@ -0,0 +1,664 @@ +//! FFI Database bindings + +use std::collections::HashMap; +use std::sync::Arc; + +use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; + +use crate::error::FfiError; +#[cfg(feature = "postgres")] +use crate::postgres::WalletPostgresDatabase; +use crate::sqlite::WalletSqliteDatabase; +use crate::types::*; + +/// FFI-compatible trait for wallet database operations +/// This trait mirrors the CDK WalletDatabase trait but uses FFI-compatible types +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait WalletDatabase: Send + Sync { + // Mint Management + /// Add Mint to storage + async fn add_mint( + &self, + mint_url: MintUrl, + mint_info: Option, + ) -> Result<(), FfiError>; + + /// Remove Mint from storage + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError>; + + /// Get mint from storage + async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError>; + + /// Get all mints from storage + async fn get_mints(&self) -> Result>, FfiError>; + + /// Update mint url + async fn update_mint_url( + &self, + old_mint_url: MintUrl, + new_mint_url: MintUrl, + ) -> Result<(), FfiError>; + + // Keyset Management + /// Add mint keyset to storage + async fn add_mint_keysets( + &self, + mint_url: MintUrl, + keysets: Vec, + ) -> Result<(), FfiError>; + + /// Get mint keysets for mint url + async fn get_mint_keysets( + &self, + mint_url: MintUrl, + ) -> Result>, FfiError>; + + /// Get mint keyset by id + async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError>; + + // Mint Quote Management + /// Add mint quote to storage + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError>; + + /// Get mint quote from storage + async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError>; + + /// Get mint quotes from storage + async fn get_mint_quotes(&self) -> Result, FfiError>; + + /// Remove mint quote from storage + async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError>; + + // Melt Quote Management + /// Add melt quote to storage + async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError>; + + /// Get melt quote from storage + async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError>; + + /// Get melt quotes from storage + async fn get_melt_quotes(&self) -> Result, FfiError>; + + /// Remove melt quote from storage + async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError>; + + // Keys Management + /// Add Keys to storage + async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError>; + + /// Get Keys from storage + async fn get_keys(&self, id: Id) -> Result, FfiError>; + + /// Remove Keys from storage + async fn remove_keys(&self, id: Id) -> Result<(), FfiError>; + + // Proof Management + /// Update the proofs in storage by adding new proofs or removing proofs by their Y value + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), FfiError>; + + /// Get proofs from storage + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, FfiError>; + + /// Get proofs by Y values + async fn get_proofs_by_ys(&self, ys: Vec) -> Result, FfiError>; + + /// Get balance efficiently using SQL aggregation + async fn get_balance( + &self, + mint_url: Option, + unit: Option, + state: Option>, + ) -> Result; + + /// Update proofs state in storage + async fn update_proofs_state( + &self, + ys: Vec, + state: ProofState, + ) -> Result<(), FfiError>; + + // Keyset Counter Management + /// Increment Keyset counter + async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result; + + // Transaction Management + /// Add transaction to storage + async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError>; + + /// Get transaction from storage + async fn get_transaction( + &self, + transaction_id: TransactionId, + ) -> Result, FfiError>; + + /// List transactions from storage + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, FfiError>; + + /// Remove transaction from storage + async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError>; +} + +/// Internal bridge trait to convert from the FFI trait to the CDK database trait +/// This allows us to bridge between the UniFFI trait and the CDK's internal database trait +struct WalletDatabaseBridge { + ffi_db: Arc, +} + +impl WalletDatabaseBridge { + fn new(ffi_db: Arc) -> Self { + Self { ffi_db } + } +} + +impl std::fmt::Debug for WalletDatabaseBridge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "WalletDatabaseBridge") + } +} + +#[async_trait::async_trait] +impl CdkWalletDatabase for WalletDatabaseBridge { + type Err = cdk::cdk_database::Error; + + // Mint Management + async fn add_mint( + &self, + mint_url: cdk::mint_url::MintUrl, + mint_info: Option, + ) -> Result<(), Self::Err> { + let ffi_mint_url = mint_url.into(); + let ffi_mint_info = mint_info.map(Into::into); + self.ffi_db + .add_mint(ffi_mint_url, ffi_mint_info) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn remove_mint(&self, mint_url: cdk::mint_url::MintUrl) -> Result<(), Self::Err> { + let ffi_mint_url = mint_url.into(); + self.ffi_db + .remove_mint(ffi_mint_url) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn get_mint( + &self, + mint_url: cdk::mint_url::MintUrl, + ) -> Result, Self::Err> { + let ffi_mint_url = mint_url.into(); + let result = self + .ffi_db + .get_mint(ffi_mint_url) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + Ok(result.map(Into::into)) + } + + async fn get_mints( + &self, + ) -> Result>, Self::Err> { + let result = self + .ffi_db + .get_mints() + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + + let mut cdk_result = HashMap::new(); + for (ffi_mint_url, mint_info_opt) in result { + let cdk_url = ffi_mint_url + .try_into() + .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into()))?; + cdk_result.insert(cdk_url, mint_info_opt.map(Into::into)); + } + Ok(cdk_result) + } + + async fn update_mint_url( + &self, + old_mint_url: cdk::mint_url::MintUrl, + new_mint_url: cdk::mint_url::MintUrl, + ) -> Result<(), Self::Err> { + let ffi_old_mint_url = old_mint_url.into(); + let ffi_new_mint_url = new_mint_url.into(); + self.ffi_db + .update_mint_url(ffi_old_mint_url, ffi_new_mint_url) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + // Keyset Management + async fn add_mint_keysets( + &self, + mint_url: cdk::mint_url::MintUrl, + keysets: Vec, + ) -> Result<(), Self::Err> { + let ffi_mint_url = mint_url.into(); + let ffi_keysets: Vec = keysets.into_iter().map(Into::into).collect(); + + self.ffi_db + .add_mint_keysets(ffi_mint_url, ffi_keysets) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn get_mint_keysets( + &self, + mint_url: cdk::mint_url::MintUrl, + ) -> Result>, Self::Err> { + let ffi_mint_url = mint_url.into(); + let result = self + .ffi_db + .get_mint_keysets(ffi_mint_url) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) + } + + async fn get_keyset_by_id( + &self, + keyset_id: &cdk::nuts::Id, + ) -> Result, Self::Err> { + let ffi_id = (*keyset_id).into(); + let result = self + .ffi_db + .get_keyset_by_id(ffi_id) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + Ok(result.map(Into::into)) + } + + // Mint Quote Management + async fn add_mint_quote(&self, quote: cdk::wallet::MintQuote) -> Result<(), Self::Err> { + let ffi_quote = quote.into(); + self.ffi_db + .add_mint_quote(ffi_quote) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn get_mint_quote( + &self, + quote_id: &str, + ) -> Result, Self::Err> { + let result = self + .ffi_db + .get_mint_quote(quote_id.to_string()) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + Ok(result + .map(|q| { + q.try_into() + .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into())) + }) + .transpose()?) + } + + async fn get_mint_quotes(&self) -> Result, Self::Err> { + let result = self + .ffi_db + .get_mint_quotes() + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + Ok(result + .into_iter() + .map(|q| { + q.try_into() + .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into())) + }) + .collect::, _>>()?) + } + + async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err> { + self.ffi_db + .remove_mint_quote(quote_id.to_string()) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + // Melt Quote Management + async fn add_melt_quote(&self, quote: cdk::wallet::MeltQuote) -> Result<(), Self::Err> { + let ffi_quote = quote.into(); + self.ffi_db + .add_melt_quote(ffi_quote) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn get_melt_quote( + &self, + quote_id: &str, + ) -> Result, Self::Err> { + let result = self + .ffi_db + .get_melt_quote(quote_id.to_string()) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + Ok(result + .map(|q| { + q.try_into() + .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into())) + }) + .transpose()?) + } + + async fn get_melt_quotes(&self) -> Result, Self::Err> { + let result = self + .ffi_db + .get_melt_quotes() + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + Ok(result + .into_iter() + .map(|q| { + q.try_into() + .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into())) + }) + .collect::, _>>()?) + } + + async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> { + self.ffi_db + .remove_melt_quote(quote_id.to_string()) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + // Keys Management + async fn add_keys(&self, keyset: cdk::nuts::KeySet) -> Result<(), Self::Err> { + let ffi_keyset: KeySet = keyset.into(); + self.ffi_db + .add_keys(ffi_keyset) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn get_keys(&self, id: &cdk::nuts::Id) -> Result, Self::Err> { + let ffi_id: Id = (*id).into(); + let result = self + .ffi_db + .get_keys(ffi_id) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + + // Convert FFI Keys back to CDK Keys using TryFrom + result + .map(|ffi_keys| { + ffi_keys + .try_into() + .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into())) + }) + .transpose() + } + + async fn remove_keys(&self, id: &cdk::nuts::Id) -> Result<(), Self::Err> { + let ffi_id = (*id).into(); + self.ffi_db + .remove_keys(ffi_id) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + // Proof Management + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), Self::Err> { + let ffi_added: Vec = added.into_iter().map(Into::into).collect(); + let ffi_removed_ys: Vec = removed_ys.into_iter().map(Into::into).collect(); + + self.ffi_db + .update_proofs(ffi_added, ffi_removed_ys) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, Self::Err> { + let ffi_mint_url = mint_url.map(Into::into); + let ffi_unit = unit.map(Into::into); + let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect()); + let ffi_spending_conditions = + spending_conditions.map(|sc| sc.into_iter().map(Into::into).collect()); + + let result = self + .ffi_db + .get_proofs(ffi_mint_url, ffi_unit, ffi_state, ffi_spending_conditions) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + + // Convert back to CDK ProofInfo + let cdk_result: Result, cdk::cdk_database::Error> = result + .into_iter() + .map(|info| { + Ok(cdk::types::ProofInfo { + proof: info.proof.try_into().map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?, + y: info.y.try_into().map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?, + mint_url: info.mint_url.try_into().map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?, + state: info.state.into(), + spending_condition: info + .spending_condition + .map(|sc| sc.try_into()) + .transpose() + .map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?, + unit: info.unit.into(), + }) + }) + .collect(); + + cdk_result + } + + async fn get_proofs_by_ys( + &self, + ys: Vec, + ) -> Result, Self::Err> { + let ffi_ys: Vec = ys.into_iter().map(Into::into).collect(); + + let result = self + .ffi_db + .get_proofs_by_ys(ffi_ys) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + + // Convert back to CDK ProofInfo + let cdk_result: Result, cdk::cdk_database::Error> = result + .into_iter() + .map(|info| { + Ok(cdk::types::ProofInfo { + proof: info.proof.try_into().map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?, + y: info.y.try_into().map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?, + mint_url: info.mint_url.try_into().map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?, + state: info.state.into(), + spending_condition: info + .spending_condition + .map(|sc| sc.try_into()) + .transpose() + .map_err(|e: FfiError| { + cdk::cdk_database::Error::Database(e.to_string().into()) + })?, + unit: info.unit.into(), + }) + }) + .collect(); + + cdk_result + } + + async fn get_balance( + &self, + mint_url: Option, + unit: Option, + state: Option>, + ) -> Result { + let ffi_mint_url = mint_url.map(Into::into); + let ffi_unit = unit.map(Into::into); + let ffi_state = state.map(|s| s.into_iter().map(Into::into).collect()); + + self.ffi_db + .get_balance(ffi_mint_url, ffi_unit, ffi_state) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn update_proofs_state( + &self, + ys: Vec, + state: cdk::nuts::State, + ) -> Result<(), Self::Err> { + let ffi_ys: Vec = ys.into_iter().map(Into::into).collect(); + let ffi_state = state.into(); + + self.ffi_db + .update_proofs_state(ffi_ys, ffi_state) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + // Keyset Counter Management + async fn increment_keyset_counter( + &self, + keyset_id: &cdk::nuts::Id, + count: u32, + ) -> Result { + let ffi_id = (*keyset_id).into(); + self.ffi_db + .increment_keyset_counter(ffi_id, count) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + // Transaction Management + async fn add_transaction( + &self, + transaction: cdk::wallet::types::Transaction, + ) -> Result<(), Self::Err> { + let ffi_transaction = transaction.into(); + self.ffi_db + .add_transaction(ffi_transaction) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn get_transaction( + &self, + transaction_id: cdk::wallet::types::TransactionId, + ) -> Result, Self::Err> { + let ffi_id = transaction_id.into(); + let result = self + .ffi_db + .get_transaction(ffi_id) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + + result + .map(|tx| tx.try_into()) + .transpose() + .map_err(|e: FfiError| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, Self::Err> { + let ffi_mint_url = mint_url.map(Into::into); + let ffi_direction = direction.map(Into::into); + let ffi_unit = unit.map(Into::into); + + let result = self + .ffi_db + .list_transactions(ffi_mint_url, ffi_direction, ffi_unit) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into()))?; + + result + .into_iter() + .map(|tx| tx.try_into()) + .collect::, FfiError>>() + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } + + async fn remove_transaction( + &self, + transaction_id: cdk::wallet::types::TransactionId, + ) -> Result<(), Self::Err> { + let ffi_id = transaction_id.into(); + self.ffi_db + .remove_transaction(ffi_id) + .await + .map_err(|e| cdk::cdk_database::Error::Database(e.to_string().into())) + } +} + +/// FFI-safe wallet database backend selection +#[derive(uniffi::Enum)] +pub enum WalletDbBackend { + Sqlite { + path: String, + }, + #[cfg(feature = "postgres")] + Postgres { + url: String, + }, +} + +/// Factory helpers returning a CDK wallet database behind the FFI trait +#[uniffi::export] +pub fn create_wallet_db(backend: WalletDbBackend) -> Result, FfiError> { + match backend { + WalletDbBackend::Sqlite { path } => { + let sqlite = WalletSqliteDatabase::new(path)?; + Ok(sqlite as Arc) + } + #[cfg(feature = "postgres")] + WalletDbBackend::Postgres { url } => { + let pg = WalletPostgresDatabase::new(url)?; + Ok(pg as Arc) + } + } +} + +/// Helper function to create a CDK database from the FFI trait +pub fn create_cdk_database_from_ffi( + ffi_db: Arc, +) -> Arc + Send + Sync> { + Arc::new(WalletDatabaseBridge::new(ffi_db)) +} diff --git a/crates/cdk-ffi/src/error.rs b/crates/cdk-ffi/src/error.rs new file mode 100644 index 000000000..b1dcbce4a --- /dev/null +++ b/crates/cdk-ffi/src/error.rs @@ -0,0 +1,124 @@ +//! FFI Error types + +use cdk::Error as CdkError; + +/// FFI Error type that wraps CDK errors for cross-language use +#[derive(Debug, thiserror::Error, uniffi::Error)] +#[uniffi(flat_error)] +pub enum FfiError { + /// Generic error with message + #[error("CDK Error: {msg}")] + Generic { msg: String }, + + /// Amount overflow + #[error("Amount overflow")] + AmountOverflow, + + /// Division by zero + #[error("Division by zero")] + DivisionByZero, + + /// Amount error + #[error("Amount error: {msg}")] + Amount { msg: String }, + + /// Payment failed + #[error("Payment failed")] + PaymentFailed, + + /// Payment pending + #[error("Payment pending")] + PaymentPending, + + /// Insufficient funds + #[error("Insufficient funds")] + InsufficientFunds, + + /// Database error + #[error("Database error: {msg}")] + Database { msg: String }, + + /// Network error + #[error("Network error: {msg}")] + Network { msg: String }, + + /// Invalid token + #[error("Invalid token: {msg}")] + InvalidToken { msg: String }, + + /// Wallet error + #[error("Wallet error: {msg}")] + Wallet { msg: String }, + + /// Keyset unknown + #[error("Keyset unknown")] + KeysetUnknown, + + /// Unit not supported + #[error("Unit not supported")] + UnitNotSupported, + + /// Runtime task join error + #[error("Runtime task join error: {msg}")] + RuntimeTaskJoin { msg: String }, + + /// Invalid mnemonic phrase + #[error("Invalid mnemonic: {msg}")] + InvalidMnemonic { msg: String }, + + /// URL parsing error + #[error("Invalid URL: {msg}")] + InvalidUrl { msg: String }, + + /// Hex format error + #[error("Invalid hex format: {msg}")] + InvalidHex { msg: String }, + + /// Cryptographic key parsing error + #[error("Invalid cryptographic key: {msg}")] + InvalidCryptographicKey { msg: String }, + + /// Serialization/deserialization error + #[error("Serialization error: {msg}")] + Serialization { msg: String }, +} + +impl From for FfiError { + fn from(err: CdkError) -> Self { + match err { + CdkError::AmountOverflow => FfiError::AmountOverflow, + CdkError::PaymentFailed => FfiError::PaymentFailed, + CdkError::PaymentPending => FfiError::PaymentPending, + CdkError::InsufficientFunds => FfiError::InsufficientFunds, + CdkError::UnsupportedUnit => FfiError::UnitNotSupported, + CdkError::KeysetUnknown(_) => FfiError::KeysetUnknown, + _ => FfiError::Generic { + msg: err.to_string(), + }, + } + } +} + +impl From for FfiError { + fn from(err: cdk::amount::Error) -> Self { + FfiError::Amount { + msg: err.to_string(), + } + } +} + +impl From for FfiError { + fn from(err: cdk::nuts::nut00::Error) -> Self { + FfiError::Generic { + msg: err.to_string(), + } + } +} + +impl From for FfiError { + fn from(err: serde_json::Error) -> Self { + FfiError::Serialization { + msg: err.to_string(), + } + } +} diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs new file mode 100644 index 000000000..c60350b2f --- /dev/null +++ b/crates/cdk-ffi/src/lib.rs @@ -0,0 +1,293 @@ +//! CDK FFI Bindings +//! +//! UniFFI bindings for the CDK Wallet and related types. + +#![warn(clippy::unused_async)] + +pub mod database; +pub mod error; +pub mod multi_mint_wallet; +#[cfg(feature = "postgres")] +pub mod postgres; +pub mod sqlite; +pub mod token; +pub mod types; +pub mod wallet; + +pub use database::*; +pub use error::*; +pub use multi_mint_wallet::*; +pub use types::*; +pub use wallet::*; + +uniffi::setup_scaffolding!(); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_amount_conversion() { + let amount = Amount::new(1000); + assert_eq!(amount.value, 1000); + assert!(!amount.is_zero()); + + let zero = Amount::zero(); + assert!(zero.is_zero()); + } + + #[test] + fn test_currency_unit_conversion() { + use cdk::nuts::CurrencyUnit as CdkCurrencyUnit; + + let unit = CurrencyUnit::Sat; + let cdk_unit: CdkCurrencyUnit = unit.into(); + let back: CurrencyUnit = cdk_unit.into(); + assert_eq!(back, CurrencyUnit::Sat); + } + + #[test] + fn test_mint_url_creation() { + let url = MintUrl::new("https://mint.example.com".to_string()); + assert!(url.is_ok()); + + let invalid_url = MintUrl::new("not-a-url".to_string()); + assert!(invalid_url.is_err()); + } + + #[test] + fn test_send_options_default() { + let options = SendOptions::default(); + assert!(options.memo.is_none()); + assert!(options.conditions.is_none()); + assert!(matches!(options.amount_split_target, SplitTarget::None)); + assert!(matches!(options.send_kind, SendKind::OnlineExact)); + assert!(!options.include_fee); + assert!(options.max_proofs.is_none()); + assert!(options.metadata.is_empty()); + } + + #[test] + fn test_receive_options_default() { + let options = ReceiveOptions::default(); + assert!(matches!(options.amount_split_target, SplitTarget::None)); + assert!(options.p2pk_signing_keys.is_empty()); + assert!(options.preimages.is_empty()); + assert!(options.metadata.is_empty()); + } + + #[test] + fn test_send_memo() { + let memo_text = "Test memo".to_string(); + let memo = SendMemo { + memo: memo_text.clone(), + include_memo: true, + }; + + assert_eq!(memo.memo, memo_text); + assert!(memo.include_memo); + } + + #[test] + fn test_split_target_variants() { + let split_none = SplitTarget::None; + assert!(matches!(split_none, SplitTarget::None)); + + let amount = Amount::new(1000); + let split_value = SplitTarget::Value { amount }; + assert!(matches!(split_value, SplitTarget::Value { .. })); + + let amounts = vec![Amount::new(100), Amount::new(200)]; + let split_values = SplitTarget::Values { amounts }; + assert!(matches!(split_values, SplitTarget::Values { .. })); + } + + #[test] + fn test_send_kind_variants() { + let online_exact = SendKind::OnlineExact; + assert!(matches!(online_exact, SendKind::OnlineExact)); + + let tolerance = Amount::new(50); + let online_tolerance = SendKind::OnlineTolerance { tolerance }; + assert!(matches!(online_tolerance, SendKind::OnlineTolerance { .. })); + + let offline_exact = SendKind::OfflineExact; + assert!(matches!(offline_exact, SendKind::OfflineExact)); + + let offline_tolerance = SendKind::OfflineTolerance { tolerance }; + assert!(matches!( + offline_tolerance, + SendKind::OfflineTolerance { .. } + )); + } + + #[test] + fn test_secret_key_from_hex() { + // Test valid hex string (64 characters) + let valid_hex = "a".repeat(64); + let secret_key = SecretKey::from_hex(valid_hex.clone()); + assert!(secret_key.is_ok()); + assert_eq!(secret_key.unwrap().hex, valid_hex); + + // Test invalid length + let invalid_length = "a".repeat(32); // 32 chars instead of 64 + let secret_key = SecretKey::from_hex(invalid_length); + assert!(secret_key.is_err()); + + // Test invalid characters + let invalid_chars = "g".repeat(64); // 'g' is not a valid hex character + let secret_key = SecretKey::from_hex(invalid_chars); + assert!(secret_key.is_err()); + } + + #[test] + fn test_secret_key_random() { + let key1 = SecretKey::random(); + let key2 = SecretKey::random(); + + // Keys should be different + assert_ne!(key1.hex, key2.hex); + + // Keys should be valid hex (64 characters) + assert_eq!(key1.hex.len(), 64); + assert_eq!(key2.hex.len(), 64); + assert!(key1.hex.chars().all(|c| c.is_ascii_hexdigit())); + assert!(key2.hex.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_send_options_with_all_fields() { + use std::collections::HashMap; + + let memo = SendMemo { + memo: "Test memo".to_string(), + include_memo: true, + }; + + let mut metadata = HashMap::new(); + metadata.insert("key1".to_string(), "value1".to_string()); + + let conditions = SpendingConditions::P2PK { + pubkey: "02a1633cafcc01ebfb6d78e39f687a1f0995c62fc95f51ead10a02ee0be551b5dc" + .to_string(), + conditions: None, + }; + + let options = SendOptions { + memo: Some(memo), + conditions: Some(conditions), + amount_split_target: SplitTarget::Value { + amount: Amount::new(1000), + }, + send_kind: SendKind::OnlineTolerance { + tolerance: Amount::new(50), + }, + include_fee: true, + max_proofs: Some(10), + metadata, + }; + + assert!(options.memo.is_some()); + assert!(options.conditions.is_some()); + assert!(matches!( + options.amount_split_target, + SplitTarget::Value { .. } + )); + assert!(matches!( + options.send_kind, + SendKind::OnlineTolerance { .. } + )); + assert!(options.include_fee); + assert_eq!(options.max_proofs, Some(10)); + assert!(!options.metadata.is_empty()); + } + + #[test] + fn test_receive_options_with_all_fields() { + use std::collections::HashMap; + + let secret_key = SecretKey::random(); + let mut metadata = HashMap::new(); + metadata.insert("key1".to_string(), "value1".to_string()); + + let options = ReceiveOptions { + amount_split_target: SplitTarget::Values { + amounts: vec![Amount::new(100), Amount::new(200)], + }, + p2pk_signing_keys: vec![secret_key], + preimages: vec!["preimage1".to_string(), "preimage2".to_string()], + metadata, + }; + + assert!(matches!( + options.amount_split_target, + SplitTarget::Values { .. } + )); + assert_eq!(options.p2pk_signing_keys.len(), 1); + assert_eq!(options.preimages.len(), 2); + assert!(!options.metadata.is_empty()); + } + + #[test] + fn test_wallet_config() { + let config = WalletConfig { + target_proof_count: None, + }; + assert!(config.target_proof_count.is_none()); + + let config_with_values = WalletConfig { + target_proof_count: Some(5), + }; + assert_eq!(config_with_values.target_proof_count, Some(5)); + } + + #[test] + fn test_mnemonic_generation() { + // Test mnemonic generation + let mnemonic = generate_mnemonic().unwrap(); + assert!(!mnemonic.is_empty()); + assert_eq!(mnemonic.split_whitespace().count(), 12); + + // Verify it's a valid mnemonic by trying to parse it + use bip39::Mnemonic; + let parsed = Mnemonic::parse(&mnemonic); + assert!(parsed.is_ok()); + } + + #[test] + fn test_mnemonic_validation() { + // Test with valid mnemonic + let mnemonic = generate_mnemonic().unwrap(); + use bip39::Mnemonic; + let parsed = Mnemonic::parse(&mnemonic); + assert!(parsed.is_ok()); + + // Test with invalid mnemonic + let invalid_mnemonic = "invalid mnemonic phrase that should not work"; + let parsed_invalid = Mnemonic::parse(invalid_mnemonic); + assert!(parsed_invalid.is_err()); + + // Test mnemonic word count variations + let mnemonic_12 = generate_mnemonic().unwrap(); + assert_eq!(mnemonic_12.split_whitespace().count(), 12); + } + + #[test] + fn test_mnemonic_to_entropy() { + // Test with generated mnemonic + let mnemonic = generate_mnemonic().unwrap(); + let entropy = mnemonic_to_entropy(mnemonic.clone()).unwrap(); + + // For a 12-word mnemonic, entropy should be 16 bytes (128 bits) + assert_eq!(entropy.len(), 16); + + // Test that we can recreate the mnemonic from entropy + use bip39::Mnemonic; + let recreated_mnemonic = Mnemonic::from_entropy(&entropy).unwrap(); + assert_eq!(recreated_mnemonic.to_string(), mnemonic); + + // Test with invalid mnemonic + let invalid_result = mnemonic_to_entropy("invalid mnemonic".to_string()); + assert!(invalid_result.is_err()); + } +} diff --git a/crates/cdk-ffi/src/multi_mint_wallet.rs b/crates/cdk-ffi/src/multi_mint_wallet.rs new file mode 100644 index 000000000..131db9c4d --- /dev/null +++ b/crates/cdk-ffi/src/multi_mint_wallet.rs @@ -0,0 +1,826 @@ +//! FFI MultiMintWallet bindings + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; + +use bip39::Mnemonic; +use cdk::wallet::multi_mint_wallet::{ + MultiMintReceiveOptions as CdkMultiMintReceiveOptions, + MultiMintSendOptions as CdkMultiMintSendOptions, MultiMintWallet as CdkMultiMintWallet, + TokenData as CdkTokenData, TransferMode as CdkTransferMode, + TransferResult as CdkTransferResult, +}; + +use crate::error::FfiError; +use crate::token::Token; +use crate::types::*; + +/// FFI-compatible MultiMintWallet +#[derive(uniffi::Object)] +pub struct MultiMintWallet { + inner: Arc, +} + +#[uniffi::export(async_runtime = "tokio")] +impl MultiMintWallet { + /// Create a new MultiMintWallet from mnemonic using WalletDatabase trait + #[uniffi::constructor] + pub fn new( + unit: CurrencyUnit, + mnemonic: String, + db: Arc, + ) -> Result { + // Parse mnemonic and generate seed without passphrase + let m = Mnemonic::parse(&mnemonic) + .map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + let seed = m.to_seed_normalized(""); + + // Convert the FFI database trait to a CDK database implementation + let localstore = crate::database::create_cdk_database_from_ffi(db); + + let wallet = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on(async move { + CdkMultiMintWallet::new(localstore, seed, unit.into()).await + }) + }), + Err(_) => { + // No current runtime, create a new one + tokio::runtime::Runtime::new() + .map_err(|e| FfiError::Database { + msg: format!("Failed to create runtime: {}", e), + })? + .block_on(async move { + CdkMultiMintWallet::new(localstore, seed, unit.into()).await + }) + } + }?; + + Ok(Self { + inner: Arc::new(wallet), + }) + } + + /// Create a new MultiMintWallet with proxy configuration + #[uniffi::constructor] + pub fn new_with_proxy( + unit: CurrencyUnit, + mnemonic: String, + db: Arc, + proxy_url: String, + ) -> Result { + // Parse mnemonic and generate seed without passphrase + let m = Mnemonic::parse(&mnemonic) + .map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + let seed = m.to_seed_normalized(""); + + // Convert the FFI database trait to a CDK database implementation + let localstore = crate::database::create_cdk_database_from_ffi(db); + + // Parse proxy URL + let proxy_url = + url::Url::parse(&proxy_url).map_err(|e| FfiError::InvalidUrl { msg: e.to_string() })?; + + let wallet = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on(async move { + CdkMultiMintWallet::new_with_proxy(localstore, seed, unit.into(), proxy_url) + .await + }) + }), + Err(_) => { + // No current runtime, create a new one + tokio::runtime::Runtime::new() + .map_err(|e| FfiError::Database { + msg: format!("Failed to create runtime: {}", e), + })? + .block_on(async move { + CdkMultiMintWallet::new_with_proxy(localstore, seed, unit.into(), proxy_url) + .await + }) + } + }?; + + Ok(Self { + inner: Arc::new(wallet), + }) + } + + /// Get the currency unit for this wallet + pub fn unit(&self) -> CurrencyUnit { + self.inner.unit().clone().into() + } + + /// Set metadata cache TTL (time-to-live) in seconds for a specific mint + /// + /// Controls how long cached mint metadata (keysets, keys, mint info) is considered fresh + /// before requiring a refresh from the mint server for a specific mint. + /// + /// # Arguments + /// + /// * `mint_url` - The mint URL to set the TTL for + /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires. + pub async fn set_metadata_cache_ttl_for_mint( + &self, + mint_url: MintUrl, + ttl_secs: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let wallets = self.inner.get_wallets().await; + + if let Some(wallet) = wallets.iter().find(|w| w.mint_url == cdk_mint_url) { + let ttl = ttl_secs.map(std::time::Duration::from_secs); + wallet.set_metadata_cache_ttl(ttl); + Ok(()) + } else { + Err(FfiError::Generic { + msg: format!("Mint not found: {}", cdk_mint_url), + }) + } + } + + /// Set metadata cache TTL (time-to-live) in seconds for all mints + /// + /// Controls how long cached mint metadata is considered fresh for all mints + /// in this MultiMintWallet. + /// + /// # Arguments + /// + /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires for any mint. + pub async fn set_metadata_cache_ttl_for_all_mints(&self, ttl_secs: Option) { + let wallets = self.inner.get_wallets().await; + let ttl = ttl_secs.map(std::time::Duration::from_secs); + + for wallet in wallets.iter() { + wallet.set_metadata_cache_ttl(ttl); + } + } + + /// Add a mint to this MultiMintWallet + pub async fn add_mint( + &self, + mint_url: MintUrl, + target_proof_count: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + + 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(()) + } + + /// Remove mint from MultiMintWallet + pub async fn remove_mint(&self, mint_url: MintUrl) { + let url_str = mint_url.url.clone(); + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into().unwrap_or_else(|_| { + // If conversion fails, we can't remove the mint, but we shouldn't panic + // This is a best-effort operation + cdk::mint_url::MintUrl::from_str(&url_str).unwrap_or_else(|_| { + // Last resort: create a dummy URL that won't match anything + cdk::mint_url::MintUrl::from_str("https://invalid.mint").unwrap() + }) + }); + self.inner.remove_mint(&cdk_mint_url).await; + } + + /// Check if mint is in wallet + pub async fn has_mint(&self, mint_url: MintUrl) -> bool { + if let Ok(cdk_mint_url) = mint_url.try_into() { + self.inner.has_mint(&cdk_mint_url).await + } else { + false + } + } + + pub async fn get_mint_keysets(&self, mint_url: MintUrl) -> Result, FfiError> { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let keysets = self.inner.get_mint_keysets(&cdk_mint_url).await?; + + let keysets = keysets.into_iter().map(|k| k.into()).collect(); + + Ok(keysets) + } + + /// Get token data (mint URL and proofs) from a token + /// + /// This method extracts the mint URL and proofs from a token. It will automatically + /// fetch the keysets from the mint if needed to properly decode the proofs. + /// + /// The mint must already be added to the wallet. If the mint is not in the wallet, + /// use `add_mint` first. + pub async fn get_token_data(&self, token: Arc) -> Result { + let token_data = self.inner.get_token_data(&token.inner).await?; + Ok(token_data.into()) + } + + /// Get wallet balances for all mints + pub async fn get_balances(&self) -> Result { + let balances = self.inner.get_balances().await?; + let mut balance_map = HashMap::new(); + for (mint_url, (amount, unit)) in balances { + balance_map.insert(mint_url.to_string(), amount.into()); + } + Ok(balance_map) + } + + /// Get total balance across all mints + pub async fn total_balance(&self) -> Result { + let total = self.inner.total_balance().await?; + Ok(total.into()) + } + + /// List proofs for all mints + pub async fn list_proofs(&self) -> Result { + 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 = mint_proofs.into_iter().map(|p| p.into()).collect(); + proofs_by_mint.insert(mint_url.to_string(), ffi_proofs); + } + Ok(proofs_by_mint) + } + + /// Receive token + pub async fn receive( + &self, + token: Arc, + options: MultiMintReceiveOptions, + ) -> Result { + let amount = self + .inner + .receive(&token.to_string(), options.into()) + .await?; + Ok(amount.into()) + } + + /// Restore wallets for a specific mint + pub async fn restore(&self, mint_url: MintUrl) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let amount = self.inner.restore(&cdk_mint_url).await?; + Ok(amount.into()) + } + + /// Prepare a send operation from a specific mint + pub async fn prepare_send( + &self, + mint_url: MintUrl, + amount: Amount, + options: MultiMintSendOptions, + ) -> Result, FfiError> { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let prepared = self + .inner + .prepare_send(cdk_mint_url, amount.into(), options.into()) + .await?; + Ok(Arc::new(prepared.into())) + } + + /// Get a mint quote from a specific mint + pub async fn mint_quote( + &self, + mint_url: MintUrl, + amount: Amount, + description: Option, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let quote = self + .inner + .mint_quote(&cdk_mint_url, amount.into(), description) + .await?; + Ok(quote.into()) + } + + /// Check a specific mint quote status + pub async fn check_mint_quote( + &self, + mint_url: MintUrl, + quote_id: String, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let quote = self + .inner + .check_mint_quote(&cdk_mint_url, "e_id) + .await?; + Ok(quote.into()) + } + + /// Mint tokens at a specific mint + pub async fn mint( + &self, + mint_url: MintUrl, + quote_id: String, + spending_conditions: Option, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?; + + let proofs = self + .inner + .mint(&cdk_mint_url, "e_id, conditions) + .await?; + Ok(proofs.into_iter().map(|p| p.into()).collect()) + } + + /// Wait for a mint quote to be paid and automatically mint the proofs + #[cfg(not(target_arch = "wasm32"))] + pub async fn wait_for_mint_quote( + &self, + mint_url: MintUrl, + quote_id: String, + split_target: SplitTarget, + spending_conditions: Option, + timeout_secs: u64, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?; + + let proofs = self + .inner + .wait_for_mint_quote( + &cdk_mint_url, + "e_id, + split_target.into(), + conditions, + timeout_secs, + ) + .await?; + Ok(proofs.into_iter().map(|p| p.into()).collect()) + } + + /// Get a melt quote from a specific mint + pub async fn melt_quote( + &self, + mint_url: MintUrl, + request: String, + options: Option, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let cdk_options = options.map(Into::into); + let quote = self + .inner + .melt_quote(&cdk_mint_url, request, cdk_options) + .await?; + Ok(quote.into()) + } + + /// Get a melt quote for a BIP353 human-readable address + /// + /// This method resolves a BIP353 address (e.g., "alice@example.com") to a Lightning offer + /// and then creates a melt quote for that offer at the specified mint. + /// + /// # Arguments + /// + /// * `mint_url` - The mint to use for creating the melt quote + /// * `bip353_address` - Human-readable address in the format "user@domain.com" + /// * `amount_msat` - Amount to pay in millisatoshis + #[cfg(not(target_arch = "wasm32"))] + pub async fn melt_bip353_quote( + &self, + mint_url: MintUrl, + bip353_address: String, + amount_msat: u64, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let cdk_amount = cdk::Amount::from(amount_msat); + let quote = self + .inner + .melt_bip353_quote(&cdk_mint_url, &bip353_address, cdk_amount) + .await?; + Ok(quote.into()) + } + + /// Get a melt quote for a Lightning address + /// + /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice + /// and then creates a melt quote for that invoice at the specified mint. + /// + /// # Arguments + /// + /// * `mint_url` - The mint to use for creating the melt quote + /// * `lightning_address` - Lightning address in the format "user@domain.com" + /// * `amount_msat` - Amount to pay in millisatoshis + pub async fn melt_lightning_address_quote( + &self, + mint_url: MintUrl, + lightning_address: String, + amount_msat: u64, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let cdk_amount = cdk::Amount::from(amount_msat); + let quote = self + .inner + .melt_lightning_address_quote(&cdk_mint_url, &lightning_address, cdk_amount) + .await?; + Ok(quote.into()) + } + + /// Get a melt quote for a human-readable address + /// + /// This method accepts a human-readable address that could be either a BIP353 address + /// or a Lightning address. It intelligently determines which to try based on mint support: + /// + /// 1. If the mint supports Bolt12, it tries BIP353 first + /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails + /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address + /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly + /// + /// # Arguments + /// + /// * `mint_url` - The mint to use for creating the melt quote + /// * `address` - Human-readable address (BIP353 or Lightning address) + /// * `amount_msat` - Amount to pay in millisatoshis + #[cfg(not(target_arch = "wasm32"))] + pub async fn melt_human_readable_quote( + &self, + mint_url: MintUrl, + address: String, + amount_msat: u64, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let cdk_amount = cdk::Amount::from(amount_msat); + let quote = self + .inner + .melt_human_readable_quote(&cdk_mint_url, &address, cdk_amount) + .await?; + Ok(quote.into()) + } + + /// Melt tokens + pub async fn melt_with_mint( + &self, + mint_url: MintUrl, + quote_id: String, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let melted = self.inner.melt_with_mint(&cdk_mint_url, "e_id).await?; + Ok(melted.into()) + } + + /// Melt specific proofs from a specific mint + /// + /// This method allows melting proofs that may not be in the wallet's database, + /// similar to how `receive_proofs` handles external proofs. The proofs will be + /// added to the database and used for the melt operation. + /// + /// # Arguments + /// + /// * `mint_url` - The mint to use for the melt operation + /// * `quote_id` - The melt quote ID (obtained from `melt_quote`) + /// * `proofs` - The proofs to melt (can be external proofs not in the wallet's database) + /// + /// # Returns + /// + /// A `Melted` result containing the payment details and any change proofs + pub async fn melt_proofs( + &self, + mint_url: MintUrl, + quote_id: String, + proofs: Proofs, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let cdk_proofs: Result, _> = + proofs.into_iter().map(|p| p.try_into()).collect(); + let cdk_proofs = cdk_proofs?; + + let melted = self + .inner + .melt_proofs(&cdk_mint_url, "e_id, cdk_proofs) + .await?; + Ok(melted.into()) + } + + /// Check melt quote status + pub async fn check_melt_quote( + &self, + mint_url: MintUrl, + quote_id: String, + ) -> Result { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into()?; + let melted = self + .inner + .check_melt_quote(&cdk_mint_url, "e_id) + .await?; + Ok(melted.into()) + } + + /// Melt tokens (pay a bolt11 invoice) + pub async fn melt( + &self, + bolt11: String, + options: Option, + max_fee: Option, + ) -> Result { + let cdk_options = options.map(Into::into); + let cdk_max_fee = max_fee.map(Into::into); + let melted = self.inner.melt(&bolt11, cdk_options, cdk_max_fee).await?; + Ok(melted.into()) + } + + /// Transfer funds between mints + pub async fn transfer( + &self, + source_mint: MintUrl, + target_mint: MintUrl, + transfer_mode: TransferMode, + ) -> Result { + let source_cdk: cdk::mint_url::MintUrl = source_mint.try_into()?; + let target_cdk: cdk::mint_url::MintUrl = target_mint.try_into()?; + let result = self + .inner + .transfer(&source_cdk, &target_cdk, transfer_mode.into()) + .await?; + Ok(result.into()) + } + + /// Swap proofs with automatic wallet selection + pub async fn swap( + &self, + amount: Option, + spending_conditions: Option, + ) -> Result, FfiError> { + let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?; + + let result = self.inner.swap(amount.map(Into::into), conditions).await?; + + Ok(result.map(|proofs| proofs.into_iter().map(|p| p.into()).collect())) + } + + /// List transactions from all mints + pub async fn list_transactions( + &self, + direction: Option, + ) -> Result, FfiError> { + let cdk_direction = direction.map(Into::into); + let transactions = self.inner.list_transactions(cdk_direction).await?; + Ok(transactions.into_iter().map(Into::into).collect()) + } + + /// Get proofs for a transaction by transaction ID + /// + /// This retrieves all proofs associated with a transaction. If `mint_url` is provided, + /// it will only check that specific mint's wallet. Otherwise, it searches across all + /// wallets to find which mint the transaction belongs to. + /// + /// # Arguments + /// + /// * `id` - The transaction ID + /// * `mint_url` - Optional mint URL to check directly, avoiding iteration over all wallets + pub async fn get_proofs_for_transaction( + &self, + id: TransactionId, + mint_url: Option, + ) -> Result, FfiError> { + let cdk_id = id.try_into()?; + let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?; + let proofs = self + .inner + .get_proofs_for_transaction(cdk_id, cdk_mint_url) + .await?; + Ok(proofs.into_iter().map(Into::into).collect()) + } + + /// Check all mint quotes and mint if paid + pub async fn check_all_mint_quotes( + &self, + mint_url: Option, + ) -> Result { + let cdk_mint_url = mint_url.map(|url| url.try_into()).transpose()?; + let amount = self.inner.check_all_mint_quotes(cdk_mint_url).await?; + Ok(amount.into()) + } + + /// Consolidate proofs across all mints + pub async fn consolidate(&self) -> Result { + let amount = self.inner.consolidate().await?; + Ok(amount.into()) + } + + /// Get list of mint URLs + pub async fn get_mint_urls(&self) -> Vec { + let wallets = self.inner.get_wallets().await; + wallets.iter().map(|w| w.mint_url.to_string()).collect() + } + + /// Get all wallets from MultiMintWallet + pub async fn get_wallets(&self) -> Vec> { + let wallets = self.inner.get_wallets().await; + wallets + .into_iter() + .map(|w| Arc::new(crate::wallet::Wallet::from_inner(Arc::new(w)))) + .collect() + } + + /// Get a specific wallet from MultiMintWallet by mint URL + pub async fn get_wallet(&self, mint_url: MintUrl) -> Option> { + let cdk_mint_url: cdk::mint_url::MintUrl = mint_url.try_into().ok()?; + let wallet = self.inner.get_wallet(&cdk_mint_url).await?; + Some(Arc::new(crate::wallet::Wallet::from_inner(Arc::new( + wallet, + )))) + } + + /// Verify token DLEQ proofs + pub async fn verify_token_dleq(&self, token: Arc) -> Result<(), FfiError> { + let cdk_token = token.inner.clone(); + 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, 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 { + 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, 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 +#[derive(Debug, Clone, uniffi::Enum)] +pub enum TransferMode { + /// Transfer exact amount to target (target receives specified amount) + ExactReceive { amount: Amount }, + /// Transfer all available balance (source will be emptied) + FullBalance, +} + +impl From for CdkTransferMode { + fn from(mode: TransferMode) -> Self { + match mode { + TransferMode::ExactReceive { amount } => CdkTransferMode::ExactReceive(amount.into()), + TransferMode::FullBalance => CdkTransferMode::FullBalance, + } + } +} + +/// Result of a transfer operation with detailed breakdown +#[derive(Debug, Clone, uniffi::Record)] +pub struct TransferResult { + /// Amount deducted from source mint + pub amount_sent: Amount, + /// Amount received at target mint + pub amount_received: Amount, + /// Total fees paid for the transfer + pub fees_paid: Amount, + /// Remaining balance in source mint after transfer + pub source_balance_after: Amount, + /// New balance in target mint after transfer + pub target_balance_after: Amount, +} + +impl From for TransferResult { + fn from(result: CdkTransferResult) -> Self { + Self { + amount_sent: result.amount_sent.into(), + amount_received: result.amount_received.into(), + fees_paid: result.fees_paid.into(), + source_balance_after: result.source_balance_after.into(), + target_balance_after: result.target_balance_after.into(), + } + } +} + +/// Data extracted from a token including mint URL, proofs, and memo +#[derive(Debug, Clone, uniffi::Record)] +pub struct TokenData { + /// The mint URL from the token + pub mint_url: MintUrl, + /// The proofs contained in the token + pub proofs: Proofs, + /// The memo from the token, if present + pub memo: Option, +} + +impl From for TokenData { + fn from(data: CdkTokenData) -> Self { + Self { + mint_url: data.mint_url.into(), + proofs: data.proofs.into_iter().map(|p| p.into()).collect(), + memo: data.memo, + } + } +} + +/// Options for receiving tokens in multi-mint context +#[derive(Debug, Clone, Default, uniffi::Record)] +pub struct MultiMintReceiveOptions { + /// Whether to allow receiving from untrusted (not yet added) mints + pub allow_untrusted: bool, + /// Mint URL to transfer tokens to from untrusted mints (None means keep in original mint) + pub transfer_to_mint: Option, + /// Base receive options to apply to the wallet receive + pub receive_options: ReceiveOptions, +} + +impl From for CdkMultiMintReceiveOptions { + fn from(options: MultiMintReceiveOptions) -> Self { + let mut opts = CdkMultiMintReceiveOptions::new(); + opts.allow_untrusted = options.allow_untrusted; + opts.transfer_to_mint = options.transfer_to_mint.and_then(|url| url.try_into().ok()); + opts.receive_options = options.receive_options.into(); + opts + } +} + +/// Options for sending tokens in multi-mint context +#[derive(Debug, Clone, Default, uniffi::Record)] +pub struct MultiMintSendOptions { + /// Whether to allow transferring funds from other mints if needed + pub allow_transfer: bool, + /// Maximum amount to transfer from other mints (optional limit) + pub max_transfer_amount: Option, + /// Specific mint URLs allowed for transfers (empty means all mints allowed) + pub allowed_mints: Vec, + /// Specific mint URLs to exclude from transfers + pub excluded_mints: Vec, + /// Base send options to apply to the wallet send + pub send_options: SendOptions, +} + +impl From for CdkMultiMintSendOptions { + fn from(options: MultiMintSendOptions) -> Self { + let mut opts = CdkMultiMintSendOptions::new(); + opts.allow_transfer = options.allow_transfer; + opts.max_transfer_amount = options.max_transfer_amount.map(Into::into); + opts.allowed_mints = options + .allowed_mints + .into_iter() + .filter_map(|url| url.try_into().ok()) + .collect(); + opts.excluded_mints = options + .excluded_mints + .into_iter() + .filter_map(|url| url.try_into().ok()) + .collect(); + opts.send_options = options.send_options.into(); + opts + } +} + +/// Type alias for balances by mint URL +pub type BalanceMap = HashMap; + +/// Type alias for proofs by mint URL +pub type ProofsByMint = HashMap>; diff --git a/crates/cdk-ffi/src/postgres.rs b/crates/cdk-ffi/src/postgres.rs new file mode 100644 index 000000000..80ccf8fd4 --- /dev/null +++ b/crates/cdk-ffi/src/postgres.rs @@ -0,0 +1,432 @@ +use std::collections::HashMap; +use std::sync::Arc; + +// Bring the CDK wallet database trait into scope so trait methods resolve on the inner DB +use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; +#[cfg(feature = "postgres")] +use cdk_postgres::WalletPgDatabase as CdkWalletPgDatabase; + +use crate::{ + CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl, + ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection, + TransactionId, WalletDatabase, +}; + +#[derive(uniffi::Object)] +pub struct WalletPostgresDatabase { + inner: Arc, +} + +// Keep a long-lived Tokio runtime for Postgres-created resources so that +// background tasks (e.g., tokio-postgres connection drivers spawned during +// construction) are not tied to a short-lived, ad-hoc runtime. +#[cfg(feature = "postgres")] +static PG_RUNTIME: once_cell::sync::OnceCell = + once_cell::sync::OnceCell::new(); + +#[cfg(feature = "postgres")] +fn pg_runtime() -> &'static tokio::runtime::Runtime { + PG_RUNTIME.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("cdk-ffi-pg") + .build() + .expect("failed to build pg runtime") + }) +} + +// Implement the local WalletDatabase trait (simple trait path required by uniffi) +#[uniffi::export(async_runtime = "tokio")] +#[async_trait::async_trait] +impl WalletDatabase for WalletPostgresDatabase { + // Forward all trait methods to inner CDK database via the bridge adapter + async fn add_mint( + &self, + mint_url: MintUrl, + mint_info: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_mint_info = mint_info.map(Into::into); + println!("adding new mint"); + self.inner + .add_mint(cdk_mint_url, cdk_mint_info) + .await + .map_err(|e| { + println!("ffi error {:?}", e); + FfiError::Database { msg: e.to_string() } + }) + } + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + self.inner + .remove_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + async fn get_mints(&self) -> Result>, FfiError> { + let result = self + .inner + .get_mints() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result + .into_iter() + .map(|(k, v)| (k.into(), v.map(Into::into))) + .collect()) + } + async fn update_mint_url( + &self, + old_mint_url: MintUrl, + new_mint_url: MintUrl, + ) -> Result<(), FfiError> { + let cdk_old_mint_url = old_mint_url.try_into()?; + let cdk_new_mint_url = new_mint_url.try_into()?; + self.inner + .update_mint_url(cdk_old_mint_url, cdk_new_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn add_mint_keysets( + &self, + mint_url: MintUrl, + keysets: Vec, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_keysets: Vec = keysets.into_iter().map(Into::into).collect(); + self.inner + .add_mint_keysets(cdk_mint_url, cdk_keysets) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + async fn get_mint_keysets( + &self, + mint_url: MintUrl, + ) -> Result>, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint_keysets(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) + } + + async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError> { + let cdk_id = keyset_id.into(); + let result = self + .inner + .get_keyset_by_id(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + // Mint Quote Management + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_mint_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_mint_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_mint_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Melt Quote Management + async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_melt_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_melt_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_melt_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keys Management + async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> { + // Convert FFI KeySet to cdk::nuts::KeySet + let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?; + self.inner + .add_keys(cdk_keyset) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_keys(&self, id: Id) -> Result, FfiError> { + let cdk_id = id.into(); + let result = self + .inner + .get_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn remove_keys(&self, id: Id) -> Result<(), FfiError> { + let cdk_id = id.into(); + self.inner + .remove_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Proof Management + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), FfiError> { + // Convert FFI types to CDK types + let cdk_added: Result, FfiError> = added + .into_iter() + .map(|info| { + Ok::(cdk::types::ProofInfo { + proof: info.proof.try_into()?, + y: info.y.try_into()?, + mint_url: info.mint_url.try_into()?, + state: info.state.into(), + spending_condition: info + .spending_condition + .map(|sc| sc.try_into()) + .transpose()?, + unit: info.unit.into(), + }) + }) + .collect(); + let cdk_added = cdk_added?; + + let cdk_removed_ys: Result, FfiError> = + removed_ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_removed_ys = cdk_removed_ys?; + + self.inner + .update_proofs(cdk_added, cdk_removed_ys) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_unit = unit.map(Into::into); + let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); + let cdk_spending_conditions: Option> = + spending_conditions + .map(|sc| { + sc.into_iter() + .map(|c| c.try_into()) + .collect::, FfiError>>() + }) + .transpose()?; + + let result = self + .inner + .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn get_proofs_by_ys(&self, ys: Vec) -> Result, FfiError> { + let cdk_ys: Vec = ys + .into_iter() + .map(|y| y.try_into()) + .collect::, FfiError>>()?; + + let result = self + .inner + .get_proofs_by_ys(cdk_ys) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn get_balance( + &self, + mint_url: Option, + unit: Option, + state: Option>, + ) -> Result { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_unit = unit.map(Into::into); + let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); + + self.inner + .get_balance(cdk_mint_url, cdk_unit, cdk_state) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn update_proofs_state( + &self, + ys: Vec, + state: ProofState, + ) -> Result<(), FfiError> { + let cdk_ys: Result, FfiError> = + ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_ys = cdk_ys?; + let cdk_state = state.into(); + + self.inner + .update_proofs_state(cdk_ys, cdk_state) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Counter Management + async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result { + let cdk_id = keyset_id.into(); + self.inner + .increment_keyset_counter(&cdk_id, count) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Transaction Management + async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> { + // Convert FFI Transaction to CDK Transaction using TryFrom + let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?; + + self.inner + .add_transaction(cdk_transaction) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_transaction( + &self, + transaction_id: TransactionId, + ) -> Result, FfiError> { + let cdk_id = transaction_id.try_into()?; + let result = self + .inner + .get_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_direction = direction.map(Into::into); + let cdk_unit = unit.map(Into::into); + + let result = self + .inner + .list_transactions(cdk_mint_url, cdk_direction, cdk_unit) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> { + let cdk_id = transaction_id.try_into()?; + self.inner + .remove_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } +} + +#[uniffi::export] +impl WalletPostgresDatabase { + /// Create a new Postgres-backed wallet database + /// Requires cdk-ffi to be built with feature "postgres". + /// Example URL: + /// "host=localhost user=test password=test dbname=testdb port=5433 schema=wallet sslmode=prefer" + #[cfg(feature = "postgres")] + #[uniffi::constructor] + pub fn new(url: String) -> Result, FfiError> { + let inner = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on( + async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }, + ) + }), + // Important: use a process-long runtime so background connection tasks stay alive. + Err(_) => pg_runtime() + .block_on(async move { cdk_postgres::new_wallet_pg_database(url.as_str()).await }), + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(WalletPostgresDatabase { + inner: Arc::new(inner), + })) + } + + fn clone_as_trait(&self) -> Arc { + // Safety: UniFFI objects are reference counted and Send+Sync via Arc + let obj: Arc = Arc::new(WalletPostgresDatabase { + inner: self.inner.clone(), + }); + obj + } +} diff --git a/crates/cdk-ffi/src/sqlite.rs b/crates/cdk-ffi/src/sqlite.rs new file mode 100644 index 000000000..566783f83 --- /dev/null +++ b/crates/cdk-ffi/src/sqlite.rs @@ -0,0 +1,433 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use cdk_sqlite::wallet::WalletSqliteDatabase as CdkWalletSqliteDatabase; + +use crate::{ + CurrencyUnit, FfiError, Id, KeySet, KeySetInfo, Keys, MeltQuote, MintInfo, MintQuote, MintUrl, + ProofInfo, ProofState, PublicKey, SpendingConditions, Transaction, TransactionDirection, + TransactionId, WalletDatabase, +}; + +/// FFI-compatible WalletSqliteDatabase implementation that implements the WalletDatabase trait +#[derive(uniffi::Object)] +pub struct WalletSqliteDatabase { + inner: Arc, +} +use cdk::cdk_database::WalletDatabase as CdkWalletDatabase; + +impl WalletSqliteDatabase { + // No additional methods needed beyond the trait implementation +} + +#[uniffi::export] +impl WalletSqliteDatabase { + /// Create a new WalletSqliteDatabase with the given work directory + #[uniffi::constructor] + pub fn new(file_path: String) -> Result, FfiError> { + let db = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle + .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) + }), + Err(_) => { + // No current runtime, create a new one + tokio::runtime::Runtime::new() + .map_err(|e| FfiError::Database { + msg: format!("Failed to create runtime: {}", e), + })? + .block_on(async move { CdkWalletSqliteDatabase::new(file_path.as_str()).await }) + } + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(Self { + inner: Arc::new(db), + })) + } + + /// Create an in-memory database + #[uniffi::constructor] + pub fn new_in_memory() -> Result, FfiError> { + let db = match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| { + handle.block_on(async move { cdk_sqlite::wallet::memory::empty().await }) + }), + Err(_) => { + // No current runtime, create a new one + tokio::runtime::Runtime::new() + .map_err(|e| FfiError::Database { + msg: format!("Failed to create runtime: {}", e), + })? + .block_on(async move { cdk_sqlite::wallet::memory::empty().await }) + } + } + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(Arc::new(Self { + inner: Arc::new(db), + })) + } +} + +#[uniffi::export(async_runtime = "tokio")] +#[async_trait::async_trait] +impl WalletDatabase for WalletSqliteDatabase { + // Mint Management + async fn add_mint( + &self, + mint_url: MintUrl, + mint_info: Option, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_mint_info = mint_info.map(Into::into); + self.inner + .add_mint(cdk_mint_url, cdk_mint_info) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + self.inner + .remove_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint(&self, mint_url: MintUrl) -> Result, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn get_mints(&self) -> Result>, FfiError> { + let result = self + .inner + .get_mints() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result + .into_iter() + .map(|(k, v)| (k.into(), v.map(Into::into))) + .collect()) + } + + async fn update_mint_url( + &self, + old_mint_url: MintUrl, + new_mint_url: MintUrl, + ) -> Result<(), FfiError> { + let cdk_old_mint_url = old_mint_url.try_into()?; + let cdk_new_mint_url = new_mint_url.try_into()?; + self.inner + .update_mint_url(cdk_old_mint_url, cdk_new_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Management + async fn add_mint_keysets( + &self, + mint_url: MintUrl, + keysets: Vec, + ) -> Result<(), FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let cdk_keysets: Vec = keysets.into_iter().map(Into::into).collect(); + self.inner + .add_mint_keysets(cdk_mint_url, cdk_keysets) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_keysets( + &self, + mint_url: MintUrl, + ) -> Result>, FfiError> { + let cdk_mint_url = mint_url.try_into()?; + let result = self + .inner + .get_mint_keysets(cdk_mint_url) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|keysets| keysets.into_iter().map(Into::into).collect())) + } + + async fn get_keyset_by_id(&self, keyset_id: Id) -> Result, FfiError> { + let cdk_id = keyset_id.into(); + let result = self + .inner + .get_keyset_by_id(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + // Mint Quote Management + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_mint_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_mint_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_mint_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_mint_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_mint_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_mint_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Melt Quote Management + async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), FfiError> { + let cdk_quote = quote.try_into()?; + self.inner + .add_melt_quote(cdk_quote) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_melt_quote(&self, quote_id: String) -> Result, FfiError> { + let result = self + .inner + .get_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(|q| q.into())) + } + + async fn get_melt_quotes(&self) -> Result, FfiError> { + let result = self + .inner + .get_melt_quotes() + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.into_iter().map(|q| q.into()).collect()) + } + + async fn remove_melt_quote(&self, quote_id: String) -> Result<(), FfiError> { + self.inner + .remove_melt_quote("e_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keys Management + async fn add_keys(&self, keyset: KeySet) -> Result<(), FfiError> { + // Convert FFI KeySet to cdk::nuts::KeySet + let cdk_keyset: cdk::nuts::KeySet = keyset.try_into()?; + self.inner + .add_keys(cdk_keyset) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_keys(&self, id: Id) -> Result, FfiError> { + let cdk_id = id.into(); + let result = self + .inner + .get_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn remove_keys(&self, id: Id) -> Result<(), FfiError> { + let cdk_id = id.into(); + self.inner + .remove_keys(&cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Proof Management + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), FfiError> { + // Convert FFI types to CDK types + let cdk_added: Result, FfiError> = added + .into_iter() + .map(|info| { + Ok::(cdk::types::ProofInfo { + proof: info.proof.try_into()?, + y: info.y.try_into()?, + mint_url: info.mint_url.try_into()?, + state: info.state.into(), + spending_condition: info + .spending_condition + .map(|sc| sc.try_into()) + .transpose()?, + unit: info.unit.into(), + }) + }) + .collect(); + let cdk_added = cdk_added?; + + let cdk_removed_ys: Result, FfiError> = + removed_ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_removed_ys = cdk_removed_ys?; + + self.inner + .update_proofs(cdk_added, cdk_removed_ys) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_unit = unit.map(Into::into); + let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); + let cdk_spending_conditions: Option> = + spending_conditions + .map(|sc| { + sc.into_iter() + .map(|c| c.try_into()) + .collect::, FfiError>>() + }) + .transpose()?; + + let result = self + .inner + .get_proofs(cdk_mint_url, cdk_unit, cdk_state, cdk_spending_conditions) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn get_proofs_by_ys(&self, ys: Vec) -> Result, FfiError> { + let cdk_ys: Vec = ys + .into_iter() + .map(|y| y.try_into()) + .collect::, FfiError>>()?; + + let result = self + .inner + .get_proofs_by_ys(cdk_ys) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn get_balance( + &self, + mint_url: Option, + unit: Option, + state: Option>, + ) -> Result { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_unit = unit.map(Into::into); + let cdk_state = state.map(|s| s.into_iter().map(Into::into).collect()); + + self.inner + .get_balance(cdk_mint_url, cdk_unit, cdk_state) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn update_proofs_state( + &self, + ys: Vec, + state: ProofState, + ) -> Result<(), FfiError> { + let cdk_ys: Result, FfiError> = + ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_ys = cdk_ys?; + let cdk_state = state.into(); + + self.inner + .update_proofs_state(cdk_ys, cdk_state) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Keyset Counter Management + async fn increment_keyset_counter(&self, keyset_id: Id, count: u32) -> Result { + let cdk_id = keyset_id.into(); + self.inner + .increment_keyset_counter(&cdk_id, count) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + // Transaction Management + async fn add_transaction(&self, transaction: Transaction) -> Result<(), FfiError> { + // Convert FFI Transaction to CDK Transaction using TryFrom + let cdk_transaction: cdk::wallet::types::Transaction = transaction.try_into()?; + + self.inner + .add_transaction(cdk_transaction) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } + + async fn get_transaction( + &self, + transaction_id: TransactionId, + ) -> Result, FfiError> { + let cdk_id = transaction_id.try_into()?; + let result = self + .inner + .get_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + Ok(result.map(Into::into)) + } + + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, FfiError> { + let cdk_mint_url = mint_url.map(|u| u.try_into()).transpose()?; + let cdk_direction = direction.map(Into::into); + let cdk_unit = unit.map(Into::into); + + let result = self + .inner + .list_transactions(cdk_mint_url, cdk_direction, cdk_unit) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() })?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), FfiError> { + let cdk_id = transaction_id.try_into()?; + self.inner + .remove_transaction(cdk_id) + .await + .map_err(|e| FfiError::Database { msg: e.to_string() }) + } +} diff --git a/crates/cdk-ffi/src/token.rs b/crates/cdk-ffi/src/token.rs new file mode 100644 index 000000000..a5c96f4e5 --- /dev/null +++ b/crates/cdk-ffi/src/token.rs @@ -0,0 +1,162 @@ +//! FFI token bindings + +use std::collections::BTreeSet; +use std::str::FromStr; + +use crate::error::FfiError; +use crate::{Amount, CurrencyUnit, KeySetInfo, MintUrl, Proofs}; + +/// FFI-compatible Token +#[derive(Debug, uniffi::Object)] +pub struct Token { + pub(crate) inner: cdk::nuts::Token, +} + +impl std::fmt::Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.inner) + } +} + +impl FromStr for Token { + type Err = FfiError; + + fn from_str(s: &str) -> Result { + let token = cdk::nuts::Token::from_str(s) + .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; + Ok(Token { inner: token }) + } +} + +impl From for Token { + fn from(token: cdk::nuts::Token) -> Self { + Self { inner: token } + } +} + +impl From for cdk::nuts::Token { + fn from(token: Token) -> Self { + token.inner + } +} + +#[uniffi::export] +impl Token { + /// Create a new Token from string + #[uniffi::constructor] + pub fn from_string(encoded_token: String) -> Result { + let token = cdk::nuts::Token::from_str(&encoded_token) + .map_err(|e| FfiError::InvalidToken { msg: e.to_string() })?; + Ok(Token { inner: token }) + } + + /// Get the total value of the token + pub fn value(&self) -> Result { + Ok(self.inner.value()?.into()) + } + + /// Get the memo from the token + pub fn memo(&self) -> Option { + self.inner.memo().clone() + } + + /// Get the currency unit + pub fn unit(&self) -> Option { + self.inner.unit().map(Into::into) + } + + /// Get the mint URL + pub fn mint_url(&self) -> Result { + Ok(self.inner.mint_url()?.into()) + } + + /// Get proofs from the token (simplified - no keyset filtering for now) + pub fn proofs_simple(&self) -> Result { + // 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| p.into()).collect()) + } + + /// Get proofs from the token + pub fn proofs(&self, mint_keysets: Vec) -> Result { + let mint_keysets: Vec<_> = mint_keysets.into_iter().map(|k| k.into()).collect(); + let proofs = self.inner.proofs(&mint_keysets)?; + Ok(proofs.into_iter().map(|p| p.into()).collect()) + } + + /// Convert token to raw bytes + pub fn to_raw_bytes(&self) -> Result, FfiError> { + Ok(self.inner.to_raw_bytes()?) + } + + /// Encode token to string representation + pub fn encode(&self) -> String { + self.to_string() + } + + /// Decode token from string representation + #[uniffi::constructor] + pub fn decode(encoded_token: String) -> Result { + encoded_token.parse() + } + + /// Return unique spending conditions across all proofs in this token + pub fn spending_conditions(&self) -> Vec { + self.inner + .spending_conditions() + .map(|set| set.into_iter().map(Into::into).collect()) + .unwrap_or_default() + } + + /// Return all P2PK pubkeys referenced by this token's spending conditions + pub fn p2pk_pubkeys(&self) -> Vec { + let set = self + .inner + .p2pk_pubkeys() + .map(|keys| { + keys.into_iter() + .map(|k| k.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all refund pubkeys from P2PK spending conditions + pub fn p2pk_refund_pubkeys(&self) -> Vec { + let set = self + .inner + .p2pk_refund_pubkeys() + .map(|keys| { + keys.into_iter() + .map(|k| k.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all HTLC hashes from spending conditions + pub fn htlc_hashes(&self) -> Vec { + let set = self + .inner + .htlc_hashes() + .map(|hashes| { + hashes + .into_iter() + .map(|h| h.to_string()) + .collect::>() + }) + .unwrap_or_default(); + set.into_iter().collect() + } + + /// Return all locktimes from spending conditions (sorted ascending) + pub fn locktimes(&self) -> Vec { + self.inner + .locktimes() + .map(|s| s.into_iter().collect()) + .unwrap_or_default() + } +} diff --git a/crates/cdk-ffi/src/types/amount.rs b/crates/cdk-ffi/src/types/amount.rs new file mode 100644 index 000000000..d0864d574 --- /dev/null +++ b/crates/cdk-ffi/src/types/amount.rs @@ -0,0 +1,166 @@ +//! Amount and currency related types + +use cdk::nuts::CurrencyUnit as CdkCurrencyUnit; +use cdk::Amount as CdkAmount; +use serde::{Deserialize, Serialize}; + +use crate::error::FfiError; + +/// FFI-compatible Amount type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct Amount { + pub value: u64, +} + +impl Amount { + pub fn new(value: u64) -> Self { + Self { value } + } + + pub fn zero() -> Self { + Self { value: 0 } + } + + pub fn is_zero(&self) -> bool { + self.value == 0 + } + + pub fn convert_unit( + &self, + current_unit: CurrencyUnit, + target_unit: CurrencyUnit, + ) -> Result { + Ok(CdkAmount::from(self.value) + .convert_unit(¤t_unit.into(), &target_unit.into()) + .map(Into::into)?) + } + + pub fn add(&self, other: Amount) -> Result { + let self_amount = CdkAmount::from(self.value); + let other_amount = CdkAmount::from(other.value); + self_amount + .checked_add(other_amount) + .map(Into::into) + .ok_or(FfiError::AmountOverflow) + } + + pub fn subtract(&self, other: Amount) -> Result { + let self_amount = CdkAmount::from(self.value); + let other_amount = CdkAmount::from(other.value); + self_amount + .checked_sub(other_amount) + .map(Into::into) + .ok_or(FfiError::AmountOverflow) + } + + pub fn multiply(&self, factor: u64) -> Result { + let self_amount = CdkAmount::from(self.value); + let factor_amount = CdkAmount::from(factor); + self_amount + .checked_mul(factor_amount) + .map(Into::into) + .ok_or(FfiError::AmountOverflow) + } + + pub fn divide(&self, divisor: u64) -> Result { + if divisor == 0 { + return Err(FfiError::DivisionByZero); + } + let self_amount = CdkAmount::from(self.value); + let divisor_amount = CdkAmount::from(divisor); + self_amount + .checked_div(divisor_amount) + .map(Into::into) + .ok_or(FfiError::AmountOverflow) + } +} + +impl From for Amount { + fn from(amount: CdkAmount) -> Self { + Self { + value: u64::from(amount), + } + } +} + +impl From for CdkAmount { + fn from(amount: Amount) -> Self { + CdkAmount::from(amount.value) + } +} + +/// FFI-compatible Currency Unit +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum CurrencyUnit { + Sat, + Msat, + Usd, + Eur, + Auth, + Custom { unit: String }, +} + +impl From for CurrencyUnit { + fn from(unit: CdkCurrencyUnit) -> Self { + match unit { + CdkCurrencyUnit::Sat => CurrencyUnit::Sat, + CdkCurrencyUnit::Msat => CurrencyUnit::Msat, + CdkCurrencyUnit::Usd => CurrencyUnit::Usd, + CdkCurrencyUnit::Eur => CurrencyUnit::Eur, + CdkCurrencyUnit::Auth => CurrencyUnit::Auth, + CdkCurrencyUnit::Custom(s) => CurrencyUnit::Custom { unit: s }, + _ => CurrencyUnit::Sat, // Default for unknown units + } + } +} + +impl From for CdkCurrencyUnit { + fn from(unit: CurrencyUnit) -> Self { + match unit { + CurrencyUnit::Sat => CdkCurrencyUnit::Sat, + CurrencyUnit::Msat => CdkCurrencyUnit::Msat, + CurrencyUnit::Usd => CdkCurrencyUnit::Usd, + CurrencyUnit::Eur => CdkCurrencyUnit::Eur, + CurrencyUnit::Auth => CdkCurrencyUnit::Auth, + CurrencyUnit::Custom { unit } => CdkCurrencyUnit::Custom(unit), + } + } +} + +/// FFI-compatible SplitTarget +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +pub enum SplitTarget { + /// Default target; least amount of proofs + None, + /// Target amount for wallet to have most proofs that add up to value + Value { amount: Amount }, + /// Specific amounts to split into (must equal amount being split) + Values { amounts: Vec }, +} + +impl From for cdk::amount::SplitTarget { + fn from(target: SplitTarget) -> Self { + match target { + SplitTarget::None => cdk::amount::SplitTarget::None, + SplitTarget::Value { amount } => cdk::amount::SplitTarget::Value(amount.into()), + SplitTarget::Values { amounts } => { + cdk::amount::SplitTarget::Values(amounts.into_iter().map(Into::into).collect()) + } + } + } +} + +impl From for SplitTarget { + fn from(target: cdk::amount::SplitTarget) -> Self { + match target { + cdk::amount::SplitTarget::None => SplitTarget::None, + cdk::amount::SplitTarget::Value(amount) => SplitTarget::Value { + amount: amount.into(), + }, + cdk::amount::SplitTarget::Values(amounts) => SplitTarget::Values { + amounts: amounts.into_iter().map(Into::into).collect(), + }, + } + } +} diff --git a/crates/cdk-ffi/src/types/invoice.rs b/crates/cdk-ffi/src/types/invoice.rs new file mode 100644 index 000000000..262cbebd1 --- /dev/null +++ b/crates/cdk-ffi/src/types/invoice.rs @@ -0,0 +1,96 @@ +//! Invoice decoding FFI types and functions + +use serde::{Deserialize, Serialize}; + +use crate::error::FfiError; + +/// Type of Lightning payment request +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Enum)] +pub enum PaymentType { + /// Bolt11 invoice + Bolt11, + /// Bolt12 offer + Bolt12, +} + +impl From for PaymentType { + fn from(payment_type: cdk::invoice::PaymentType) -> Self { + match payment_type { + cdk::invoice::PaymentType::Bolt11 => Self::Bolt11, + cdk::invoice::PaymentType::Bolt12 => Self::Bolt12, + } + } +} + +impl From for cdk::invoice::PaymentType { + fn from(payment_type: PaymentType) -> Self { + match payment_type { + PaymentType::Bolt11 => Self::Bolt11, + PaymentType::Bolt12 => Self::Bolt12, + } + } +} + +/// Decoded invoice or offer information +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct DecodedInvoice { + /// Type of payment request (Bolt11 or Bolt12) + pub payment_type: PaymentType, + /// Amount in millisatoshis, if specified + pub amount_msat: Option, + /// Expiry timestamp (Unix timestamp), if specified + pub expiry: Option, + /// Description or offer description, if specified + pub description: Option, +} + +impl From for DecodedInvoice { + fn from(decoded: cdk::invoice::DecodedInvoice) -> Self { + Self { + payment_type: decoded.payment_type.into(), + amount_msat: decoded.amount_msat, + expiry: decoded.expiry, + description: decoded.description, + } + } +} + +impl From for cdk::invoice::DecodedInvoice { + fn from(decoded: DecodedInvoice) -> Self { + Self { + payment_type: decoded.payment_type.into(), + amount_msat: decoded.amount_msat, + expiry: decoded.expiry, + description: decoded.description, + } + } +} + +/// Decode a bolt11 invoice or bolt12 offer from a string +/// +/// This function attempts to parse the input as a bolt11 invoice first, +/// then as a bolt12 offer if bolt11 parsing fails. +/// +/// # Arguments +/// +/// * `invoice_str` - The invoice or offer string to decode +/// +/// # Returns +/// +/// * `Ok(DecodedInvoice)` - Successfully decoded invoice/offer information +/// * `Err(FfiError)` - Failed to parse as either bolt11 or bolt12 +/// +/// # Example +/// +/// ```kotlin +/// val decoded = decodeInvoice("lnbc...") +/// when (decoded.paymentType) { +/// PaymentType.BOLT11 -> println("Bolt11 invoice") +/// PaymentType.BOLT12 -> println("Bolt12 offer") +/// } +/// ``` +#[uniffi::export] +pub fn decode_invoice(invoice_str: String) -> Result { + let decoded = cdk::invoice::decode_invoice(&invoice_str)?; + Ok(decoded.into()) +} diff --git a/crates/cdk-ffi/src/types/keys.rs b/crates/cdk-ffi/src/types/keys.rs new file mode 100644 index 000000000..57ef47617 --- /dev/null +++ b/crates/cdk-ffi/src/types/keys.rs @@ -0,0 +1,258 @@ +//! Key-related FFI types + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::amount::CurrencyUnit; +use crate::error::FfiError; + +/// FFI-compatible KeySetInfo +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct KeySetInfo { + pub id: String, + pub unit: CurrencyUnit, + pub active: bool, + /// Input fee per thousand (ppk) + pub input_fee_ppk: u64, +} + +impl From for KeySetInfo { + fn from(keyset: cdk::nuts::KeySetInfo) -> Self { + Self { + id: keyset.id.to_string(), + unit: keyset.unit.into(), + active: keyset.active, + input_fee_ppk: keyset.input_fee_ppk, + } + } +} + +impl From for cdk::nuts::KeySetInfo { + fn from(keyset: KeySetInfo) -> Self { + use std::str::FromStr; + Self { + id: cdk::nuts::Id::from_str(&keyset.id).unwrap(), + unit: keyset.unit.into(), + active: keyset.active, + final_expiry: None, + input_fee_ppk: keyset.input_fee_ppk, + } + } +} + +impl KeySetInfo { + /// Convert KeySetInfo to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode KeySetInfo from JSON string +#[uniffi::export] +pub fn decode_key_set_info(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode KeySetInfo to JSON string +#[uniffi::export] +pub fn encode_key_set_info(info: KeySetInfo) -> Result { + Ok(serde_json::to_string(&info)?) +} + +/// FFI-compatible PublicKey +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct PublicKey { + /// Hex-encoded public key + pub hex: String, +} + +impl From for PublicKey { + fn from(key: cdk::nuts::PublicKey) -> Self { + Self { + hex: key.to_string(), + } + } +} + +impl TryFrom for cdk::nuts::PublicKey { + type Error = FfiError; + + fn try_from(key: PublicKey) -> Result { + key.hex + .parse() + .map_err(|e| FfiError::InvalidCryptographicKey { + msg: format!("Invalid public key: {}", e), + }) + } +} + +/// FFI-compatible Keys (simplified - contains only essential info) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct Keys { + /// Keyset ID + pub id: String, + /// Currency unit + pub unit: CurrencyUnit, + /// Map of amount to public key hex (simplified from BTreeMap) + pub keys: HashMap, +} + +impl From for Keys { + fn from(keys: cdk::nuts::Keys) -> Self { + // Keys doesn't have id and unit - we'll need to get these from context + // For now, use placeholder values + Self { + id: "unknown".to_string(), // This should come from KeySet + unit: CurrencyUnit::Sat, // This should come from KeySet + keys: keys + .keys() + .iter() + .map(|(amount, pubkey)| (u64::from(*amount), pubkey.to_string())) + .collect(), + } + } +} + +impl TryFrom for cdk::nuts::Keys { + type Error = FfiError; + + fn try_from(keys: Keys) -> Result { + use std::collections::BTreeMap; + use std::str::FromStr; + + // Convert the HashMap to BTreeMap with proper types + let mut keys_map = BTreeMap::new(); + for (amount_u64, pubkey_hex) in keys.keys { + let amount = cdk::Amount::from(amount_u64); + let pubkey = cdk::nuts::PublicKey::from_str(&pubkey_hex) + .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?; + keys_map.insert(amount, pubkey); + } + + Ok(cdk::nuts::Keys::new(keys_map)) + } +} + +impl Keys { + /// Convert Keys to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode Keys from JSON string +#[uniffi::export] +pub fn decode_keys(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode Keys to JSON string +#[uniffi::export] +pub fn encode_keys(keys: Keys) -> Result { + Ok(serde_json::to_string(&keys)?) +} + +/// FFI-compatible KeySet +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct KeySet { + /// Keyset ID + pub id: String, + /// Currency unit + pub unit: CurrencyUnit, + /// The keys (map of amount to public key hex) + pub keys: HashMap, + /// Optional expiry timestamp + pub final_expiry: Option, +} + +impl From for KeySet { + fn from(keyset: cdk::nuts::KeySet) -> Self { + Self { + id: keyset.id.to_string(), + unit: keyset.unit.into(), + keys: keyset + .keys + .keys() + .iter() + .map(|(amount, pubkey)| (u64::from(*amount), pubkey.to_string())) + .collect(), + final_expiry: keyset.final_expiry, + } + } +} + +impl TryFrom for cdk::nuts::KeySet { + type Error = FfiError; + + fn try_from(keyset: KeySet) -> Result { + use std::collections::BTreeMap; + use std::str::FromStr; + + // Convert id + let id = cdk::nuts::Id::from_str(&keyset.id) + .map_err(|e| FfiError::Serialization { msg: e.to_string() })?; + + // Convert unit + let unit: cdk::nuts::CurrencyUnit = keyset.unit.into(); + + // Convert keys + let mut keys_map = BTreeMap::new(); + for (amount_u64, pubkey_hex) in keyset.keys { + let amount = cdk::Amount::from(amount_u64); + let pubkey = cdk::nuts::PublicKey::from_str(&pubkey_hex) + .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?; + keys_map.insert(amount, pubkey); + } + let keys = cdk::nuts::Keys::new(keys_map); + + Ok(cdk::nuts::KeySet { + id, + unit, + keys, + final_expiry: keyset.final_expiry, + }) + } +} + +impl KeySet { + /// Convert KeySet to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode KeySet from JSON string +#[uniffi::export] +pub fn decode_key_set(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode KeySet to JSON string +#[uniffi::export] +pub fn encode_key_set(keyset: KeySet) -> Result { + Ok(serde_json::to_string(&keyset)?) +} + +/// FFI-compatible Id (for keyset IDs) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct Id { + pub hex: String, +} + +impl From for Id { + fn from(id: cdk::nuts::Id) -> Self { + Self { + hex: id.to_string(), + } + } +} + +impl From for cdk::nuts::Id { + fn from(id: Id) -> Self { + use std::str::FromStr; + Self::from_str(&id.hex).unwrap() + } +} diff --git a/crates/cdk-ffi/src/types/mint.rs b/crates/cdk-ffi/src/types/mint.rs new file mode 100644 index 000000000..6287beeb2 --- /dev/null +++ b/crates/cdk-ffi/src/types/mint.rs @@ -0,0 +1,1012 @@ +//! Mint-related FFI types + +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +use super::amount::{Amount, CurrencyUnit}; +use super::quote::PaymentMethod; +use crate::error::FfiError; + +/// FFI-compatible Mint URL +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct MintUrl { + pub url: String, +} + +impl MintUrl { + pub fn new(url: String) -> Result { + // Validate URL format + url::Url::parse(&url).map_err(|e| FfiError::InvalidUrl { msg: e.to_string() })?; + + Ok(Self { url }) + } +} + +impl From for MintUrl { + fn from(mint_url: cdk::mint_url::MintUrl) -> Self { + Self { + url: mint_url.to_string(), + } + } +} + +impl TryFrom for cdk::mint_url::MintUrl { + type Error = FfiError; + + fn try_from(mint_url: MintUrl) -> Result { + cdk::mint_url::MintUrl::from_str(&mint_url.url) + .map_err(|e| FfiError::InvalidUrl { msg: e.to_string() }) + } +} + +/// FFI-compatible MintVersion +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct MintVersion { + /// Mint Software name + pub name: String, + /// Mint Version + pub version: String, +} + +impl From for MintVersion { + fn from(version: cdk::nuts::MintVersion) -> Self { + Self { + name: version.name, + version: version.version, + } + } +} + +impl From for cdk::nuts::MintVersion { + fn from(version: MintVersion) -> Self { + Self { + name: version.name, + version: version.version, + } + } +} + +impl MintVersion { + /// Convert MintVersion to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode MintVersion from JSON string +#[uniffi::export] +pub fn decode_mint_version(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode MintVersion to JSON string +#[uniffi::export] +pub fn encode_mint_version(version: MintVersion) -> Result { + Ok(serde_json::to_string(&version)?) +} + +/// FFI-compatible ContactInfo +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ContactInfo { + /// Contact Method i.e. nostr + pub method: String, + /// Contact info i.e. npub... + pub info: String, +} + +impl From for ContactInfo { + fn from(contact: cdk::nuts::ContactInfo) -> Self { + Self { + method: contact.method, + info: contact.info, + } + } +} + +impl From for cdk::nuts::ContactInfo { + fn from(contact: ContactInfo) -> Self { + Self { + method: contact.method, + info: contact.info, + } + } +} + +impl ContactInfo { + /// Convert ContactInfo to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode ContactInfo from JSON string +#[uniffi::export] +pub fn decode_contact_info(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode ContactInfo to JSON string +#[uniffi::export] +pub fn encode_contact_info(info: ContactInfo) -> Result { + Ok(serde_json::to_string(&info)?) +} + +/// FFI-compatible SupportedSettings +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct SupportedSettings { + /// Setting supported + pub supported: bool, +} + +impl From for SupportedSettings { + fn from(settings: cdk::nuts::nut06::SupportedSettings) -> Self { + Self { + supported: settings.supported, + } + } +} + +impl From for cdk::nuts::nut06::SupportedSettings { + fn from(settings: SupportedSettings) -> Self { + Self { + supported: settings.supported, + } + } +} + +// ----------------------------- +// NUT-04/05 FFI Types +// ----------------------------- + +/// FFI-compatible MintMethodSettings (NUT-04) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct MintMethodSettings { + pub method: PaymentMethod, + pub unit: CurrencyUnit, + pub min_amount: Option, + pub max_amount: Option, + /// For bolt11, whether mint supports setting invoice description + pub description: Option, +} + +impl From for MintMethodSettings { + fn from(s: cdk::nuts::nut04::MintMethodSettings) -> Self { + let description = match s.options { + Some(cdk::nuts::nut04::MintMethodOptions::Bolt11 { description }) => Some(description), + _ => None, + }; + Self { + method: s.method.into(), + unit: s.unit.into(), + min_amount: s.min_amount.map(Into::into), + max_amount: s.max_amount.map(Into::into), + description, + } + } +} + +impl TryFrom for cdk::nuts::nut04::MintMethodSettings { + type Error = FfiError; + + fn try_from(s: MintMethodSettings) -> Result { + let options = match (s.method.clone(), s.description) { + (PaymentMethod::Bolt11, Some(description)) => { + Some(cdk::nuts::nut04::MintMethodOptions::Bolt11 { description }) + } + _ => None, + }; + Ok(Self { + method: s.method.into(), + unit: s.unit.into(), + min_amount: s.min_amount.map(Into::into), + max_amount: s.max_amount.map(Into::into), + options, + }) + } +} + +/// FFI-compatible Nut04 Settings +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct Nut04Settings { + pub methods: Vec, + pub disabled: bool, +} + +impl From for Nut04Settings { + fn from(s: cdk::nuts::nut04::Settings) -> Self { + Self { + methods: s.methods.into_iter().map(Into::into).collect(), + disabled: s.disabled, + } + } +} + +impl TryFrom for cdk::nuts::nut04::Settings { + type Error = FfiError; + + fn try_from(s: Nut04Settings) -> Result { + Ok(Self { + methods: s + .methods + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + disabled: s.disabled, + }) + } +} + +/// FFI-compatible MeltMethodSettings (NUT-05) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct MeltMethodSettings { + pub method: PaymentMethod, + pub unit: CurrencyUnit, + pub min_amount: Option, + pub max_amount: Option, + /// For bolt11, whether mint supports amountless invoices + pub amountless: Option, +} + +impl From for MeltMethodSettings { + fn from(s: cdk::nuts::nut05::MeltMethodSettings) -> Self { + let amountless = match s.options { + Some(cdk::nuts::nut05::MeltMethodOptions::Bolt11 { amountless }) => Some(amountless), + _ => None, + }; + Self { + method: s.method.into(), + unit: s.unit.into(), + min_amount: s.min_amount.map(Into::into), + max_amount: s.max_amount.map(Into::into), + amountless, + } + } +} + +impl TryFrom for cdk::nuts::nut05::MeltMethodSettings { + type Error = FfiError; + + fn try_from(s: MeltMethodSettings) -> Result { + let options = match (s.method.clone(), s.amountless) { + (PaymentMethod::Bolt11, Some(amountless)) => { + Some(cdk::nuts::nut05::MeltMethodOptions::Bolt11 { amountless }) + } + _ => None, + }; + Ok(Self { + method: s.method.into(), + unit: s.unit.into(), + min_amount: s.min_amount.map(Into::into), + max_amount: s.max_amount.map(Into::into), + options, + }) + } +} + +/// FFI-compatible Nut05 Settings +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct Nut05Settings { + pub methods: Vec, + pub disabled: bool, +} + +impl From for Nut05Settings { + fn from(s: cdk::nuts::nut05::Settings) -> Self { + Self { + methods: s.methods.into_iter().map(Into::into).collect(), + disabled: s.disabled, + } + } +} + +impl TryFrom for cdk::nuts::nut05::Settings { + type Error = FfiError; + + fn try_from(s: Nut05Settings) -> Result { + Ok(Self { + methods: s + .methods + .into_iter() + .map(TryInto::try_into) + .collect::>()?, + disabled: s.disabled, + }) + } +} + +/// FFI-compatible ProtectedEndpoint (for auth nuts) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ProtectedEndpoint { + /// HTTP method (GET, POST, etc.) + pub method: String, + /// Endpoint path + pub path: String, +} + +/// FFI-compatible ClearAuthSettings (NUT-21) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ClearAuthSettings { + /// OpenID Connect discovery URL + pub openid_discovery: String, + /// OAuth 2.0 client ID + pub client_id: String, + /// Protected endpoints requiring clear authentication + pub protected_endpoints: Vec, +} + +/// FFI-compatible BlindAuthSettings (NUT-22) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct BlindAuthSettings { + /// Maximum number of blind auth tokens that can be minted per request + pub bat_max_mint: u64, + /// Protected endpoints requiring blind authentication + pub protected_endpoints: Vec, +} + +impl From for ClearAuthSettings { + fn from(settings: cdk::nuts::ClearAuthSettings) -> Self { + Self { + openid_discovery: settings.openid_discovery, + client_id: settings.client_id, + protected_endpoints: settings + .protected_endpoints + .into_iter() + .map(Into::into) + .collect(), + } + } +} + +impl TryFrom for cdk::nuts::ClearAuthSettings { + type Error = FfiError; + + fn try_from(settings: ClearAuthSettings) -> Result { + Ok(Self { + openid_discovery: settings.openid_discovery, + client_id: settings.client_id, + protected_endpoints: settings + .protected_endpoints + .into_iter() + .map(|e| e.try_into()) + .collect::, _>>()?, + }) + } +} + +impl From for BlindAuthSettings { + fn from(settings: cdk::nuts::BlindAuthSettings) -> Self { + Self { + bat_max_mint: settings.bat_max_mint, + protected_endpoints: settings + .protected_endpoints + .into_iter() + .map(Into::into) + .collect(), + } + } +} + +impl TryFrom for cdk::nuts::BlindAuthSettings { + type Error = FfiError; + + fn try_from(settings: BlindAuthSettings) -> Result { + Ok(Self { + bat_max_mint: settings.bat_max_mint, + protected_endpoints: settings + .protected_endpoints + .into_iter() + .map(|e| e.try_into()) + .collect::, _>>()?, + }) + } +} + +impl From for ProtectedEndpoint { + fn from(endpoint: cdk::nuts::ProtectedEndpoint) -> Self { + Self { + method: match endpoint.method { + cdk::nuts::Method::Get => "GET".to_string(), + cdk::nuts::Method::Post => "POST".to_string(), + }, + path: endpoint.path.to_string(), + } + } +} + +impl TryFrom for cdk::nuts::ProtectedEndpoint { + type Error = FfiError; + + fn try_from(endpoint: ProtectedEndpoint) -> Result { + let method = match endpoint.method.as_str() { + "GET" => cdk::nuts::Method::Get, + "POST" => cdk::nuts::Method::Post, + _ => { + return Err(FfiError::Generic { + msg: format!( + "Invalid HTTP method: {}. Only GET and POST are supported", + endpoint.method + ), + }) + } + }; + + // Convert path string to RoutePath by matching against known paths + let route_path = match endpoint.path.as_str() { + "/v1/mint/quote/bolt11" => cdk::nuts::RoutePath::MintQuoteBolt11, + "/v1/mint/bolt11" => cdk::nuts::RoutePath::MintBolt11, + "/v1/melt/quote/bolt11" => cdk::nuts::RoutePath::MeltQuoteBolt11, + "/v1/melt/bolt11" => cdk::nuts::RoutePath::MeltBolt11, + "/v1/swap" => cdk::nuts::RoutePath::Swap, + "/v1/ws" => cdk::nuts::RoutePath::Ws, + "/v1/checkstate" => cdk::nuts::RoutePath::Checkstate, + "/v1/restore" => cdk::nuts::RoutePath::Restore, + "/v1/auth/blind/mint" => cdk::nuts::RoutePath::MintBlindAuth, + "/v1/mint/quote/bolt12" => cdk::nuts::RoutePath::MintQuoteBolt12, + "/v1/mint/bolt12" => cdk::nuts::RoutePath::MintBolt12, + "/v1/melt/quote/bolt12" => cdk::nuts::RoutePath::MeltQuoteBolt12, + "/v1/melt/bolt12" => cdk::nuts::RoutePath::MeltBolt12, + _ => { + return Err(FfiError::Generic { + msg: format!("Unknown route path: {}", endpoint.path), + }) + } + }; + + Ok(cdk::nuts::ProtectedEndpoint::new(method, route_path)) + } +} + +/// FFI-compatible Nuts settings (extended to include NUT-04 and NUT-05 settings) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct Nuts { + /// NUT04 Settings + pub nut04: Nut04Settings, + /// NUT05 Settings + pub nut05: Nut05Settings, + /// NUT07 Settings - Token state check + pub nut07_supported: bool, + /// NUT08 Settings - Lightning fee return + pub nut08_supported: bool, + /// NUT09 Settings - Restore signature + pub nut09_supported: bool, + /// NUT10 Settings - Spending conditions + pub nut10_supported: bool, + /// NUT11 Settings - Pay to Public Key Hash + pub nut11_supported: bool, + /// NUT12 Settings - DLEQ proofs + pub nut12_supported: bool, + /// NUT14 Settings - Hashed Time Locked Contracts + pub nut14_supported: bool, + /// NUT20 Settings - Web sockets + pub nut20_supported: bool, + /// NUT21 Settings - Clear authentication + pub nut21: Option, + /// NUT22 Settings - Blind authentication + pub nut22: Option, + /// Supported currency units for minting + pub mint_units: Vec, + /// Supported currency units for melting + pub melt_units: Vec, +} + +impl From for Nuts { + fn from(nuts: cdk::nuts::Nuts) -> Self { + let mint_units = nuts + .supported_mint_units() + .into_iter() + .map(|u| u.clone().into()) + .collect(); + let melt_units = nuts + .supported_melt_units() + .into_iter() + .map(|u| u.clone().into()) + .collect(); + + Self { + nut04: nuts.nut04.clone().into(), + nut05: nuts.nut05.clone().into(), + nut07_supported: nuts.nut07.supported, + nut08_supported: nuts.nut08.supported, + nut09_supported: nuts.nut09.supported, + nut10_supported: nuts.nut10.supported, + nut11_supported: nuts.nut11.supported, + nut12_supported: nuts.nut12.supported, + nut14_supported: nuts.nut14.supported, + nut20_supported: nuts.nut20.supported, + nut21: nuts.nut21.map(Into::into), + nut22: nuts.nut22.map(Into::into), + mint_units, + melt_units, + } + } +} + +impl TryFrom for cdk::nuts::Nuts { + type Error = FfiError; + + fn try_from(n: Nuts) -> Result { + Ok(Self { + nut04: n.nut04.try_into()?, + nut05: n.nut05.try_into()?, + nut07: cdk::nuts::nut06::SupportedSettings { + supported: n.nut07_supported, + }, + nut08: cdk::nuts::nut06::SupportedSettings { + supported: n.nut08_supported, + }, + nut09: cdk::nuts::nut06::SupportedSettings { + supported: n.nut09_supported, + }, + nut10: cdk::nuts::nut06::SupportedSettings { + supported: n.nut10_supported, + }, + nut11: cdk::nuts::nut06::SupportedSettings { + supported: n.nut11_supported, + }, + nut12: cdk::nuts::nut06::SupportedSettings { + supported: n.nut12_supported, + }, + nut14: cdk::nuts::nut06::SupportedSettings { + supported: n.nut14_supported, + }, + nut15: Default::default(), + nut17: Default::default(), + nut19: Default::default(), + nut20: cdk::nuts::nut06::SupportedSettings { + supported: n.nut20_supported, + }, + nut21: n.nut21.map(|s| s.try_into()).transpose()?, + nut22: n.nut22.map(|s| s.try_into()).transpose()?, + }) + } +} + +impl Nuts { + /// Convert Nuts to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode Nuts from JSON string +#[uniffi::export] +pub fn decode_nuts(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode Nuts to JSON string +#[uniffi::export] +pub fn encode_nuts(nuts: Nuts) -> Result { + Ok(serde_json::to_string(&nuts)?) +} + +/// FFI-compatible MintInfo +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct MintInfo { + /// name of the mint and should be recognizable + pub name: Option, + /// hex pubkey of the mint + pub pubkey: Option, + /// implementation name and the version running + pub version: Option, + /// short description of the mint + pub description: Option, + /// long description + pub description_long: Option, + /// Contact info + pub contact: Option>, + /// shows which NUTs the mint supports + pub nuts: Nuts, + /// Mint's icon URL + pub icon_url: Option, + /// Mint's endpoint URLs + pub urls: Option>, + /// message of the day that the wallet must display to the user + pub motd: Option, + /// server unix timestamp + pub time: Option, + /// terms of url service of the mint + pub tos_url: Option, +} + +impl From for MintInfo { + fn from(info: cdk::nuts::MintInfo) -> Self { + Self { + name: info.name, + pubkey: info.pubkey.map(|p| p.to_string()), + version: info.version.map(Into::into), + description: info.description, + description_long: info.description_long, + contact: info + .contact + .map(|contacts| contacts.into_iter().map(Into::into).collect()), + nuts: info.nuts.into(), + icon_url: info.icon_url, + urls: info.urls, + motd: info.motd, + time: info.time, + tos_url: info.tos_url, + } + } +} + +impl From for cdk::nuts::MintInfo { + fn from(info: MintInfo) -> Self { + // Convert FFI Nuts back to cdk::nuts::Nuts (best-effort) + let nuts_cdk: cdk::nuts::Nuts = info.nuts.clone().try_into().unwrap_or_default(); + Self { + name: info.name, + pubkey: info.pubkey.and_then(|p| p.parse().ok()), + version: info.version.map(Into::into), + description: info.description, + description_long: info.description_long, + contact: info + .contact + .map(|contacts| contacts.into_iter().map(Into::into).collect()), + nuts: nuts_cdk, + icon_url: info.icon_url, + urls: info.urls, + motd: info.motd, + time: info.time, + tos_url: info.tos_url, + } + } +} + +impl MintInfo { + /// Convert MintInfo to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode MintInfo from JSON string +#[uniffi::export] +pub fn decode_mint_info(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode MintInfo to JSON string +#[uniffi::export] +pub fn encode_mint_info(info: MintInfo) -> Result { + Ok(serde_json::to_string(&info)?) +} +#[cfg(test)] +mod tests { + use super::*; + + /// Helper function to create a sample cdk::nuts::Nuts for testing + fn create_sample_cdk_nuts() -> cdk::nuts::Nuts { + cdk::nuts::Nuts { + nut04: cdk::nuts::nut04::Settings { + methods: vec![cdk::nuts::nut04::MintMethodSettings { + method: cdk::nuts::PaymentMethod::Bolt11, + unit: cdk::nuts::CurrencyUnit::Sat, + min_amount: Some(cdk::Amount::from(1)), + max_amount: Some(cdk::Amount::from(100000)), + options: Some(cdk::nuts::nut04::MintMethodOptions::Bolt11 { + description: true, + }), + }], + disabled: false, + }, + nut05: cdk::nuts::nut05::Settings { + methods: vec![cdk::nuts::nut05::MeltMethodSettings { + method: cdk::nuts::PaymentMethod::Bolt11, + unit: cdk::nuts::CurrencyUnit::Sat, + min_amount: Some(cdk::Amount::from(1)), + max_amount: Some(cdk::Amount::from(100000)), + options: Some(cdk::nuts::nut05::MeltMethodOptions::Bolt11 { amountless: true }), + }], + disabled: false, + }, + nut07: cdk::nuts::nut06::SupportedSettings { supported: true }, + nut08: cdk::nuts::nut06::SupportedSettings { supported: true }, + nut09: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut10: cdk::nuts::nut06::SupportedSettings { supported: true }, + nut11: cdk::nuts::nut06::SupportedSettings { supported: true }, + nut12: cdk::nuts::nut06::SupportedSettings { supported: true }, + nut14: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut15: Default::default(), + nut17: Default::default(), + nut19: Default::default(), + nut20: cdk::nuts::nut06::SupportedSettings { supported: true }, + nut21: Some(cdk::nuts::ClearAuthSettings { + openid_discovery: "https://example.com/.well-known/openid-configuration" + .to_string(), + client_id: "test-client".to_string(), + protected_endpoints: vec![cdk::nuts::ProtectedEndpoint::new( + cdk::nuts::Method::Post, + cdk::nuts::RoutePath::Swap, + )], + }), + nut22: Some(cdk::nuts::BlindAuthSettings { + bat_max_mint: 100, + protected_endpoints: vec![cdk::nuts::ProtectedEndpoint::new( + cdk::nuts::Method::Post, + cdk::nuts::RoutePath::MintBolt11, + )], + }), + } + } + + #[test] + fn test_nuts_from_cdk_to_ffi() { + let cdk_nuts = create_sample_cdk_nuts(); + let ffi_nuts: Nuts = cdk_nuts.clone().into(); + + // Verify NUT04 settings + assert!(!ffi_nuts.nut04.disabled); + assert_eq!(ffi_nuts.nut04.methods.len(), 1); + assert_eq!(ffi_nuts.nut04.methods[0].description, Some(true)); + + // Verify NUT05 settings + assert!(!ffi_nuts.nut05.disabled); + assert_eq!(ffi_nuts.nut05.methods.len(), 1); + assert_eq!(ffi_nuts.nut05.methods[0].amountless, Some(true)); + + // Verify supported flags + assert!(ffi_nuts.nut07_supported); + assert!(ffi_nuts.nut08_supported); + assert!(!ffi_nuts.nut09_supported); + assert!(ffi_nuts.nut10_supported); + assert!(ffi_nuts.nut11_supported); + assert!(ffi_nuts.nut12_supported); + assert!(!ffi_nuts.nut14_supported); + assert!(ffi_nuts.nut20_supported); + + // Verify auth settings + assert!(ffi_nuts.nut21.is_some()); + let nut21 = ffi_nuts.nut21.as_ref().unwrap(); + assert_eq!( + nut21.openid_discovery, + "https://example.com/.well-known/openid-configuration" + ); + assert_eq!(nut21.client_id, "test-client"); + assert_eq!(nut21.protected_endpoints.len(), 1); + + assert!(ffi_nuts.nut22.is_some()); + let nut22 = ffi_nuts.nut22.as_ref().unwrap(); + assert_eq!(nut22.bat_max_mint, 100); + assert_eq!(nut22.protected_endpoints.len(), 1); + + // Verify units + assert!(!ffi_nuts.mint_units.is_empty()); + assert!(!ffi_nuts.melt_units.is_empty()); + } + + #[test] + fn test_nuts_round_trip_conversion() { + let original_cdk_nuts = create_sample_cdk_nuts(); + + // Convert cdk -> ffi -> cdk + let ffi_nuts: Nuts = original_cdk_nuts.clone().into(); + let converted_back: cdk::nuts::Nuts = ffi_nuts.try_into().unwrap(); + + // Verify all supported flags match + assert_eq!( + original_cdk_nuts.nut07.supported, + converted_back.nut07.supported + ); + assert_eq!( + original_cdk_nuts.nut08.supported, + converted_back.nut08.supported + ); + assert_eq!( + original_cdk_nuts.nut09.supported, + converted_back.nut09.supported + ); + assert_eq!( + original_cdk_nuts.nut10.supported, + converted_back.nut10.supported + ); + assert_eq!( + original_cdk_nuts.nut11.supported, + converted_back.nut11.supported + ); + assert_eq!( + original_cdk_nuts.nut12.supported, + converted_back.nut12.supported + ); + assert_eq!( + original_cdk_nuts.nut14.supported, + converted_back.nut14.supported + ); + assert_eq!( + original_cdk_nuts.nut20.supported, + converted_back.nut20.supported + ); + + // Verify NUT04 settings + assert_eq!( + original_cdk_nuts.nut04.disabled, + converted_back.nut04.disabled + ); + assert_eq!( + original_cdk_nuts.nut04.methods.len(), + converted_back.nut04.methods.len() + ); + + // Verify NUT05 settings + assert_eq!( + original_cdk_nuts.nut05.disabled, + converted_back.nut05.disabled + ); + assert_eq!( + original_cdk_nuts.nut05.methods.len(), + converted_back.nut05.methods.len() + ); + + // Verify auth settings presence + assert_eq!( + original_cdk_nuts.nut21.is_some(), + converted_back.nut21.is_some() + ); + assert_eq!( + original_cdk_nuts.nut22.is_some(), + converted_back.nut22.is_some() + ); + } + + #[test] + fn test_nuts_without_auth() { + let cdk_nuts = cdk::nuts::Nuts { + nut04: Default::default(), + nut05: Default::default(), + nut07: cdk::nuts::nut06::SupportedSettings { supported: true }, + nut08: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut09: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut10: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut11: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut12: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut14: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut15: Default::default(), + nut17: Default::default(), + nut19: Default::default(), + nut20: cdk::nuts::nut06::SupportedSettings { supported: false }, + nut21: None, + nut22: None, + }; + + let ffi_nuts: Nuts = cdk_nuts.into(); + + assert!(ffi_nuts.nut21.is_none()); + assert!(ffi_nuts.nut22.is_none()); + assert!(ffi_nuts.nut07_supported); + assert!(!ffi_nuts.nut08_supported); + } + + #[test] + fn test_ffi_nuts_to_cdk_with_defaults() { + let ffi_nuts = Nuts { + nut04: Nut04Settings { + methods: vec![], + disabled: true, + }, + nut05: Nut05Settings { + methods: vec![], + disabled: true, + }, + nut07_supported: false, + nut08_supported: false, + nut09_supported: false, + nut10_supported: false, + nut11_supported: false, + nut12_supported: false, + nut14_supported: false, + nut20_supported: false, + nut21: None, + nut22: None, + mint_units: vec![], + melt_units: vec![], + }; + + let cdk_nuts: Result = ffi_nuts.try_into(); + assert!(cdk_nuts.is_ok()); + + let cdk_nuts = cdk_nuts.unwrap(); + assert!(!cdk_nuts.nut07.supported); + assert!(!cdk_nuts.nut08.supported); + assert!(cdk_nuts.nut21.is_none()); + assert!(cdk_nuts.nut22.is_none()); + + // Verify default values for nuts not included in FFI + assert_eq!(cdk_nuts.nut17.supported.len(), 0); + } + + #[test] + fn test_nuts_serialization() { + let cdk_nuts = create_sample_cdk_nuts(); + let ffi_nuts: Nuts = cdk_nuts.into(); + + // Test JSON serialization + let json = ffi_nuts.to_json(); + assert!(json.is_ok()); + + let json_str = json.unwrap(); + assert!(json_str.contains("nut04")); + assert!(json_str.contains("nut05")); + + // Test deserialization + let decoded: Result = serde_json::from_str(&json_str); + assert!(decoded.is_ok()); + + let decoded_nuts = decoded.unwrap(); + assert_eq!(decoded_nuts.nut07_supported, ffi_nuts.nut07_supported); + assert_eq!(decoded_nuts.nut08_supported, ffi_nuts.nut08_supported); + } + + #[test] + fn test_nuts_multiple_units() { + let mut cdk_nuts = create_sample_cdk_nuts(); + + // Add multiple payment methods to test unit collection + cdk_nuts + .nut04 + .methods + .push(cdk::nuts::nut04::MintMethodSettings { + method: cdk::nuts::PaymentMethod::Bolt11, + unit: cdk::nuts::CurrencyUnit::Msat, + min_amount: Some(cdk::Amount::from(1)), + max_amount: Some(cdk::Amount::from(100000)), + options: None, + }); + + cdk_nuts + .nut05 + .methods + .push(cdk::nuts::nut05::MeltMethodSettings { + method: cdk::nuts::PaymentMethod::Bolt11, + unit: cdk::nuts::CurrencyUnit::Usd, + min_amount: None, + max_amount: None, + options: None, + }); + + let ffi_nuts: Nuts = cdk_nuts.into(); + + // Should have collected multiple units + assert!(!ffi_nuts.mint_units.is_empty()); + assert!(!ffi_nuts.melt_units.is_empty()); + } + + #[test] + fn test_protected_endpoint_conversion() { + let cdk_endpoint = + cdk::nuts::ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Swap); + + let ffi_endpoint: ProtectedEndpoint = cdk_endpoint.into(); + + assert_eq!(ffi_endpoint.method, "POST"); + assert_eq!(ffi_endpoint.path, "/v1/swap"); + + // Test round-trip + let converted_back: Result = ffi_endpoint.try_into(); + assert!(converted_back.is_ok()); + } + + #[test] + fn test_invalid_protected_endpoint_method() { + let invalid_endpoint = ProtectedEndpoint { + method: "INVALID".to_string(), + path: "/v1/swap".to_string(), + }; + + let result: Result = invalid_endpoint.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_protected_endpoint_path() { + let invalid_endpoint = ProtectedEndpoint { + method: "POST".to_string(), + path: "/invalid/path".to_string(), + }; + + let result: Result = invalid_endpoint.try_into(); + assert!(result.is_err()); + } +} diff --git a/crates/cdk-ffi/src/types/mod.rs b/crates/cdk-ffi/src/types/mod.rs new file mode 100644 index 000000000..e428b8fa2 --- /dev/null +++ b/crates/cdk-ffi/src/types/mod.rs @@ -0,0 +1,26 @@ +//! FFI-compatible types +//! +//! This module contains all the FFI types used by the UniFFI bindings. +//! Types are organized into logical submodules for better maintainability. + +// Module declarations +pub mod amount; +pub mod invoice; +pub mod keys; +pub mod mint; +pub mod proof; +pub mod quote; +pub mod subscription; +pub mod transaction; +pub mod wallet; + +// Re-export all types for convenient access +pub use amount::*; +pub use invoice::*; +pub use keys::*; +pub use mint::*; +pub use proof::*; +pub use quote::*; +pub use subscription::*; +pub use transaction::*; +pub use wallet::*; diff --git a/crates/cdk-ffi/src/types/proof.rs b/crates/cdk-ffi/src/types/proof.rs new file mode 100644 index 000000000..b5db3f97f --- /dev/null +++ b/crates/cdk-ffi/src/types/proof.rs @@ -0,0 +1,560 @@ +//! Proof-related FFI types + +use std::str::FromStr; + +use cdk::nuts::State as CdkState; +use serde::{Deserialize, Serialize}; + +use super::amount::{Amount, CurrencyUnit}; +use super::mint::MintUrl; +use crate::error::FfiError; + +/// FFI-compatible Proof state +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum ProofState { + Unspent, + Pending, + Spent, + Reserved, + PendingSpent, +} + +impl From for ProofState { + fn from(state: CdkState) -> Self { + match state { + CdkState::Unspent => ProofState::Unspent, + CdkState::Pending => ProofState::Pending, + CdkState::Spent => ProofState::Spent, + CdkState::Reserved => ProofState::Reserved, + CdkState::PendingSpent => ProofState::PendingSpent, + } + } +} + +impl From for CdkState { + fn from(state: ProofState) -> Self { + match state { + ProofState::Unspent => CdkState::Unspent, + ProofState::Pending => CdkState::Pending, + ProofState::Spent => CdkState::Spent, + ProofState::Reserved => CdkState::Reserved, + ProofState::PendingSpent => CdkState::PendingSpent, + } + } +} + +/// FFI-compatible Proof +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct 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, + /// Optional DLEQ proof + pub dleq: Option, +} + +impl From for Proof { + fn from(proof: cdk::nuts::Proof) -> Self { + 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 TryFrom for cdk::nuts::Proof { + type Error = FfiError; + + fn try_from(proof: Proof) -> Result { + use std::str::FromStr; + + use cdk::nuts::Id; + + 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 Y value (hash_to_curve of secret) for a proof +#[uniffi::export] +pub fn proof_y(proof: &Proof) -> Result { + // Convert to CDK proof to calculate Y + let cdk_proof: cdk::nuts::Proof = proof.clone().try_into()?; + Ok(cdk_proof.y()?.to_string()) +} + +/// Check if proof is active with given keyset IDs +#[uniffi::export] +pub fn proof_is_active(proof: &Proof, active_keyset_ids: Vec) -> bool { + use cdk::nuts::Id; + let ids: Vec = 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 has DLEQ proof +#[uniffi::export] +pub fn proof_has_dleq(proof: &Proof) -> bool { + proof.dleq.is_some() +} + +/// 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() }) +} + +/// 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 { + 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() })?; + + 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; + +/// FFI-compatible DLEQ proof for proofs +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ProofDleq { + /// e value (hex-encoded SecretKey) + pub e: String, + /// s value (hex-encoded SecretKey) + pub s: String, + /// r value - blinding factor (hex-encoded SecretKey) + pub r: String, +} + +/// FFI-compatible DLEQ proof for blind signatures +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct BlindSignatureDleq { + /// e value (hex-encoded SecretKey) + pub e: String, + /// s value (hex-encoded SecretKey) + pub s: String, +} + +impl From for ProofDleq { + fn from(dleq: cdk::nuts::ProofDleq) -> Self { + Self { + e: dleq.e.to_secret_hex(), + s: dleq.s.to_secret_hex(), + r: dleq.r.to_secret_hex(), + } + } +} + +impl From for cdk::nuts::ProofDleq { + fn from(dleq: ProofDleq) -> Self { + Self { + e: cdk::nuts::SecretKey::from_hex(&dleq.e).expect("Invalid e hex"), + s: cdk::nuts::SecretKey::from_hex(&dleq.s).expect("Invalid s hex"), + r: cdk::nuts::SecretKey::from_hex(&dleq.r).expect("Invalid r hex"), + } + } +} + +impl From for BlindSignatureDleq { + fn from(dleq: cdk::nuts::BlindSignatureDleq) -> Self { + Self { + e: dleq.e.to_secret_hex(), + s: dleq.s.to_secret_hex(), + } + } +} + +impl From for cdk::nuts::BlindSignatureDleq { + fn from(dleq: BlindSignatureDleq) -> Self { + Self { + e: cdk::nuts::SecretKey::from_hex(&dleq.e).expect("Invalid e hex"), + s: cdk::nuts::SecretKey::from_hex(&dleq.s).expect("Invalid s hex"), + } + } +} + +/// Helper function to calculate total amount of proofs +#[uniffi::export] +pub fn proofs_total_amount(proofs: &Proofs) -> Result { + let cdk_proofs: Result, _> = + proofs.iter().map(|p| p.clone().try_into()).collect(); + let cdk_proofs = cdk_proofs?; + use cdk::nuts::ProofsMethods; + Ok(cdk_proofs.total_amount()?.into()) +} + +/// FFI-compatible Conditions (for spending conditions) +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct Conditions { + /// Unix locktime after which refund keys can be used + pub locktime: Option, + /// Additional Public keys (as hex strings) + pub pubkeys: Vec, + /// Refund keys (as hex strings) + pub refund_keys: Vec, + /// Number of signatures required (default 1) + pub num_sigs: Option, + /// Signature flag (0 = SigInputs, 1 = SigAll) + pub sig_flag: u8, + /// Number of refund signatures required (default 1) + pub num_sigs_refund: Option, +} + +impl From for Conditions { + fn from(conditions: cdk::nuts::nut11::Conditions) -> Self { + Self { + locktime: conditions.locktime, + pubkeys: conditions + .pubkeys + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string()) + .collect(), + refund_keys: conditions + .refund_keys + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string()) + .collect(), + num_sigs: conditions.num_sigs, + sig_flag: match conditions.sig_flag { + cdk::nuts::nut11::SigFlag::SigInputs => 0, + cdk::nuts::nut11::SigFlag::SigAll => 1, + }, + num_sigs_refund: conditions.num_sigs_refund, + } + } +} + +impl TryFrom for cdk::nuts::nut11::Conditions { + type Error = FfiError; + + fn try_from(conditions: Conditions) -> Result { + let pubkeys = if conditions.pubkeys.is_empty() { + None + } else { + Some( + conditions + .pubkeys + .into_iter() + .map(|s| { + s.parse().map_err(|e| FfiError::InvalidCryptographicKey { + msg: format!("Invalid pubkey: {}", e), + }) + }) + .collect::, _>>()?, + ) + }; + + let refund_keys = if conditions.refund_keys.is_empty() { + None + } else { + Some( + conditions + .refund_keys + .into_iter() + .map(|s| { + s.parse().map_err(|e| FfiError::InvalidCryptographicKey { + msg: format!("Invalid refund key: {}", e), + }) + }) + .collect::, _>>()?, + ) + }; + + let sig_flag = match conditions.sig_flag { + 0 => cdk::nuts::nut11::SigFlag::SigInputs, + 1 => cdk::nuts::nut11::SigFlag::SigAll, + _ => { + return Err(FfiError::Generic { + msg: "Invalid sig_flag value".to_string(), + }) + } + }; + + Ok(Self { + locktime: conditions.locktime, + pubkeys, + refund_keys, + num_sigs: conditions.num_sigs, + sig_flag, + num_sigs_refund: conditions.num_sigs_refund, + }) + } +} + +impl Conditions { + /// Convert Conditions to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode Conditions from JSON string +#[uniffi::export] +pub fn decode_conditions(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode Conditions to JSON string +#[uniffi::export] +pub fn encode_conditions(conditions: Conditions) -> Result { + Ok(serde_json::to_string(&conditions)?) +} + +/// FFI-compatible Witness +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +pub enum Witness { + /// P2PK Witness + P2PK { + /// Signatures + signatures: Vec, + }, + /// HTLC Witness + HTLC { + /// Preimage + preimage: String, + /// Optional signatures + signatures: Option>, + }, +} + +impl From for Witness { + fn from(witness: cdk::nuts::Witness) -> Self { + match witness { + cdk::nuts::Witness::P2PKWitness(p2pk) => Self::P2PK { + signatures: p2pk.signatures, + }, + cdk::nuts::Witness::HTLCWitness(htlc) => Self::HTLC { + preimage: htlc.preimage, + signatures: htlc.signatures, + }, + } + } +} + +impl From for cdk::nuts::Witness { + fn from(witness: Witness) -> Self { + match witness { + Witness::P2PK { signatures } => { + Self::P2PKWitness(cdk::nuts::nut11::P2PKWitness { signatures }) + } + Witness::HTLC { + preimage, + signatures, + } => Self::HTLCWitness(cdk::nuts::nut14::HTLCWitness { + preimage, + signatures, + }), + } + } +} + +/// FFI-compatible SpendingConditions +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +pub enum SpendingConditions { + /// P2PK (Pay to Public Key) conditions + P2PK { + /// The public key (as hex string) + pubkey: String, + /// Additional conditions + conditions: Option, + }, + /// HTLC (Hash Time Locked Contract) conditions + HTLC { + /// Hash of the preimage (as hex string) + hash: String, + /// Additional conditions + conditions: Option, + }, +} + +impl From for SpendingConditions { + fn from(spending_conditions: cdk::nuts::SpendingConditions) -> Self { + match spending_conditions { + cdk::nuts::SpendingConditions::P2PKConditions { data, conditions } => Self::P2PK { + pubkey: data.to_string(), + conditions: conditions.map(Into::into), + }, + cdk::nuts::SpendingConditions::HTLCConditions { data, conditions } => Self::HTLC { + hash: data.to_string(), + conditions: conditions.map(Into::into), + }, + } + } +} + +impl TryFrom for cdk::nuts::SpendingConditions { + type Error = FfiError; + + fn try_from(spending_conditions: SpendingConditions) -> Result { + match spending_conditions { + SpendingConditions::P2PK { pubkey, conditions } => { + let pubkey = pubkey + .parse() + .map_err(|e| FfiError::InvalidCryptographicKey { + msg: format!("Invalid pubkey: {}", e), + })?; + let conditions = conditions.map(|c| c.try_into()).transpose()?; + Ok(Self::P2PKConditions { + data: pubkey, + conditions, + }) + } + SpendingConditions::HTLC { hash, conditions } => { + let hash = hash + .parse() + .map_err(|e| FfiError::InvalidCryptographicKey { + msg: format!("Invalid hash: {}", e), + })?; + let conditions = conditions.map(|c| c.try_into()).transpose()?; + Ok(Self::HTLCConditions { + data: hash, + conditions, + }) + } + } + } +} + +/// FFI-compatible ProofInfo +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProofInfo { + /// Proof + pub proof: Proof, + /// Y value (hash_to_curve of secret) + pub y: super::keys::PublicKey, + /// Mint URL + pub mint_url: MintUrl, + /// Proof state + pub state: ProofState, + /// Proof Spending Conditions + pub spending_condition: Option, + /// Currency unit + pub unit: CurrencyUnit, +} + +impl From for ProofInfo { + fn from(info: cdk::types::ProofInfo) -> Self { + Self { + proof: info.proof.into(), + y: info.y.into(), + mint_url: info.mint_url.into(), + state: info.state.into(), + spending_condition: info.spending_condition.map(Into::into), + unit: info.unit.into(), + } + } +} + +/// Decode ProofInfo from JSON string +#[uniffi::export] +pub fn decode_proof_info(json: String) -> Result { + let info: cdk::types::ProofInfo = serde_json::from_str(&json)?; + Ok(info.into()) +} + +/// Encode ProofInfo to JSON string +#[uniffi::export] +pub fn encode_proof_info(info: ProofInfo) -> Result { + // Convert to cdk::types::ProofInfo for serialization + let cdk_info = cdk::types::ProofInfo { + proof: info.proof.try_into()?, + y: info.y.try_into()?, + mint_url: info.mint_url.try_into()?, + state: info.state.into(), + spending_condition: info.spending_condition.and_then(|c| c.try_into().ok()), + unit: info.unit.into(), + }; + Ok(serde_json::to_string(&cdk_info)?) +} + +/// FFI-compatible ProofStateUpdate +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ProofStateUpdate { + /// Y value (hash_to_curve of secret) + pub y: String, + /// Current state + pub state: ProofState, + /// Optional witness data + pub witness: Option, +} + +impl From for ProofStateUpdate { + fn from(proof_state: cdk::nuts::nut07::ProofState) -> Self { + Self { + y: proof_state.y.to_string(), + state: proof_state.state.into(), + witness: proof_state.witness.map(|w| format!("{:?}", w)), + } + } +} + +impl ProofStateUpdate { + /// Convert ProofStateUpdate to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode ProofStateUpdate from JSON string +#[uniffi::export] +pub fn decode_proof_state_update(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode ProofStateUpdate to JSON string +#[uniffi::export] +pub fn encode_proof_state_update(update: ProofStateUpdate) -> Result { + Ok(serde_json::to_string(&update)?) +} diff --git a/crates/cdk-ffi/src/types/quote.rs b/crates/cdk-ffi/src/types/quote.rs new file mode 100644 index 000000000..7ee467094 --- /dev/null +++ b/crates/cdk-ffi/src/types/quote.rs @@ -0,0 +1,343 @@ +//! Quote-related FFI types + +use serde::{Deserialize, Serialize}; + +use super::amount::{Amount, CurrencyUnit}; +use super::mint::MintUrl; +use crate::error::FfiError; + +/// FFI-compatible MintQuote +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct MintQuote { + /// Quote ID + pub id: String, + /// Quote amount + pub amount: Option, + /// Currency unit + pub unit: CurrencyUnit, + /// Payment request + pub request: String, + /// Quote state + pub state: QuoteState, + /// Expiry timestamp + pub expiry: u64, + /// Mint URL + pub mint_url: MintUrl, + /// Amount issued + pub amount_issued: Amount, + /// Amount paid + pub amount_paid: Amount, + /// Payment method + pub payment_method: PaymentMethod, + /// Secret key (optional, hex-encoded) + pub secret_key: Option, +} + +impl From for MintQuote { + fn from(quote: cdk::wallet::MintQuote) -> Self { + Self { + id: quote.id.clone(), + amount: quote.amount.map(Into::into), + unit: quote.unit.clone().into(), + request: quote.request.clone(), + state: quote.state.into(), + expiry: quote.expiry, + mint_url: quote.mint_url.clone().into(), + amount_issued: quote.amount_issued.into(), + amount_paid: quote.amount_paid.into(), + payment_method: quote.payment_method.into(), + secret_key: quote.secret_key.map(|sk| sk.to_secret_hex()), + } + } +} + +impl TryFrom for cdk::wallet::MintQuote { + type Error = FfiError; + + fn try_from(quote: MintQuote) -> Result { + let secret_key = quote + .secret_key + .map(|hex| cdk::nuts::SecretKey::from_hex(&hex)) + .transpose() + .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?; + + Ok(Self { + id: quote.id, + amount: quote.amount.map(Into::into), + unit: quote.unit.into(), + request: quote.request, + state: quote.state.into(), + expiry: quote.expiry, + mint_url: quote.mint_url.try_into()?, + amount_issued: quote.amount_issued.into(), + amount_paid: quote.amount_paid.into(), + payment_method: quote.payment_method.into(), + secret_key, + }) + } +} + +/// Get total amount for a mint quote (amount paid) +#[uniffi::export] +pub fn mint_quote_total_amount(quote: &MintQuote) -> Result { + let cdk_quote: cdk::wallet::MintQuote = quote.clone().try_into()?; + Ok(cdk_quote.total_amount().into()) +} + +/// Check if mint quote is expired +#[uniffi::export] +pub fn mint_quote_is_expired(quote: &MintQuote, current_time: u64) -> Result { + let cdk_quote: cdk::wallet::MintQuote = quote.clone().try_into()?; + Ok(cdk_quote.is_expired(current_time)) +} + +/// Get amount that can be minted from a mint quote +#[uniffi::export] +pub fn mint_quote_amount_mintable(quote: &MintQuote) -> Result { + let cdk_quote: cdk::wallet::MintQuote = quote.clone().try_into()?; + Ok(cdk_quote.amount_mintable().into()) +} + +/// Decode MintQuote from JSON string +#[uniffi::export] +pub fn decode_mint_quote(json: String) -> Result { + let quote: cdk::wallet::MintQuote = serde_json::from_str(&json)?; + Ok(quote.into()) +} + +/// Encode MintQuote to JSON string +#[uniffi::export] +pub fn encode_mint_quote(quote: MintQuote) -> Result { + Ok(serde_json::to_string("e)?) +} + +/// FFI-compatible MintQuoteBolt11Response +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct MintQuoteBolt11Response { + /// Quote ID + pub quote: String, + /// Request string + pub request: String, + /// State of the quote + pub state: QuoteState, + /// Expiry timestamp (optional) + pub expiry: Option, + /// Amount (optional) + pub amount: Option, + /// Unit (optional) + pub unit: Option, + /// Pubkey (optional) + pub pubkey: Option, +} + +impl From> for MintQuoteBolt11Response { + fn from(response: cdk::nuts::MintQuoteBolt11Response) -> Self { + Self { + quote: response.quote, + request: response.request, + state: response.state.into(), + expiry: response.expiry, + amount: response.amount.map(Into::into), + unit: response.unit.map(Into::into), + pubkey: response.pubkey.map(|p| p.to_string()), + } + } +} + +/// FFI-compatible MeltQuoteBolt11Response +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct MeltQuoteBolt11Response { + /// Quote ID + pub quote: String, + /// Amount + pub amount: Amount, + /// Fee reserve + pub fee_reserve: Amount, + /// State of the quote + pub state: QuoteState, + /// Expiry timestamp + pub expiry: u64, + /// Payment preimage (optional) + pub payment_preimage: Option, + /// Request string (optional) + pub request: Option, + /// Unit (optional) + pub unit: Option, +} + +impl From> for MeltQuoteBolt11Response { + fn from(response: cdk::nuts::MeltQuoteBolt11Response) -> Self { + Self { + quote: response.quote, + amount: response.amount.into(), + fee_reserve: response.fee_reserve.into(), + state: response.state.into(), + expiry: response.expiry, + payment_preimage: response.payment_preimage, + request: response.request, + unit: response.unit.map(Into::into), + } + } +} +/// FFI-compatible PaymentMethod +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum PaymentMethod { + /// Bolt11 payment type + Bolt11, + /// Bolt12 payment type + Bolt12, + /// Custom payment type + Custom { method: String }, +} + +impl From for PaymentMethod { + fn from(method: cdk::nuts::PaymentMethod) -> Self { + match method { + cdk::nuts::PaymentMethod::Bolt11 => Self::Bolt11, + cdk::nuts::PaymentMethod::Bolt12 => Self::Bolt12, + cdk::nuts::PaymentMethod::Custom(s) => Self::Custom { method: s }, + } + } +} + +impl From for cdk::nuts::PaymentMethod { + fn from(method: PaymentMethod) -> Self { + match method { + PaymentMethod::Bolt11 => Self::Bolt11, + PaymentMethod::Bolt12 => Self::Bolt12, + PaymentMethod::Custom { method } => Self::Custom(method), + } + } +} + +/// FFI-compatible MeltQuote +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct MeltQuote { + /// Quote ID + pub id: String, + /// Quote amount + pub amount: Amount, + /// Currency unit + pub unit: CurrencyUnit, + /// Payment request + pub request: String, + /// Fee reserve + pub fee_reserve: Amount, + /// Quote state + pub state: QuoteState, + /// Expiry timestamp + pub expiry: u64, + /// Payment preimage + pub payment_preimage: Option, + /// Payment method + pub payment_method: PaymentMethod, +} + +impl From for MeltQuote { + fn from(quote: cdk::wallet::MeltQuote) -> Self { + Self { + id: quote.id.clone(), + amount: quote.amount.into(), + unit: quote.unit.clone().into(), + request: quote.request.clone(), + fee_reserve: quote.fee_reserve.into(), + state: quote.state.into(), + expiry: quote.expiry, + payment_preimage: quote.payment_preimage.clone(), + payment_method: quote.payment_method.into(), + } + } +} + +impl TryFrom for cdk::wallet::MeltQuote { + type Error = FfiError; + + fn try_from(quote: MeltQuote) -> Result { + Ok(Self { + id: quote.id, + amount: quote.amount.into(), + unit: quote.unit.into(), + request: quote.request, + fee_reserve: quote.fee_reserve.into(), + state: quote.state.into(), + expiry: quote.expiry, + payment_preimage: quote.payment_preimage, + payment_method: quote.payment_method.into(), + }) + } +} + +impl MeltQuote { + /// Convert MeltQuote to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode MeltQuote from JSON string +#[uniffi::export] +pub fn decode_melt_quote(json: String) -> Result { + let quote: cdk::wallet::MeltQuote = serde_json::from_str(&json)?; + Ok(quote.into()) +} + +/// Encode MeltQuote to JSON string +#[uniffi::export] +pub fn encode_melt_quote(quote: MeltQuote) -> Result { + Ok(serde_json::to_string("e)?) +} + +/// FFI-compatible QuoteState +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum QuoteState { + Unpaid, + Paid, + Pending, + Issued, +} + +impl From for QuoteState { + fn from(state: cdk::nuts::nut05::QuoteState) -> Self { + match state { + cdk::nuts::nut05::QuoteState::Unpaid => QuoteState::Unpaid, + cdk::nuts::nut05::QuoteState::Paid => QuoteState::Paid, + cdk::nuts::nut05::QuoteState::Pending => QuoteState::Pending, + cdk::nuts::nut05::QuoteState::Unknown => QuoteState::Unpaid, + cdk::nuts::nut05::QuoteState::Failed => QuoteState::Unpaid, + } + } +} + +impl From for cdk::nuts::nut05::QuoteState { + fn from(state: QuoteState) -> Self { + match state { + QuoteState::Unpaid => cdk::nuts::nut05::QuoteState::Unpaid, + QuoteState::Paid => cdk::nuts::nut05::QuoteState::Paid, + QuoteState::Pending => cdk::nuts::nut05::QuoteState::Pending, + QuoteState::Issued => cdk::nuts::nut05::QuoteState::Paid, // Map issued to paid for melt quotes + } + } +} + +impl From for QuoteState { + fn from(state: cdk::nuts::MintQuoteState) -> Self { + match state { + cdk::nuts::MintQuoteState::Unpaid => QuoteState::Unpaid, + cdk::nuts::MintQuoteState::Paid => QuoteState::Paid, + cdk::nuts::MintQuoteState::Issued => QuoteState::Issued, + } + } +} + +impl From for cdk::nuts::MintQuoteState { + fn from(state: QuoteState) -> Self { + match state { + QuoteState::Unpaid => cdk::nuts::MintQuoteState::Unpaid, + QuoteState::Paid => cdk::nuts::MintQuoteState::Paid, + QuoteState::Issued => cdk::nuts::MintQuoteState::Issued, + QuoteState::Pending => cdk::nuts::MintQuoteState::Paid, // Map pending to paid + } + } +} + +// Note: MeltQuoteState is the same as nut05::QuoteState, so we don't need a separate impl diff --git a/crates/cdk-ffi/src/types/subscription.rs b/crates/cdk-ffi/src/types/subscription.rs new file mode 100644 index 000000000..b4a0aed9a --- /dev/null +++ b/crates/cdk-ffi/src/types/subscription.rs @@ -0,0 +1,171 @@ +//! Subscription-related FFI types +use std::sync::Arc; + +use cdk::event::MintEvent; +use serde::{Deserialize, Serialize}; + +use super::proof::ProofStateUpdate; +use super::quote::{MeltQuoteBolt11Response, MintQuoteBolt11Response}; +use crate::error::FfiError; + +/// FFI-compatible SubscriptionKind +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)] +pub enum SubscriptionKind { + /// Bolt 11 Melt Quote + Bolt11MeltQuote, + /// Bolt 11 Mint Quote + Bolt11MintQuote, + /// Bolt 12 Mint Quote + Bolt12MintQuote, + /// Proof State + ProofState, +} + +impl From for cdk::nuts::nut17::Kind { + fn from(kind: SubscriptionKind) -> Self { + match kind { + SubscriptionKind::Bolt11MeltQuote => cdk::nuts::nut17::Kind::Bolt11MeltQuote, + SubscriptionKind::Bolt11MintQuote => cdk::nuts::nut17::Kind::Bolt11MintQuote, + SubscriptionKind::Bolt12MintQuote => cdk::nuts::nut17::Kind::Bolt12MintQuote, + SubscriptionKind::ProofState => cdk::nuts::nut17::Kind::ProofState, + } + } +} + +impl From for SubscriptionKind { + fn from(kind: cdk::nuts::nut17::Kind) -> Self { + match kind { + cdk::nuts::nut17::Kind::Bolt11MeltQuote => SubscriptionKind::Bolt11MeltQuote, + cdk::nuts::nut17::Kind::Bolt11MintQuote => SubscriptionKind::Bolt11MintQuote, + cdk::nuts::nut17::Kind::Bolt12MintQuote => SubscriptionKind::Bolt12MintQuote, + cdk::nuts::nut17::Kind::ProofState => SubscriptionKind::ProofState, + } + } +} + +/// FFI-compatible SubscribeParams +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct SubscribeParams { + /// Subscription kind + pub kind: SubscriptionKind, + /// Filters + pub filters: Vec, + /// Subscription ID (optional, will be generated if not provided) + pub id: Option, +} + +impl From for cdk::nuts::nut17::Params> { + fn from(params: SubscribeParams) -> Self { + let sub_id = params.id.unwrap_or_else(|| { + // Generate a random ID + uuid::Uuid::new_v4().to_string() + }); + + cdk::nuts::nut17::Params { + kind: params.kind.into(), + filters: params.filters, + id: Arc::new(sub_id), + } + } +} + +impl SubscribeParams { + /// Convert SubscribeParams to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode SubscribeParams from JSON string +#[uniffi::export] +pub fn decode_subscribe_params(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode SubscribeParams to JSON string +#[uniffi::export] +pub fn encode_subscribe_params(params: SubscribeParams) -> Result { + Ok(serde_json::to_string(¶ms)?) +} + +/// FFI-compatible ActiveSubscription +#[derive(uniffi::Object)] +pub struct ActiveSubscription { + inner: std::sync::Arc>, + pub sub_id: String, +} + +impl ActiveSubscription { + pub(crate) fn new( + inner: cdk::wallet::subscription::ActiveSubscription, + sub_id: String, + ) -> Self { + Self { + inner: std::sync::Arc::new(tokio::sync::Mutex::new(inner)), + sub_id, + } + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl ActiveSubscription { + /// Get the subscription ID + pub fn id(&self) -> String { + self.sub_id.clone() + } + + /// Receive the next notification + pub async fn recv(&self) -> Result { + let mut guard = self.inner.lock().await; + guard + .recv() + .await + .ok_or(FfiError::Generic { + msg: "Subscription closed".to_string(), + }) + .map(Into::into) + } + + /// Try to receive a notification without blocking + pub async fn try_recv(&self) -> Result, FfiError> { + let mut guard = self.inner.lock().await; + Ok(guard.try_recv().map(Into::into)) + } +} + +/// FFI-compatible NotificationPayload +#[derive(Debug, Clone, uniffi::Enum)] +pub enum NotificationPayload { + /// Proof state update + ProofState { proof_states: Vec }, + /// Mint quote update + MintQuoteUpdate { quote: MintQuoteBolt11Response }, + /// Melt quote update + MeltQuoteUpdate { quote: MeltQuoteBolt11Response }, +} + +impl From> for NotificationPayload { + fn from(payload: MintEvent) -> Self { + match payload.into() { + cdk::nuts::NotificationPayload::ProofState(states) => NotificationPayload::ProofState { + proof_states: vec![states.into()], + }, + cdk::nuts::NotificationPayload::MintQuoteBolt11Response(quote_resp) => { + NotificationPayload::MintQuoteUpdate { + quote: quote_resp.into(), + } + } + cdk::nuts::NotificationPayload::MeltQuoteBolt11Response(quote_resp) => { + NotificationPayload::MeltQuoteUpdate { + quote: quote_resp.into(), + } + } + _ => { + // For now, handle other notification types as empty ProofState + NotificationPayload::ProofState { + proof_states: vec![], + } + } + } + } +} diff --git a/crates/cdk-ffi/src/types/transaction.rs b/crates/cdk-ffi/src/types/transaction.rs new file mode 100644 index 000000000..39c0aa216 --- /dev/null +++ b/crates/cdk-ffi/src/types/transaction.rs @@ -0,0 +1,272 @@ +//! Transaction-related FFI types + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::amount::{Amount, CurrencyUnit}; +use super::keys::PublicKey; +use super::mint::MintUrl; +use super::proof::Proofs; +use crate::error::FfiError; + +/// FFI-compatible Transaction +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct Transaction { + /// Transaction ID + pub id: TransactionId, + /// Mint URL + pub mint_url: MintUrl, + /// Transaction direction + pub direction: TransactionDirection, + /// Amount + pub amount: Amount, + /// Fee + pub fee: Amount, + /// Currency Unit + pub unit: CurrencyUnit, + /// Proof Ys (Y values from proofs) + pub ys: Vec, + /// Unix timestamp + pub timestamp: u64, + /// Memo + pub memo: Option, + /// User-defined metadata + pub metadata: HashMap, + /// Quote ID if this is a mint or melt transaction + pub quote_id: Option, + /// Payment request (e.g., BOLT11 invoice, BOLT12 offer) + pub payment_request: Option, + /// Payment proof (e.g., preimage for Lightning melt transactions) + pub payment_proof: Option, +} + +impl From for Transaction { + fn from(tx: cdk::wallet::types::Transaction) -> Self { + Self { + id: tx.id().into(), + mint_url: tx.mint_url.into(), + direction: tx.direction.into(), + amount: tx.amount.into(), + fee: tx.fee.into(), + unit: tx.unit.into(), + ys: tx.ys.into_iter().map(Into::into).collect(), + timestamp: tx.timestamp, + memo: tx.memo, + metadata: tx.metadata, + quote_id: tx.quote_id, + payment_request: tx.payment_request, + payment_proof: tx.payment_proof, + } + } +} + +/// Convert FFI Transaction to CDK Transaction +impl TryFrom for cdk::wallet::types::Transaction { + type Error = FfiError; + + fn try_from(tx: Transaction) -> Result { + let cdk_ys: Result, _> = + tx.ys.into_iter().map(|pk| pk.try_into()).collect(); + let cdk_ys = cdk_ys?; + + Ok(Self { + mint_url: tx.mint_url.try_into()?, + direction: tx.direction.into(), + amount: tx.amount.into(), + fee: tx.fee.into(), + unit: tx.unit.into(), + ys: cdk_ys, + timestamp: tx.timestamp, + memo: tx.memo, + metadata: tx.metadata, + quote_id: tx.quote_id, + payment_request: tx.payment_request, + payment_proof: tx.payment_proof, + }) + } +} + +impl Transaction { + /// Convert Transaction to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode Transaction from JSON string +#[uniffi::export] +pub fn decode_transaction(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode Transaction to JSON string +#[uniffi::export] +pub fn encode_transaction(transaction: Transaction) -> Result { + 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, + direction: Option, + unit: Option, +) -> Result { + 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 { + /// Incoming transaction (i.e., receive or mint) + Incoming, + /// Outgoing transaction (i.e., send or melt) + Outgoing, +} + +impl From for TransactionDirection { + fn from(direction: cdk::wallet::types::TransactionDirection) -> Self { + match direction { + cdk::wallet::types::TransactionDirection::Incoming => TransactionDirection::Incoming, + cdk::wallet::types::TransactionDirection::Outgoing => TransactionDirection::Outgoing, + } + } +} + +impl From for cdk::wallet::types::TransactionDirection { + fn from(direction: TransactionDirection) -> Self { + match direction { + TransactionDirection::Incoming => cdk::wallet::types::TransactionDirection::Incoming, + TransactionDirection::Outgoing => cdk::wallet::types::TransactionDirection::Outgoing, + } + } +} + +/// FFI-compatible TransactionId +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct TransactionId { + /// Hex-encoded transaction ID (64 characters) + pub hex: String, +} + +impl TransactionId { + /// Create a new TransactionId from hex string + pub fn from_hex(hex: String) -> Result { + // Validate hex string length (should be 64 characters for 32 bytes) + if hex.len() != 64 { + return Err(FfiError::InvalidHex { + msg: "Transaction ID hex must be exactly 64 characters (32 bytes)".to_string(), + }); + } + + // Validate hex format + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(FfiError::InvalidHex { + msg: "Transaction ID hex contains invalid characters".to_string(), + }); + } + + Ok(Self { hex }) + } + + /// Create from proofs + pub fn from_proofs(proofs: &Proofs) -> Result { + let cdk_proofs: Result, _> = + 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(), + }) + } +} + +impl From for TransactionId { + fn from(id: cdk::wallet::types::TransactionId) -> Self { + Self { + hex: id.to_string(), + } + } +} + +impl TryFrom for cdk::wallet::types::TransactionId { + type Error = FfiError; + + fn try_from(id: TransactionId) -> Result { + cdk::wallet::types::TransactionId::from_hex(&id.hex) + .map_err(|e| FfiError::InvalidHex { msg: e.to_string() }) + } +} + +/// FFI-compatible AuthProof +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct AuthProof { + /// Keyset ID + pub keyset_id: String, + /// Secret message + pub secret: String, + /// Unblinded signature (C) + pub c: String, + /// Y value (hash_to_curve of secret) + pub y: String, +} + +impl From for AuthProof { + fn from(auth_proof: cdk::nuts::AuthProof) -> Self { + Self { + keyset_id: auth_proof.keyset_id.to_string(), + secret: auth_proof.secret.to_string(), + c: auth_proof.c.to_string(), + y: auth_proof + .y() + .map(|y| y.to_string()) + .unwrap_or_else(|_| "".to_string()), + } + } +} + +impl TryFrom for cdk::nuts::AuthProof { + type Error = FfiError; + + fn try_from(auth_proof: AuthProof) -> Result { + use std::str::FromStr; + Ok(Self { + keyset_id: cdk::nuts::Id::from_str(&auth_proof.keyset_id) + .map_err(|e| FfiError::Serialization { msg: e.to_string() })?, + secret: { + use std::str::FromStr; + cdk::secret::Secret::from_str(&auth_proof.secret) + .map_err(|e| FfiError::Serialization { msg: e.to_string() })? + }, + c: cdk::nuts::PublicKey::from_str(&auth_proof.c) + .map_err(|e| FfiError::InvalidCryptographicKey { msg: e.to_string() })?, + dleq: None, // FFI doesn't expose DLEQ proofs for simplicity + }) + } +} + +impl AuthProof { + /// Convert AuthProof to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode AuthProof from JSON string +#[uniffi::export] +pub fn decode_auth_proof(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode AuthProof to JSON string +#[uniffi::export] +pub fn encode_auth_proof(proof: AuthProof) -> Result { + Ok(serde_json::to_string(&proof)?) +} diff --git a/crates/cdk-ffi/src/types/wallet.rs b/crates/cdk-ffi/src/types/wallet.rs new file mode 100644 index 000000000..d9b6928a9 --- /dev/null +++ b/crates/cdk-ffi/src/types/wallet.rs @@ -0,0 +1,468 @@ +//! Wallet-related FFI types + +use std::collections::HashMap; +use std::sync::Mutex; + +use serde::{Deserialize, Serialize}; + +use super::amount::{Amount, SplitTarget}; +use super::proof::{Proofs, SpendingConditions}; +use crate::error::FfiError; +use crate::token::Token; + +/// FFI-compatible SendMemo +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct SendMemo { + /// Memo text + pub memo: String, + /// Include memo in token + pub include_memo: bool, +} + +impl From for cdk::wallet::SendMemo { + fn from(memo: SendMemo) -> Self { + cdk::wallet::SendMemo { + memo: memo.memo, + include_memo: memo.include_memo, + } + } +} + +impl From for SendMemo { + fn from(memo: cdk::wallet::SendMemo) -> Self { + Self { + memo: memo.memo, + include_memo: memo.include_memo, + } + } +} + +impl SendMemo { + /// Convert SendMemo to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode SendMemo from JSON string +#[uniffi::export] +pub fn decode_send_memo(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode SendMemo to JSON string +#[uniffi::export] +pub fn encode_send_memo(memo: SendMemo) -> Result { + Ok(serde_json::to_string(&memo)?) +} + +/// FFI-compatible SendKind +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +pub enum SendKind { + /// Allow online swap before send if wallet does not have exact amount + OnlineExact, + /// Prefer offline send if difference is less than tolerance + OnlineTolerance { tolerance: Amount }, + /// Wallet cannot do an online swap and selected proof must be exactly send amount + OfflineExact, + /// Wallet must remain offline but can over pay if below tolerance + OfflineTolerance { tolerance: Amount }, +} + +impl From for cdk::wallet::SendKind { + fn from(kind: SendKind) -> Self { + match kind { + SendKind::OnlineExact => cdk::wallet::SendKind::OnlineExact, + SendKind::OnlineTolerance { tolerance } => { + cdk::wallet::SendKind::OnlineTolerance(tolerance.into()) + } + SendKind::OfflineExact => cdk::wallet::SendKind::OfflineExact, + SendKind::OfflineTolerance { tolerance } => { + cdk::wallet::SendKind::OfflineTolerance(tolerance.into()) + } + } + } +} + +impl From for SendKind { + fn from(kind: cdk::wallet::SendKind) -> Self { + match kind { + cdk::wallet::SendKind::OnlineExact => SendKind::OnlineExact, + cdk::wallet::SendKind::OnlineTolerance(tolerance) => SendKind::OnlineTolerance { + tolerance: tolerance.into(), + }, + cdk::wallet::SendKind::OfflineExact => SendKind::OfflineExact, + cdk::wallet::SendKind::OfflineTolerance(tolerance) => SendKind::OfflineTolerance { + tolerance: tolerance.into(), + }, + } + } +} + +/// FFI-compatible Send options +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct SendOptions { + /// Memo + pub memo: Option, + /// Spending conditions + pub conditions: Option, + /// Amount split target + pub amount_split_target: SplitTarget, + /// Send kind + pub send_kind: SendKind, + /// Include fee + pub include_fee: bool, + /// Maximum number of proofs to include in the token + pub max_proofs: Option, + /// Metadata + pub metadata: HashMap, +} + +impl Default for SendOptions { + fn default() -> Self { + Self { + memo: None, + conditions: None, + amount_split_target: SplitTarget::None, + send_kind: SendKind::OnlineExact, + include_fee: false, + max_proofs: None, + metadata: HashMap::new(), + } + } +} + +impl From for cdk::wallet::SendOptions { + fn from(opts: SendOptions) -> Self { + cdk::wallet::SendOptions { + memo: opts.memo.map(Into::into), + conditions: opts.conditions.and_then(|c| c.try_into().ok()), + amount_split_target: opts.amount_split_target.into(), + send_kind: opts.send_kind.into(), + include_fee: opts.include_fee, + max_proofs: opts.max_proofs.map(|p| p as usize), + metadata: opts.metadata, + } + } +} + +impl From for SendOptions { + fn from(opts: cdk::wallet::SendOptions) -> Self { + Self { + memo: opts.memo.map(Into::into), + conditions: opts.conditions.map(Into::into), + amount_split_target: opts.amount_split_target.into(), + send_kind: opts.send_kind.into(), + include_fee: opts.include_fee, + max_proofs: opts.max_proofs.map(|p| p as u32), + metadata: opts.metadata, + } + } +} + +impl SendOptions { + /// Convert SendOptions to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode SendOptions from JSON string +#[uniffi::export] +pub fn decode_send_options(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode SendOptions to JSON string +#[uniffi::export] +pub fn encode_send_options(options: SendOptions) -> Result { + Ok(serde_json::to_string(&options)?) +} + +/// FFI-compatible SecretKey +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +#[serde(transparent)] +pub struct SecretKey { + /// Hex-encoded secret key (64 characters) + pub hex: String, +} + +impl SecretKey { + /// Create a new SecretKey from hex string + pub fn from_hex(hex: String) -> Result { + // Validate hex string length (should be 64 characters for 32 bytes) + if hex.len() != 64 { + return Err(FfiError::InvalidHex { + msg: "Secret key hex must be exactly 64 characters (32 bytes)".to_string(), + }); + } + + // Validate hex format + if !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(FfiError::InvalidHex { + msg: "Secret key hex contains invalid characters".to_string(), + }); + } + + Ok(Self { hex }) + } + + /// Generate a random secret key + pub fn random() -> Self { + use cdk::nuts::SecretKey as CdkSecretKey; + let secret_key = CdkSecretKey::generate(); + Self { + hex: secret_key.to_secret_hex(), + } + } +} + +impl From for cdk::nuts::SecretKey { + fn from(key: SecretKey) -> Self { + // This will panic if hex is invalid, but we validate in from_hex() + cdk::nuts::SecretKey::from_hex(&key.hex).expect("Invalid secret key hex") + } +} + +impl From for SecretKey { + fn from(key: cdk::nuts::SecretKey) -> Self { + Self { + hex: key.to_secret_hex(), + } + } +} + +/// FFI-compatible Receive options +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct ReceiveOptions { + /// Amount split target + pub amount_split_target: SplitTarget, + /// P2PK signing keys + pub p2pk_signing_keys: Vec, + /// Preimages for HTLC conditions + pub preimages: Vec, + /// Metadata + pub metadata: HashMap, +} + +impl Default for ReceiveOptions { + fn default() -> Self { + Self { + amount_split_target: SplitTarget::None, + p2pk_signing_keys: Vec::new(), + preimages: Vec::new(), + metadata: HashMap::new(), + } + } +} + +impl From for cdk::wallet::ReceiveOptions { + fn from(opts: ReceiveOptions) -> Self { + cdk::wallet::ReceiveOptions { + amount_split_target: opts.amount_split_target.into(), + p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(), + preimages: opts.preimages, + metadata: opts.metadata, + } + } +} + +impl From for ReceiveOptions { + fn from(opts: cdk::wallet::ReceiveOptions) -> Self { + Self { + amount_split_target: opts.amount_split_target.into(), + p2pk_signing_keys: opts.p2pk_signing_keys.into_iter().map(Into::into).collect(), + preimages: opts.preimages, + metadata: opts.metadata, + } + } +} + +impl ReceiveOptions { + /// Convert ReceiveOptions to JSON string + pub fn to_json(&self) -> Result { + Ok(serde_json::to_string(self)?) + } +} + +/// Decode ReceiveOptions from JSON string +#[uniffi::export] +pub fn decode_receive_options(json: String) -> Result { + Ok(serde_json::from_str(&json)?) +} + +/// Encode ReceiveOptions to JSON string +#[uniffi::export] +pub fn encode_receive_options(options: ReceiveOptions) -> Result { + Ok(serde_json::to_string(&options)?) +} + +/// FFI-compatible PreparedSend +#[derive(Debug, uniffi::Object)] +pub struct PreparedSend { + inner: Mutex>, + id: String, + amount: Amount, + proofs: Proofs, +} + +impl From for PreparedSend { + fn from(prepared: cdk::wallet::PreparedSend) -> Self { + let id = format!("{:?}", prepared); // Use debug format as ID + let amount = prepared.amount().into(); + let proofs = prepared + .proofs() + .iter() + .cloned() + .map(|p| p.into()) + .collect(); + Self { + inner: Mutex::new(Some(prepared)), + id, + amount, + proofs, + } + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl PreparedSend { + /// Get the prepared send ID + pub fn id(&self) -> String { + self.id.clone() + } + + /// Get the amount to send + pub fn amount(&self) -> Amount { + self.amount + } + + /// Get the proofs that will be used + pub fn proofs(&self) -> Proofs { + self.proofs.clone() + } + + /// Get the total fee for this send operation + pub fn fee(&self) -> Amount { + if let Ok(guard) = self.inner.lock() { + if let Some(ref inner) = *guard { + inner.fee().into() + } else { + Amount::new(0) + } + } else { + Amount::new(0) + } + } + + /// Confirm the prepared send and create a token + pub async fn confirm( + self: std::sync::Arc, + memo: Option, + ) -> Result { + let inner = { + if let Ok(mut guard) = self.inner.lock() { + guard.take() + } else { + return Err(FfiError::Generic { + msg: "Failed to acquire lock on PreparedSend".to_string(), + }); + } + }; + + if let Some(inner) = inner { + let send_memo = memo.map(|m| cdk::wallet::SendMemo::for_token(&m)); + let token = inner.confirm(send_memo).await?; + Ok(token.into()) + } else { + Err(FfiError::Generic { + msg: "PreparedSend has already been consumed or cancelled".to_string(), + }) + } + } + + /// Cancel the prepared send operation + pub async fn cancel(self: std::sync::Arc) -> Result<(), FfiError> { + let inner = { + if let Ok(mut guard) = self.inner.lock() { + guard.take() + } else { + return Err(FfiError::Generic { + msg: "Failed to acquire lock on PreparedSend".to_string(), + }); + } + }; + + if let Some(inner) = inner { + inner.cancel().await?; + Ok(()) + } else { + Err(FfiError::Generic { + msg: "PreparedSend has already been consumed or cancelled".to_string(), + }) + } + } +} + +/// FFI-compatible Melted result +#[derive(Debug, Clone, uniffi::Record)] +pub struct Melted { + pub state: super::quote::QuoteState, + pub preimage: Option, + pub change: Option, + pub amount: Amount, + pub fee_paid: Amount, +} + +// MeltQuoteState is just an alias for nut05::QuoteState, so we don't need a separate implementation + +impl From for Melted { + fn from(melted: cdk::types::Melted) -> Self { + Self { + state: melted.state.into(), + preimage: melted.preimage, + change: melted + .change + .map(|proofs| proofs.into_iter().map(|p| p.into()).collect()), + amount: melted.amount.into(), + fee_paid: melted.fee_paid.into(), + } + } +} + +/// FFI-compatible MeltOptions +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Enum)] +pub enum MeltOptions { + /// MPP (Multi-Part Payments) options + Mpp { amount: Amount }, + /// Amountless options + Amountless { amount_msat: Amount }, +} + +impl From for cdk::nuts::MeltOptions { + fn from(opts: MeltOptions) -> Self { + match opts { + MeltOptions::Mpp { amount } => { + let cdk_amount: cdk::Amount = amount.into(); + cdk::nuts::MeltOptions::new_mpp(cdk_amount) + } + MeltOptions::Amountless { amount_msat } => { + let cdk_amount: cdk::Amount = amount_msat.into(); + cdk::nuts::MeltOptions::new_amountless(cdk_amount) + } + } + } +} + +impl From for MeltOptions { + fn from(opts: cdk::nuts::MeltOptions) -> Self { + match opts { + cdk::nuts::MeltOptions::Mpp { mpp } => MeltOptions::Mpp { + amount: mpp.amount.into(), + }, + cdk::nuts::MeltOptions::Amountless { amountless } => MeltOptions::Amountless { + amount_msat: amountless.amount_msat.into(), + }, + } + } +} diff --git a/crates/cdk-ffi/src/wallet.rs b/crates/cdk-ffi/src/wallet.rs new file mode 100644 index 000000000..e2549d8b2 --- /dev/null +++ b/crates/cdk-ffi/src/wallet.rs @@ -0,0 +1,595 @@ +//! FFI Wallet bindings + +use std::str::FromStr; +use std::sync::Arc; + +use bip39::Mnemonic; +use cdk::wallet::{Wallet as CdkWallet, WalletBuilder as CdkWalletBuilder}; + +use crate::error::FfiError; +use crate::token::Token; +use crate::types::*; + +/// FFI-compatible Wallet +#[derive(uniffi::Object)] +pub struct Wallet { + inner: Arc, +} + +impl Wallet { + /// Create a Wallet from an existing CDK wallet (internal use only) + pub(crate) fn from_inner(inner: Arc) -> Self { + Self { inner } + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl Wallet { + /// Create a new Wallet from mnemonic using WalletDatabase trait + #[uniffi::constructor] + pub fn new( + mint_url: String, + unit: CurrencyUnit, + mnemonic: String, + db: Arc, + config: WalletConfig, + ) -> Result { + // Parse mnemonic and generate seed without passphrase + let m = Mnemonic::parse(&mnemonic) + .map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + let seed = m.to_seed_normalized(""); + + // Convert the FFI database trait to a CDK database implementation + let localstore = crate::database::create_cdk_database_from_ffi(db); + + let wallet = + CdkWalletBuilder::new() + .mint_url(mint_url.parse().map_err(|e: cdk::mint_url::Error| { + FfiError::InvalidUrl { msg: e.to_string() } + })?) + .unit(unit.into()) + .localstore(localstore) + .seed(seed) + .target_proof_count(config.target_proof_count.unwrap_or(3) as usize) + .build() + .map_err(FfiError::from)?; + + Ok(Self { + inner: Arc::new(wallet), + }) + } + + /// Get the mint URL + pub fn mint_url(&self) -> MintUrl { + self.inner.mint_url.clone().into() + } + + /// Get the currency unit + pub fn unit(&self) -> CurrencyUnit { + self.inner.unit.clone().into() + } + + /// Set metadata cache TTL (time-to-live) in seconds + /// + /// Controls how long cached mint metadata (keysets, keys, mint info) is considered fresh + /// before requiring a refresh from the mint server. + /// + /// # Arguments + /// + /// * `ttl_secs` - Optional TTL in seconds. If None, cache never expires and is always used. + /// + /// # Example + /// + /// ```ignore + /// // Cache expires after 5 minutes + /// wallet.set_metadata_cache_ttl(Some(300)); + /// + /// // Cache never expires (default) + /// wallet.set_metadata_cache_ttl(None); + /// ``` + pub fn set_metadata_cache_ttl(&self, ttl_secs: Option) { + let ttl = ttl_secs.map(std::time::Duration::from_secs); + self.inner.set_metadata_cache_ttl(ttl); + } + + /// Get total balance + pub async fn total_balance(&self) -> Result { + let balance = self.inner.total_balance().await?; + Ok(balance.into()) + } + + /// Get total pending balance + pub async fn total_pending_balance(&self) -> Result { + let balance = self.inner.total_pending_balance().await?; + Ok(balance.into()) + } + + /// Get total reserved balance + pub async fn total_reserved_balance(&self) -> Result { + let balance = self.inner.total_reserved_balance().await?; + Ok(balance.into()) + } + + /// Get mint info + pub async fn get_mint_info(&self) -> Result, FfiError> { + let info = self.inner.fetch_mint_info().await?; + Ok(info.map(Into::into)) + } + + /// Load mint info + /// + /// This will get mint info from cache if it is fresh + pub async fn load_mint_info(&self) -> Result { + let info = self.inner.load_mint_info().await?; + Ok(info.into()) + } + + /// Receive tokens + pub async fn receive( + &self, + token: std::sync::Arc, + options: ReceiveOptions, + ) -> Result { + let amount = self + .inner + .receive(&token.to_string(), options.into()) + .await?; + Ok(amount.into()) + } + + /// Restore wallet from seed + pub async fn restore(&self) -> Result { + let amount = self.inner.restore().await?; + Ok(amount.into()) + } + + /// Verify token DLEQ proofs + pub async fn verify_token_dleq(&self, token: std::sync::Arc) -> Result<(), FfiError> { + let cdk_token = token.inner.clone(); + self.inner.verify_token_dleq(&cdk_token).await?; + Ok(()) + } + + /// Receive proofs directly + pub async fn receive_proofs( + &self, + proofs: Proofs, + options: ReceiveOptions, + memo: Option, + ) -> Result { + let cdk_proofs: Result, _> = + proofs.into_iter().map(|p| p.try_into()).collect(); + let cdk_proofs = cdk_proofs?; + + let amount = self + .inner + .receive_proofs(cdk_proofs, options.into(), memo) + .await?; + Ok(amount.into()) + } + + /// Prepare a send operation + pub async fn prepare_send( + &self, + amount: Amount, + options: SendOptions, + ) -> Result, FfiError> { + let prepared = self + .inner + .prepare_send(amount.into(), options.into()) + .await?; + Ok(std::sync::Arc::new(prepared.into())) + } + + /// Get a mint quote + pub async fn mint_quote( + &self, + amount: Amount, + description: Option, + ) -> Result { + let quote = self.inner.mint_quote(amount.into(), description).await?; + Ok(quote.into()) + } + + /// Mint tokens + pub async fn mint( + &self, + quote_id: String, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> Result { + // Convert spending conditions if provided + let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?; + + let proofs = self + .inner + .mint("e_id, amount_split_target.into(), conditions) + .await?; + Ok(proofs.into_iter().map(|p| p.into()).collect()) + } + + /// Get a melt quote + pub async fn melt_quote( + &self, + request: String, + options: Option, + ) -> Result { + let cdk_options = options.map(Into::into); + let quote = self.inner.melt_quote(request, cdk_options).await?; + Ok(quote.into()) + } + + /// Melt tokens + pub async fn melt(&self, quote_id: String) -> Result { + let melted = self.inner.melt("e_id).await?; + Ok(melted.into()) + } + + /// Melt specific proofs + /// + /// This method allows melting proofs that may not be in the wallet's database, + /// similar to how `receive_proofs` handles external proofs. The proofs will be + /// added to the database and used for the melt operation. + /// + /// # Arguments + /// + /// * `quote_id` - The melt quote ID (obtained from `melt_quote`) + /// * `proofs` - The proofs to melt (can be external proofs not in the wallet's database) + /// + /// # Returns + /// + /// A `Melted` result containing the payment details and any change proofs + pub async fn melt_proofs(&self, quote_id: String, proofs: Proofs) -> Result { + let cdk_proofs: Result, _> = + proofs.into_iter().map(|p| p.try_into()).collect(); + let cdk_proofs = cdk_proofs?; + + let melted = self.inner.melt_proofs("e_id, cdk_proofs).await?; + Ok(melted.into()) + } + + /// Get a quote for a bolt12 mint + pub async fn mint_bolt12_quote( + &self, + amount: Option, + description: Option, + ) -> Result { + let quote = self + .inner + .mint_bolt12_quote(amount.map(Into::into), description) + .await?; + Ok(quote.into()) + } + + /// Mint tokens using bolt12 + pub async fn mint_bolt12( + &self, + quote_id: String, + amount: Option, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> Result { + let conditions = spending_conditions.map(|sc| sc.try_into()).transpose()?; + + let proofs = self + .inner + .mint_bolt12( + "e_id, + amount.map(Into::into), + amount_split_target.into(), + conditions, + ) + .await?; + + Ok(proofs.into_iter().map(|p| p.into()).collect()) + } + + /// Get a quote for a bolt12 melt + pub async fn melt_bolt12_quote( + &self, + request: String, + options: Option, + ) -> Result { + let cdk_options = options.map(Into::into); + let quote = self.inner.melt_bolt12_quote(request, cdk_options).await?; + Ok(quote.into()) + } + + /// Swap proofs + pub async fn swap( + &self, + amount: Option, + amount_split_target: SplitTarget, + input_proofs: Proofs, + spending_conditions: Option, + include_fees: bool, + ) -> Result, FfiError> { + let cdk_proofs: Result, _> = + 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()?; + + let result = self + .inner + .swap( + amount.map(Into::into), + amount_split_target.into(), + cdk_proofs, + conditions, + include_fees, + ) + .await?; + + Ok(result.map(|proofs| proofs.into_iter().map(|p| p.into()).collect())) + } + + /// Get proofs by states + pub async fn get_proofs_by_states(&self, states: Vec) -> Result { + let mut all_proofs = Vec::new(); + + for state in states { + let proofs = match state { + ProofState::Unspent => self.inner.get_unspent_proofs().await?, + ProofState::Pending => self.inner.get_pending_proofs().await?, + ProofState::Reserved => self.inner.get_reserved_proofs().await?, + ProofState::PendingSpent => self.inner.get_pending_spent_proofs().await?, + ProofState::Spent => { + // CDK doesn't have a method to get spent proofs directly + // They are removed from the database when spent + continue; + } + }; + + for proof in proofs { + all_proofs.push(proof.into()); + } + } + + Ok(all_proofs) + } + + /// Check if proofs are spent + pub async fn check_proofs_spent(&self, proofs: Proofs) -> Result, FfiError> { + let cdk_proofs: Result, _> = + 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) + let spent_bools = proof_states + .into_iter() + .map(|proof_state| { + matches!( + proof_state.state, + cdk::nuts::State::Spent | cdk::nuts::State::PendingSpent + ) + }) + .collect(); + Ok(spent_bools) + } + + /// List transactions + pub async fn list_transactions( + &self, + direction: Option, + ) -> Result, FfiError> { + let cdk_direction = direction.map(Into::into); + let transactions = self.inner.list_transactions(cdk_direction).await?; + Ok(transactions.into_iter().map(Into::into).collect()) + } + + /// Get transaction by ID + pub async fn get_transaction( + &self, + id: TransactionId, + ) -> Result, FfiError> { + let cdk_id = id.try_into()?; + let transaction = self.inner.get_transaction(cdk_id).await?; + Ok(transaction.map(Into::into)) + } + + /// Get proofs for a transaction by transaction ID + /// + /// This retrieves all proofs associated with a transaction by looking up + /// the transaction's Y values and fetching the corresponding proofs. + pub async fn get_proofs_for_transaction( + &self, + id: TransactionId, + ) -> Result, FfiError> { + let cdk_id = id.try_into()?; + let proofs = self.inner.get_proofs_for_transaction(cdk_id).await?; + Ok(proofs.into_iter().map(Into::into).collect()) + } + + /// Revert a transaction + pub async fn revert_transaction(&self, id: TransactionId) -> Result<(), FfiError> { + let cdk_id = id.try_into()?; + self.inner.revert_transaction(cdk_id).await?; + Ok(()) + } + + /// Subscribe to wallet events + pub async fn subscribe( + &self, + params: SubscribeParams, + ) -> Result, FfiError> { + let cdk_params: cdk::nuts::nut17::Params> = params.clone().into(); + let sub_id = cdk_params.id.to_string(); + let active_sub = self.inner.subscribe(cdk_params).await; + Ok(std::sync::Arc::new(ActiveSubscription::new( + active_sub, sub_id, + ))) + } + + /// Refresh keysets from the mint + pub async fn refresh_keysets(&self) -> Result, FfiError> { + let keysets = self.inner.refresh_keysets().await?; + Ok(keysets.into_iter().map(Into::into).collect()) + } + + /// Get the active keyset for the wallet's unit + pub async fn get_active_keyset(&self) -> Result { + let keyset = self.inner.get_active_keyset().await?; + Ok(keyset.into()) + } + + /// Get fees for a specific keyset ID + pub async fn get_keyset_fees_by_id(&self, keyset_id: String) -> Result { + let id = cdk::nuts::Id::from_str(&keyset_id) + .map_err(|e| FfiError::Generic { msg: e.to_string() })?; + Ok(self + .inner + .get_keyset_fees_and_amounts_by_id(id) + .await? + .fee()) + } + + /// Reclaim unspent proofs (mark them as unspent in the database) + pub async fn reclaim_unspent(&self, proofs: Proofs) -> Result<(), FfiError> { + let cdk_proofs: Result, _> = + proofs.iter().map(|p| p.clone().try_into()).collect(); + let cdk_proofs = cdk_proofs?; + self.inner.reclaim_unspent(cdk_proofs).await?; + Ok(()) + } + + /// Check all pending proofs and return the total amount reclaimed + pub async fn check_all_pending_proofs(&self) -> Result { + let amount = self.inner.check_all_pending_proofs().await?; + Ok(amount.into()) + } + + /// Calculate fee for a given number of proofs with the specified keyset + pub async fn calculate_fee( + &self, + proof_count: u32, + keyset_id: String, + ) -> Result { + let id = cdk::nuts::Id::from_str(&keyset_id) + .map_err(|e| FfiError::Generic { msg: e.to_string() })?; + let fee = self + .inner + .get_keyset_count_fee(&id, proof_count as u64) + .await?; + Ok(fee.into()) + } +} + +/// BIP353 methods for Wallet +#[cfg(not(target_arch = "wasm32"))] +#[uniffi::export(async_runtime = "tokio")] +impl Wallet { + /// Get a quote for a BIP353 melt + /// + /// This method resolves a BIP353 address (e.g., "alice@example.com") to a Lightning offer + /// and then creates a melt quote for that offer. + pub async fn melt_bip353_quote( + &self, + bip353_address: String, + amount_msat: Amount, + ) -> Result { + let cdk_amount: cdk::Amount = amount_msat.into(); + let quote = self + .inner + .melt_bip353_quote(&bip353_address, cdk_amount) + .await?; + Ok(quote.into()) + } + + /// Get a quote for a Lightning address melt + /// + /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice + /// and then creates a melt quote for that invoice. + pub async fn melt_lightning_address_quote( + &self, + lightning_address: String, + amount_msat: Amount, + ) -> Result { + let cdk_amount: cdk::Amount = amount_msat.into(); + let quote = self + .inner + .melt_lightning_address_quote(&lightning_address, cdk_amount) + .await?; + Ok(quote.into()) + } + + /// Get a quote for a human-readable address melt + /// + /// This method accepts a human-readable address that could be either a BIP353 address + /// or a Lightning address. It intelligently determines which to try based on mint support: + /// + /// 1. If the mint supports Bolt12, it tries BIP353 first + /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails + /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address + /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly + pub async fn melt_human_readable( + &self, + address: String, + amount_msat: Amount, + ) -> Result { + let cdk_amount: cdk::Amount = amount_msat.into(); + let quote = self + .inner + .melt_human_readable_quote(&address, cdk_amount) + .await?; + Ok(quote.into()) + } +} + +/// Auth methods for Wallet +#[uniffi::export(async_runtime = "tokio")] +impl Wallet { + /// Set Clear Auth Token (CAT) for authentication + pub async fn set_cat(&self, cat: String) -> Result<(), FfiError> { + self.inner.set_cat(cat).await?; + Ok(()) + } + + /// Set refresh token for authentication + pub async fn set_refresh_token(&self, refresh_token: String) -> Result<(), FfiError> { + self.inner.set_refresh_token(refresh_token).await?; + Ok(()) + } + + /// Refresh access token using the stored refresh token + pub async fn refresh_access_token(&self) -> Result<(), FfiError> { + self.inner.refresh_access_token().await?; + Ok(()) + } + + /// Mint blind auth tokens + pub async fn mint_blind_auth(&self, amount: Amount) -> Result { + let proofs = self.inner.mint_blind_auth(amount.into()).await?; + Ok(proofs.into_iter().map(|p| p.into()).collect()) + } + + /// Get unspent auth proofs + pub async fn get_unspent_auth_proofs(&self) -> Result, FfiError> { + let auth_proofs = self.inner.get_unspent_auth_proofs().await?; + Ok(auth_proofs.into_iter().map(Into::into).collect()) + } +} + +/// Configuration for creating wallets +#[derive(Debug, Clone, uniffi::Record)] +pub struct WalletConfig { + pub target_proof_count: Option, +} + +/// Generates a new random mnemonic phrase +#[uniffi::export] +pub fn generate_mnemonic() -> Result { + let mnemonic = + Mnemonic::generate(12).map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + Ok(mnemonic.to_string()) +} + +/// Converts a mnemonic phrase to its entropy bytes +#[uniffi::export] +pub fn mnemonic_to_entropy(mnemonic: String) -> Result, FfiError> { + let m = + Mnemonic::parse(&mnemonic).map_err(|e| FfiError::InvalidMnemonic { msg: e.to_string() })?; + Ok(m.to_entropy()) +} diff --git a/crates/cdk-ffi/tests/README.md b/crates/cdk-ffi/tests/README.md new file mode 100644 index 000000000..3db2c66df --- /dev/null +++ b/crates/cdk-ffi/tests/README.md @@ -0,0 +1,112 @@ +# CDK FFI Python Tests + +This directory contains Python tests for the CDK FFI (Foreign Function Interface) bindings, focusing on wallet database operations. + +## Running the Tests + +### Quick Start + +The easiest way to run all tests: + +```bash +# From the repository root +just ffi-test +``` + +This command will automatically: +1. Build the FFI bindings (if needed) +2. Run all Python tests +3. Report results + +Or run directly (assumes bindings are already built): + +```bash +# From the repository root +python3 crates/cdk-ffi/tests/test_transactions.py +``` + +### Prerequisites + +**Python 3.7+** is required. The `just ffi-test` command handles everything else automatically. + +## How It Works + +The test script automatically: +1. Locates the bindings in `target/bindings/python/` +2. Copies the shared library from `target/release/` to the bindings directory +3. Runs all wallet tests + +**No manual file copying required!** + +## Test Suite + +### Wallet Tests (test_transactions.py) + +Comprehensive tests for wallet database operations: + +1. **Wallet Creation** - Tests creating a wallet with SQLite backend +2. **Wallet Mint Management** - Tests adding and querying mints +3. **Wallet Keyset Management** - Tests adding and querying keysets +4. **Wallet Keyset Counter** - Tests keyset counter increment operations +5. **Wallet Quote Operations** - Tests querying mint and melt quotes +6. **Wallet Get Proofs by Y Values** - Tests retrieving proofs by Y values + +### Key Features Tested + +- ✅ **Wallet creation** - SQLite backend initialization +- ✅ **Mint management** - Add, query, and retrieve mint URLs +- ✅ **Keyset operations** - Add keysets and query by ID or mint +- ✅ **Counter operations** - Keyset counter increment/read +- ✅ **Quote queries** - Retrieve mint and melt quotes +- ✅ **Proof retrieval** - Get proofs by Y values +- ✅ **Foreign key constraints** - Proper referential integrity + +## Test Output + +Expected output for successful run: + +``` +Starting CDK FFI Wallet Tests +================================================== +... (test execution) ... +================================================== +Test Results: 6 passed, 0 failed +================================================== +``` + +## Troubleshooting + +### Import Errors + +If you see `ModuleNotFoundError: No module named 'cdk_ffi'`: +- Ensure FFI bindings are generated: `just ffi-generate python` +- Check that `target/bindings/python/cdk_ffi.py` exists + +### Library Not Found + +If you see errors about missing `.dylib` or `.so` files: +- Build the release version: `cargo build --release -p cdk-ffi` +- Check that the library exists in `target/release/` + +### Test Failures + +If tests fail: +- Ensure you're running from the repository root +- Check that the FFI bindings match the current code version +- Try rebuilding: `just ffi-generate python && cargo build --release -p cdk-ffi` + +## Development + +When adding new tests: + +1. Add test function with `async def test_*()` signature +2. Add test to the `tests` list in `main()` +3. Use temporary databases for isolation +4. Follow existing patterns for setup/teardown + +## Implementation Notes + +- All tests use temporary SQLite databases +- Each test is fully isolated with its own database +- Tests clean up automatically via `finally` blocks +- The script handles path resolution and library loading automatically diff --git a/crates/cdk-ffi/tests/test_transactions.py b/crates/cdk-ffi/tests/test_transactions.py new file mode 100755 index 000000000..00160d9b5 --- /dev/null +++ b/crates/cdk-ffi/tests/test_transactions.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Test suite for CDK FFI wallet operations +""" + +import asyncio +import os +import sys +import tempfile +from pathlib import Path + +# Setup paths before importing cdk_ffi +repo_root = Path(__file__).parent.parent.parent.parent +bindings_path = repo_root / "target" / "bindings" / "python" +lib_path = repo_root / "target" / "release" + +# Copy the library to the bindings directory so Python can find it +import shutil +lib_file = "libcdk_ffi.dylib" if sys.platform == "darwin" else "libcdk_ffi.so" +src_lib = lib_path / lib_file +dst_lib = bindings_path / lib_file + +if src_lib.exists() and not dst_lib.exists(): + shutil.copy2(src_lib, dst_lib) + +# Add target/bindings/python to path to load cdk_ffi module +sys.path.insert(0, str(bindings_path)) + +import cdk_ffi + + +async def test_wallet_creation(): + """Test creating a wallet with SQLite backend""" + print("\n=== Test: Wallet Creation ===") + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + db_path = tmp.name + + try: + backend = cdk_ffi.WalletDbBackend.SQLITE(path=db_path) + db = cdk_ffi.create_wallet_db(backend) + print("✓ Wallet database created") + + # Verify database is accessible by querying quotes + mint_quotes = await db.get_mint_quotes() + assert isinstance(mint_quotes, list), "get_mint_quotes should return a list" + print("✓ Wallet database accessible") + + print("✓ Test passed: Wallet creation works") + + finally: + if os.path.exists(db_path): + os.unlink(db_path) + + +async def test_wallet_mint_management(): + """Test adding and querying mints""" + print("\n=== Test: Wallet Mint Management ===") + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + db_path = tmp.name + + try: + backend = cdk_ffi.WalletDbBackend.SQLITE(path=db_path) + db = cdk_ffi.create_wallet_db(backend) + + mint_url = cdk_ffi.MintUrl(url="https://testmint.example.com") + + # Add mint + await db.add_mint(mint_url, None) + print("✓ Added mint to wallet") + + # Get specific mint (verifies it was added) + await db.get_mint(mint_url) + print("✓ Retrieved mint from database") + + # Remove mint + await db.remove_mint(mint_url) + print("✓ Removed mint from wallet") + + # Verify removal + mint_info_after = await db.get_mint(mint_url) + assert mint_info_after is None, "Mint should be removed" + print("✓ Verified mint removal") + + print("✓ Test passed: Mint management works") + + finally: + if os.path.exists(db_path): + os.unlink(db_path) + + +async def test_wallet_keyset_management(): + """Test adding and querying keysets""" + print("\n=== Test: Wallet Keyset Management ===") + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + db_path = tmp.name + + try: + backend = cdk_ffi.WalletDbBackend.SQLITE(path=db_path) + db = cdk_ffi.create_wallet_db(backend) + + mint_url = cdk_ffi.MintUrl(url="https://testmint.example.com") + keyset_id = cdk_ffi.Id(hex="004146bdf4a9afab") + + # Add mint first (foreign key requirement) + await db.add_mint(mint_url, None) + print("✓ Added mint") + + # Add keyset + keyset_info = cdk_ffi.KeySetInfo( + id=keyset_id.hex, + unit=cdk_ffi.CurrencyUnit.SAT(), + active=True, + input_fee_ppk=0 + ) + await db.add_mint_keysets(mint_url, [keyset_info]) + print("✓ Added keyset") + + # Query keyset by ID + keyset = await db.get_keyset_by_id(keyset_id) + assert keyset is not None, "Keyset should exist" + assert keyset.id == keyset_id.hex, "Keyset ID should match" + print(f"✓ Retrieved keyset: {keyset.id}") + + # Query keysets for mint + keysets = await db.get_mint_keysets(mint_url) + assert keysets is not None and len(keysets) > 0, "Should have keysets for mint" + print(f"✓ Retrieved {len(keysets)} keyset(s) for mint") + + print("✓ Test passed: Keyset management works") + + finally: + if os.path.exists(db_path): + os.unlink(db_path) + + +async def test_wallet_keyset_counter(): + """Test keyset counter operations""" + print("\n=== Test: Wallet Keyset Counter ===") + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + db_path = tmp.name + + try: + backend = cdk_ffi.WalletDbBackend.SQLITE(path=db_path) + db = cdk_ffi.create_wallet_db(backend) + + mint_url = cdk_ffi.MintUrl(url="https://testmint.example.com") + keyset_id = cdk_ffi.Id(hex="004146bdf4a9afab") + + # Setup mint and keyset + await db.add_mint(mint_url, None) + keyset_info = cdk_ffi.KeySetInfo( + id=keyset_id.hex, + unit=cdk_ffi.CurrencyUnit.SAT(), + active=True, + input_fee_ppk=0 + ) + await db.add_mint_keysets(mint_url, [keyset_info]) + print("✓ Setup complete") + + # Increment counter + counter1 = await db.increment_keyset_counter(keyset_id, 1) + print(f"✓ Counter after +1: {counter1}") + assert counter1 == 1, f"Expected counter 1, got {counter1}" + + # Increment again + counter2 = await db.increment_keyset_counter(keyset_id, 5) + print(f"✓ Counter after +5: {counter2}") + assert counter2 == 6, f"Expected counter 6, got {counter2}" + + # Read current value (increment by 0) + counter3 = await db.increment_keyset_counter(keyset_id, 0) + print(f"✓ Current counter: {counter3}") + assert counter3 == 6, f"Expected counter 6, got {counter3}" + + print("✓ Test passed: Keyset counter works") + + finally: + if os.path.exists(db_path): + os.unlink(db_path) + + +async def test_wallet_quotes(): + """Test mint and melt quote operations""" + print("\n=== Test: Wallet Quote Operations ===") + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + db_path = tmp.name + + try: + backend = cdk_ffi.WalletDbBackend.SQLITE(path=db_path) + db = cdk_ffi.create_wallet_db(backend) + + mint_url = cdk_ffi.MintUrl(url="https://testmint.example.com") + + # Add mint first + await db.add_mint(mint_url, None) + print("✓ Added mint") + + # Query mint quotes (should be empty initially) + mint_quotes = await db.get_mint_quotes() + assert isinstance(mint_quotes, list), "get_mint_quotes should return a list" + print(f"✓ Retrieved {len(mint_quotes)} mint quote(s)") + + # Query melt quotes (should be empty initially) + melt_quotes = await db.get_melt_quotes() + assert isinstance(melt_quotes, list), "get_melt_quotes should return a list" + print(f"✓ Retrieved {len(melt_quotes)} melt quote(s)") + + print("✓ Test passed: Quote operations work") + + finally: + if os.path.exists(db_path): + os.unlink(db_path) + + +async def test_wallet_proofs_by_ys(): + """Test retrieving proofs by Y values""" + print("\n=== Test: Wallet Get Proofs by Y Values ===") + + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + db_path = tmp.name + + try: + backend = cdk_ffi.WalletDbBackend.SQLITE(path=db_path) + db = cdk_ffi.create_wallet_db(backend) + + # Test with empty list + proofs = await db.get_proofs_by_ys([]) + assert len(proofs) == 0, f"Expected 0 proofs, got {len(proofs)}" + print("✓ get_proofs_by_ys returns empty for empty input") + + print("✓ Test passed: get_proofs_by_ys works") + + finally: + if os.path.exists(db_path): + os.unlink(db_path) + + +async def main(): + """Run all tests""" + print("Starting CDK FFI Wallet Tests") + print("=" * 50) + + tests = [ + ("Wallet Creation", test_wallet_creation), + ("Wallet Mint Management", test_wallet_mint_management), + ("Wallet Keyset Management", test_wallet_keyset_management), + ("Wallet Keyset Counter", test_wallet_keyset_counter), + ("Wallet Quote Operations", test_wallet_quotes), + ("Wallet Get Proofs by Y Values", test_wallet_proofs_by_ys), + ] + + passed = 0 + failed = 0 + + for test_name, test_func in tests: + try: + await test_func() + passed += 1 + except Exception as e: + failed += 1 + print(f"\n✗ Test failed: {test_name}") + print(f"Error: {e}") + import traceback + traceback.print_exc() + + print("\n" + "=" * 50) + print(f"Test Results: {passed} passed, {failed} failed") + print("=" * 50) + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + sys.exit(exit_code) diff --git a/crates/cdk-ffi/uniffi.toml b/crates/cdk-ffi/uniffi.toml new file mode 100644 index 000000000..31cb7cb44 --- /dev/null +++ b/crates/cdk-ffi/uniffi.toml @@ -0,0 +1,12 @@ +[bindings.kotlin] +package_name = "org.cashudevkit" +cdylib_name = "cdk_ffi" + +[bindings.python] +cdylib_name = "cdk_ffi" + +[bindings.swift] +module_name = "CashuDevKit" +cdylib_name = "cdk_ffi" +generate_codable_conformance = true +generate_immutable_records = true diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index a824321a3..c2d7d8495 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -20,13 +20,16 @@ rand.workspace = true bip39 = { workspace = true, features = ["rand"] } anyhow.workspace = true cashu = { workspace = true, features = ["mint", "wallet"] } -cdk = { workspace = true, features = ["mint", "wallet", "auth"] } +cdk = { workspace = true, features = ["mint", "wallet", "auth", "bip353"] } cdk-cln = { workspace = true } cdk-lnd = { workspace = true } -cdk-axum = { workspace = true } +cdk-ldk-node = { workspace = true } +cdk-axum = { workspace = true, features = ["auth"] } cdk-sqlite = { workspace = true } cdk-redb = { workspace = true } cdk-fake-wallet = { workspace = true } +cdk-common = { workspace = true, features = ["mint", "wallet", "auth"] } +cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node", "prometheus", "portalwallet"] } futures = { workspace = true, default-features = false, features = [ "executor", ] } @@ -35,15 +38,19 @@ uuid.workspace = true serde.workspace = true serde_json.workspace = true # ln-regtest-rs = { path = "../../../../ln-regtest-rs" } -ln-regtest-rs = { git = "https://github.com/thesimplekid/ln-regtest-rs", rev = "ed24716" } +ln-regtest-rs = { git = "https://github.com/thesimplekid/ln-regtest-rs", rev = "df81424" } lightning-invoice.workspace = true +ldk-node.workspace = true tracing.workspace = true tracing-subscriber.workspace = true tokio-tungstenite.workspace = true tower-http = { workspace = true, features = ["cors"] } tower-service = "0.3.3" +tokio-util.workspace = true reqwest.workspace = true bitcoin = "0.32.0" +clap = { workspace = true, features = ["derive"] } +web-time.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio.workspace = true @@ -51,12 +58,12 @@ tokio.workspace = true [target.'cfg(target_arch = "wasm32")'.dependencies] tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } getrandom = { version = "0.2", features = ["js"] } -instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] } +uuid = { workspace = true, features = ["js"] } [dev-dependencies] bip39 = { workspace = true, features = ["rand"] } anyhow.workspace = true -cdk = { workspace = true, features = ["mint", "wallet"] } cdk-axum = { workspace = true } cdk-fake-wallet = { workspace = true } +cdk-ffi = { workspace = true } tower-http = { workspace = true, features = ["cors"] } diff --git a/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs b/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs new file mode 100644 index 000000000..032326093 --- /dev/null +++ b/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs @@ -0,0 +1,183 @@ +//! Binary for starting a fake mint with authentication for testing +//! +//! This binary provides a programmatic way to start a fake mint instance with authentication for testing purposes: +//! 1. Sets up a fake mint instance with authentication using the cdk-mintd library +//! 2. Configures OpenID Connect authentication settings +//! 3. Waits for the mint to be ready and responsive +//! 4. Keeps it running until interrupted (Ctrl+C) +//! 5. Gracefully shuts down on receiving shutdown signal +//! +//! This approach offers better control and integration compared to external scripts, +//! making it easier to run authentication integration tests with consistent configuration. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use bip39::Mnemonic; +use cdk_integration_tests::cli::CommonArgs; +use cdk_integration_tests::shared; +use cdk_mintd::config::AuthType; +use clap::Parser; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Parser)] +#[command(name = "start-fake-auth-mint")] +#[command(about = "Start a fake mint with authentication for testing", long_about = None)] +struct Args { + #[command(flatten)] + common: CommonArgs, + + /// Database type (sqlite) + database_type: String, + + /// Working directory path + work_dir: String, + + /// OpenID discovery URL + openid_discovery: String, + + /// Port to listen on (default: 8087) + #[arg(default_value_t = 8087)] + port: u16, +} + +/// Start a fake mint with authentication using the library +async fn start_fake_auth_mint( + temp_dir: &Path, + database: &str, + port: u16, + openid_discovery: String, + shutdown: Arc, +) -> Result> { + println!("Starting fake auth mintd on port {port}"); + + // Create settings struct for fake mint with auth using shared function + let fake_wallet_config = cdk_mintd::config::FakeWallet { + supported_units: vec![cdk::nuts::CurrencyUnit::Sat, cdk::nuts::CurrencyUnit::Usd], + fee_percent: 0.0, + reserve_fee_min: cdk::Amount::from(1), + min_delay_time: 1, + max_delay_time: 3, + }; + + let mut settings = shared::create_fake_wallet_settings( + port, + database, + Some(Mnemonic::generate(12)?.to_string()), + None, + Some(fake_wallet_config), + ); + + // Enable authentication + settings.auth = Some(cdk_mintd::config::Auth { + auth_enabled: true, + openid_discovery, + openid_client_id: "cashu-client".to_string(), + mint_max_bat: 50, + mint: AuthType::Blind, + get_mint_quote: AuthType::Blind, + check_mint_quote: AuthType::Blind, + melt: AuthType::Blind, + get_melt_quote: AuthType::Blind, + check_melt_quote: AuthType::Blind, + swap: AuthType::Blind, + restore: AuthType::Blind, + check_proof_state: AuthType::Blind, + websocket_auth: AuthType::Blind, + static_auth_token: None, + }); + + // Set description for the mint + settings.mint_info.description = "fake test mint with auth".to_string(); + + let temp_dir = temp_dir.to_path_buf(); + let shutdown_clone = shutdown.clone(); + + // Run the mint in a separate task + let handle = tokio::spawn(async move { + // Create a future that resolves when the shutdown signal is received + let shutdown_future = async move { + shutdown_clone.notified().await; + println!("Fake auth mint shutdown signal received"); + }; + + match cdk_mintd::run_mintd_with_shutdown( + &temp_dir, + &settings, + shutdown_future, + None, + None, + vec![], + ) + .await + { + Ok(_) => println!("Fake auth mint exited normally"), + Err(e) => eprintln!("Fake auth mint exited with error: {e}"), + } + }); + + Ok(handle) +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + // Initialize logging based on CLI arguments + shared::setup_logging(&args.common); + + let temp_dir = shared::init_working_directory(&args.work_dir)?; + + // Start fake auth mint + let shutdown = shared::create_shutdown_handler(); + let shutdown_clone = shutdown.clone(); + + let handle = start_fake_auth_mint( + &temp_dir, + &args.database_type, + args.port, + args.openid_discovery.clone(), + shutdown_clone, + ) + .await?; + + let cancel_token = Arc::new(CancellationToken::new()); + + // Wait for fake auth mint to be ready + if let Err(e) = shared::wait_for_mint_ready_with_shutdown(args.port, 100, cancel_token).await { + eprintln!("Error waiting for fake auth mint: {e}"); + return Err(e); + } + + println!("Fake auth mint started successfully!"); + println!("Fake auth mint: http://127.0.0.1:{}", args.port); + println!("Temp directory: {temp_dir:?}"); + println!("Database type: {}", args.database_type); + println!("OpenID Discovery: {}", args.openid_discovery); + println!(); + println!("Environment variables needed for tests:"); + println!(" CDK_TEST_OIDC_USER="); + println!(" CDK_TEST_OIDC_PASSWORD="); + println!(); + println!("You can now run auth integration tests with:"); + println!(" cargo test -p cdk-integration-tests --test fake_auth"); + println!(); + + println!("Press Ctrl+C to stop the mint..."); + + // Wait for Ctrl+C signal + shared::wait_for_shutdown_signal(shutdown).await; + + println!("\nReceived Ctrl+C, shutting down mint..."); + + // Wait for mint to finish gracefully + if let Err(e) = handle.await { + eprintln!("Error waiting for mint to shut down: {e}"); + } + + println!("Mint shut down successfully"); + + Ok(()) +} diff --git a/crates/cdk-integration-tests/src/bin/start_fake_mint.rs b/crates/cdk-integration-tests/src/bin/start_fake_mint.rs new file mode 100644 index 000000000..01aab141c --- /dev/null +++ b/crates/cdk-integration-tests/src/bin/start_fake_mint.rs @@ -0,0 +1,196 @@ +//! Binary for starting a fake mint for testing +//! +//! This binary provides a programmatic way to start a fake mint instance for testing purposes: +//! 1. Sets up a fake mint instance using the cdk-mintd library +//! 2. Configures the mint with fake wallet backend for testing Lightning Network interactions +//! 3. Waits for the mint to be ready and responsive +//! 4. Keeps it running until interrupted (Ctrl+C) +//! 5. Gracefully shuts down on receiving shutdown signal +//! +//! This approach offers better control and integration compared to external scripts, +//! making it easier to run integration tests with consistent configuration. + +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use cdk::nuts::CurrencyUnit; +use cdk_integration_tests::cli::CommonArgs; +use cdk_integration_tests::shared; +use clap::Parser; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Parser)] +#[command(name = "start-fake-mint")] +#[command(about = "Start a fake mint for testing", long_about = None)] +struct Args { + #[command(flatten)] + common: CommonArgs, + + /// Database type (sqlite) + database_type: String, + + /// Working directory path + work_dir: String, + + /// Port to listen on (default: 8086) + #[arg(default_value_t = 8086)] + port: u16, + + /// Use external signatory + #[arg(long, default_value_t = false)] + external_signatory: bool, +} + +/// Start a fake mint using the library +async fn start_fake_mint( + temp_dir: &Path, + port: u16, + database: &str, + shutdown: Arc, + external_signatory: bool, +) -> Result> { + let signatory_config = if external_signatory { + println!("Configuring external signatory"); + Some(( + "https://127.0.0.1:15060".to_string(), // Default signatory URL + temp_dir.to_string_lossy().to_string(), // Certs directory as string + )) + } else { + None + }; + + let mnemonic = if external_signatory { + None + } else { + Some( + "eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal" + .to_string(), + ) + }; + + let fake_wallet_config = Some(cdk_mintd::config::FakeWallet { + supported_units: vec![CurrencyUnit::Sat, CurrencyUnit::Usd], + fee_percent: 0.0, + reserve_fee_min: 1.into(), + min_delay_time: 1, + max_delay_time: 3, + }); + + // Create settings struct for fake mint using shared function + let settings = shared::create_fake_wallet_settings( + port, + database, + mnemonic, + signatory_config, + fake_wallet_config, + ); + + println!("Starting fake mintd on port {port}"); + + let temp_dir = temp_dir.to_path_buf(); + let shutdown_clone = shutdown.clone(); + + // Run the mint in a separate task + let handle = tokio::spawn(async move { + // Create a future that resolves when the shutdown signal is received + let shutdown_future = async move { + shutdown_clone.notified().await; + println!("Fake mint shutdown signal received"); + }; + + match cdk_mintd::run_mintd_with_shutdown( + &temp_dir, + &settings, + shutdown_future, + None, + None, + vec![], + ) + .await + { + Ok(_) => println!("Fake mint exited normally"), + Err(e) => eprintln!("Fake mint exited with error: {e}"), + } + }); + + Ok(handle) +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + + // Initialize logging based on CLI arguments + shared::setup_logging(&args.common); + + let temp_dir = shared::init_working_directory(&args.work_dir)?; + + // Write environment variables to a .env file in the temp_dir BEFORE starting the mint + let mint_url = format!("http://127.0.0.1:{}", args.port); + let itests_dir = temp_dir.display().to_string(); + let env_vars: Vec<(&str, &str)> = vec![ + ("CDK_TEST_MINT_URL", &mint_url), + ("CDK_ITESTS_DIR", &itests_dir), + ]; + + shared::write_env_file(&temp_dir, &env_vars)?; + + // Start fake mint + let shutdown = shared::create_shutdown_handler(); + let shutdown_clone = shutdown.clone(); + + let handle = start_fake_mint( + &temp_dir, + args.port, + &args.database_type, + shutdown_clone, + args.external_signatory, + ) + .await?; + + let cancel_token = Arc::new(CancellationToken::new()); + + // Wait for fake mint to be ready + if let Err(e) = shared::wait_for_mint_ready_with_shutdown(args.port, 100, cancel_token).await { + eprintln!("Error waiting for fake mint: {e}"); + return Err(e); + } + + shared::display_mint_info(args.port, &temp_dir, &args.database_type); + + println!(); + println!( + "Environment variables written to: {}/.env", + temp_dir.display() + ); + println!("You can source these variables with:"); + println!(" source {}/.env", temp_dir.display()); + println!(); + println!("Environment variables set:"); + println!(" CDK_TEST_MINT_URL=http://127.0.0.1:{}", args.port); + println!(" CDK_ITESTS_DIR={}", temp_dir.display()); + println!(); + println!("You can now run integration tests with:"); + println!(" cargo test -p cdk-integration-tests --test fake_wallet"); + println!(" cargo test -p cdk-integration-tests --test happy_path_mint_wallet"); + println!(" etc."); + println!(); + + println!("Press Ctrl+C to stop the mint..."); + + // Wait for Ctrl+C signal + shared::wait_for_shutdown_signal(shutdown).await; + + println!("\nReceived Ctrl+C, shutting down mint..."); + + // Wait for mint to finish gracefully + if let Err(e) = handle.await { + eprintln!("Error waiting for mint to shut down: {e}"); + } + + println!("Mint shut down successfully"); + + Ok(()) +} diff --git a/crates/cdk-integration-tests/src/bin/start_regtest.rs b/crates/cdk-integration-tests/src/bin/start_regtest.rs index e63a8cdc6..3ceec058a 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest.rs @@ -1,20 +1,36 @@ use std::fs::OpenOptions; use std::io::Write; +use std::path::{Path, PathBuf}; +use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use anyhow::{bail, Result}; -use cdk_integration_tests::init_regtest::{get_temp_dir, start_regtest_end}; +use anyhow::Result; +use cashu::Amount; +use cdk_integration_tests::cli::{init_logging, CommonArgs}; +use cdk_integration_tests::init_regtest::start_regtest_end; +use cdk_ldk_node::CdkLdkNode; +use clap::Parser; +use ldk_node::lightning::ln::msgs::SocketAddress; use tokio::signal; use tokio::sync::{oneshot, Notify}; use tokio::time::timeout; -use tracing_subscriber::EnvFilter; -fn signal_progress() { - let temp_dir = get_temp_dir(); +#[derive(Parser)] +#[command(name = "start-regtest")] +#[command(about = "Start regtest environment", long_about = None)] +struct Args { + #[command(flatten)] + common: CommonArgs, + + /// Working directory path + work_dir: String, +} + +fn signal_progress(work_dir: &Path) { let mut pipe = OpenOptions::new() .write(true) - .open(temp_dir.join("progress_pipe")) + .open(work_dir.join("progress_pipe")) .expect("Failed to open pipe"); pipe.write_all(b"checkpoint1\n") @@ -23,24 +39,46 @@ fn signal_progress() { #[tokio::main] async fn main() -> Result<()> { - let default_filter = "debug"; + let args = Args::parse(); - let sqlx_filter = "sqlx=warn"; - let hyper_filter = "hyper=warn"; - let h2_filter = "h2=warn"; - let rustls_filter = "rustls=warn"; + // Initialize logging based on CLI arguments + init_logging(args.common.enable_logging, args.common.log_level); - let env_filter = EnvFilter::new(format!( - "{default_filter},{sqlx_filter},{hyper_filter},{h2_filter},{rustls_filter}" - )); - - tracing_subscriber::fmt().with_env_filter(env_filter).init(); + let temp_dir = PathBuf::from_str(&args.work_dir)?; let shutdown_regtest = Arc::new(Notify::new()); - let shutdown_clone = shutdown_regtest.clone(); + let shutdown_clone = Arc::clone(&shutdown_regtest); + let shutdown_clone_two = Arc::clone(&shutdown_regtest); + + let ldk_work_dir = temp_dir.join("ldk_mint"); + let cdk_ldk = CdkLdkNode::new( + bitcoin::Network::Regtest, + cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig { + host: "127.0.0.1".to_string(), + port: 18443, + user: "testuser".to_string(), + password: "testpass".to_string(), + }), + cdk_ldk_node::GossipSource::P2P, + ldk_work_dir.to_string_lossy().to_string(), + cdk_common::common::FeeReserve { + min_fee_reserve: Amount::ZERO, + percent_fee_reserve: 0.0, + }, + vec![SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: 8092, + }], + None, + )?; + + let inner_node = cdk_ldk.node(); + + let temp_dir_clone = temp_dir.clone(); + let (tx, rx) = oneshot::channel(); tokio::spawn(async move { - start_regtest_end(tx, shutdown_clone) + start_regtest_end(&temp_dir_clone, tx, shutdown_clone, Some(inner_node)) .await .expect("Error starting regtest"); }); @@ -48,15 +86,25 @@ async fn main() -> Result<()> { match timeout(Duration::from_secs(300), rx).await { Ok(_) => { tracing::info!("Regtest set up"); - signal_progress(); + signal_progress(&temp_dir); } Err(_) => { tracing::error!("regtest setup timed out after 5 minutes"); - bail!("Could not set up regtest"); + anyhow::bail!("Could not set up regtest"); } } - signal::ctrl_c().await?; + let shutdown_future = async { + // Wait for Ctrl+C signal + signal::ctrl_c() + .await + .expect("failed to install CTRL+C handler"); + tracing::info!("Shutdown signal received"); + println!("\nReceived Ctrl+C, shutting down mints..."); + shutdown_clone_two.notify_waiters(); + }; + + shutdown_future.await; Ok(()) } diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs new file mode 100644 index 000000000..e9ab87dae --- /dev/null +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -0,0 +1,532 @@ +//! Binary for starting regtest mints +//! +//! This binary provides a programmatic way to start regtest mints for testing purposes: +//! 1. Sets up a regtest environment with CLN and LND nodes +//! 2. Starts CLN and LND mint instances using the cdk-mintd library +//! 3. Configures the mints to connect to the respective Lightning Network backends +//! 4. Waits for both mints to be ready and responsive +//! 5. Keeps them running until interrupted (Ctrl+C) +//! 6. Gracefully shuts down all services on receiving shutdown signal +//! +//! This approach offers better control and integration compared to external scripts, +//! making it easier to run integration tests with consistent configuration. + +use std::fs; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{bail, Result}; +use bip39::Mnemonic; +use cashu::Amount; +use cdk_integration_tests::cli::CommonArgs; +use cdk_integration_tests::init_regtest::start_regtest_end; +use cdk_integration_tests::shared; +use cdk_ldk_node::CdkLdkNode; +use cdk_mintd::config::LoggingConfig; +use clap::Parser; +use ldk_node::lightning::ln::msgs::SocketAddress; +use tokio::runtime::Runtime; +use tokio::signal; +use tokio::signal::unix::SignalKind; +use tokio::sync::{oneshot, Notify}; +use tokio::time::timeout; +use tokio_util::sync::CancellationToken; + +#[derive(Parser)] +#[command(name = "start-regtest-mints")] +#[command(about = "Start regtest mints", long_about = None)] +struct Args { + #[command(flatten)] + common: CommonArgs, + + /// Database type (sqlite) + database_type: String, + + /// Working directory path + work_dir: String, + + /// Mint address (default: 127.0.0.1) + #[arg(default_value = "127.0.0.1")] + mint_addr: String, + + /// CLN port (default: 8085) + #[arg(default_value_t = 8085)] + cln_port: u16, + + /// LND port (default: 8087) + #[arg(default_value_t = 8087)] + lnd_port: u16, + + /// LDK port (default: 8089) + #[arg(default_value_t = 8089)] + ldk_port: u16, +} + +/// Start regtest CLN mint using the library +async fn start_cln_mint( + temp_dir: &Path, + port: u16, + shutdown: Arc, +) -> Result> { + let cln_rpc_path = temp_dir + .join("cln") + .join("one") + .join("regtest") + .join("lightning-rpc"); + + let cln_config = cdk_mintd::config::Cln { + rpc_path: cln_rpc_path, + bolt12: false, + fee_percent: 0.0, + reserve_fee_min: 0.into(), + }; + + // Create settings struct for CLN mint using shared function + let settings = shared::create_cln_settings( + port, + temp_dir + .join("cln") + .join("one") + .join("regtest") + .join("lightning-rpc"), + "eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal".to_string(), + cln_config, + ); + + println!("Starting CLN mintd on port {port}"); + + let temp_dir = temp_dir.to_path_buf(); + let shutdown_clone = shutdown.clone(); + + // Run the mint in a separate task + let handle = tokio::spawn(async move { + // Create a future that resolves when the shutdown signal is received + let shutdown_future = async move { + shutdown_clone.notified().await; + println!("CLN mint shutdown signal received"); + }; + + match cdk_mintd::run_mintd_with_shutdown( + &temp_dir, + &settings, + shutdown_future, + None, + None, + vec![], + ) + .await + { + Ok(_) => println!("CLN mint exited normally"), + Err(e) => eprintln!("CLN mint exited with error: {e}"), + } + }); + + Ok(handle) +} + +/// Start regtest LND mint using the library +async fn start_lnd_mint( + temp_dir: &Path, + port: u16, + shutdown: Arc, +) -> Result> { + let lnd_cert_file = temp_dir.join("lnd").join("two").join("tls.cert"); + let lnd_macaroon_file = temp_dir + .join("lnd") + .join("two") + .join("data") + .join("chain") + .join("bitcoin") + .join("regtest") + .join("admin.macaroon"); + let lnd_work_dir = temp_dir.join("lnd_mint"); + + // Create work directory for LND mint + fs::create_dir_all(&lnd_work_dir)?; + + let lnd_config = cdk_mintd::config::Lnd { + address: "https://localhost:10010".to_string(), + cert_file: lnd_cert_file, + macaroon_file: lnd_macaroon_file, + fee_percent: 0.0, + reserve_fee_min: 0.into(), + }; + + // Create settings struct for LND mint using shared function + let settings = shared::create_lnd_settings( + port, + lnd_config, + "cattle gold bind busy sound reduce tone addict baby spend february strategy".to_string(), + ); + + println!("Starting LND mintd on port {port}"); + + let lnd_work_dir = lnd_work_dir.clone(); + let shutdown_clone = shutdown.clone(); + + // Run the mint in a separate task + let handle = tokio::spawn(async move { + // Create a future that resolves when the shutdown signal is received + let shutdown_future = async move { + shutdown_clone.notified().await; + println!("LND mint shutdown signal received"); + }; + + match cdk_mintd::run_mintd_with_shutdown( + &lnd_work_dir, + &settings, + shutdown_future, + None, + None, + vec![], + ) + .await + { + Ok(_) => println!("LND mint exited normally"), + Err(e) => eprintln!("LND mint exited with error: {e}"), + } + }); + + Ok(handle) +} + +/// Start regtest LDK mint using the library +async fn start_ldk_mint( + temp_dir: &Path, + port: u16, + shutdown: Arc, + runtime: Option>, +) -> Result> { + let ldk_work_dir = temp_dir.join("ldk_mint"); + + // Create work directory for LDK mint + fs::create_dir_all(&ldk_work_dir)?; + + // Configure LDK node for regtest + let ldk_config = cdk_mintd::config::LdkNode { + fee_percent: 0.0, + reserve_fee_min: 0.into(), + bitcoin_network: Some("regtest".to_string()), + // Use bitcoind RPC for regtest + chain_source_type: Some("bitcoinrpc".to_string()), + bitcoind_rpc_host: Some("127.0.0.1".to_string()), + bitcoind_rpc_port: Some(18443), + bitcoind_rpc_user: Some("testuser".to_string()), + bitcoind_rpc_password: Some("testpass".to_string()), + esplora_url: None, + storage_dir_path: Some(ldk_work_dir.to_string_lossy().to_string()), + ldk_node_host: Some("127.0.0.1".to_string()), + ldk_node_port: Some(port + 10), // Use a different port for the LDK node P2P connections + gossip_source_type: None, + rgs_url: None, + webserver_host: Some("127.0.0.1".to_string()), + webserver_port: Some(port + 1), // Use next port for web interface + }; + + // Create settings struct for LDK mint using a new shared function + let settings = create_ldk_settings(port, ldk_config, Mnemonic::generate(12)?.to_string()); + + println!("Starting LDK mintd on port {port}"); + + let ldk_work_dir = ldk_work_dir.clone(); + let shutdown_clone = shutdown.clone(); + + // Run the mint in a separate task + let handle = tokio::spawn(async move { + // Create a future that resolves when the shutdown signal is received + let shutdown_future = async move { + shutdown_clone.notified().await; + println!("LDK mint shutdown signal received"); + }; + + match cdk_mintd::run_mintd_with_shutdown( + &ldk_work_dir, + &settings, + shutdown_future, + None, + runtime, + vec![], + ) + .await + { + Ok(_) => println!("LDK mint exited normally"), + Err(e) => eprintln!("LDK mint exited with error: {e}"), + } + }); + + Ok(handle) +} + +/// Create settings for an LDK mint +fn create_ldk_settings( + port: u16, + ldk_config: cdk_mintd::config::LdkNode, + mnemonic: String, +) -> cdk_mintd::config::Settings { + cdk_mintd::config::Settings { + info: cdk_mintd::config::Info { + quote_ttl: None, + url: format!("http://127.0.0.1:{port}"), + listen_host: "127.0.0.1".to_string(), + listen_port: port, + seed: None, + mnemonic: Some(mnemonic), + signatory_url: None, + signatory_certs: None, + input_fee_ppk: None, + http_cache: cdk_axum::cache::Config::default(), + enable_swagger_ui: None, + logging: LoggingConfig::default(), + }, + mint_info: cdk_mintd::config::MintInfo::default(), + ln: cdk_mintd::config::Ln { + ln_backend: cdk_mintd::config::LnBackend::LdkNode, + invoice_description: None, + min_mint: 1.into(), + max_mint: 500_000.into(), + min_melt: 1.into(), + max_melt: 500_000.into(), + }, + cln: None, + lnbits: None, + lnd: None, + ldk_node: Some(ldk_config), + fake_wallet: None, + grpc_processor: None, + database: cdk_mintd::config::Database::default(), + auth_database: None, + mint_management_rpc: None, + prometheus: None, + auth: None, + portal_wallet: None, + } +} + +fn main() -> Result<()> { + let rt = Arc::new(Runtime::new()?); + + let rt_clone = Arc::clone(&rt); + + rt.block_on(async { + let args = Args::parse(); + + // Initialize logging based on CLI arguments + shared::setup_logging(&args.common); + + let temp_dir = shared::init_working_directory(&args.work_dir)?; + + // Write environment variables to a .env file in the temp_dir + let mint_url_1 = format!("http://{}:{}", args.mint_addr, args.cln_port); + let mint_url_2 = format!("http://{}:{}", args.mint_addr, args.lnd_port); + let mint_url_3 = format!("http://{}:{}", args.mint_addr, args.ldk_port); + let env_vars: Vec<(&str, &str)> = vec![ + ("CDK_TEST_MINT_URL", &mint_url_1), + ("CDK_TEST_MINT_URL_2", &mint_url_2), + ("CDK_TEST_MINT_URL_3", &mint_url_3), + ]; + + shared::write_env_file(&temp_dir, &env_vars)?; + + // Start regtest + println!("Starting regtest..."); + + let shutdown_regtest = shared::create_shutdown_handler(); + let shutdown_clone = shutdown_regtest.clone(); + let (tx, rx) = oneshot::channel(); + + let shutdown_clone_one = Arc::clone(&shutdown_clone); + + let ldk_work_dir = temp_dir.join("ldk_mint"); + let cdk_ldk = CdkLdkNode::new( + bitcoin::Network::Regtest, + cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig { + host: "127.0.0.1".to_string(), + port: 18443, + user: "testuser".to_string(), + password: "testpass".to_string(), + }), + cdk_ldk_node::GossipSource::P2P, + ldk_work_dir.to_string_lossy().to_string(), + cdk_common::common::FeeReserve { + min_fee_reserve: Amount::ZERO, + percent_fee_reserve: 0.0, + }, + vec![SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: 8092, + }], + Some(Arc::clone(&rt_clone)), + )?; + + let inner_node = cdk_ldk.node(); + + let temp_dir_clone = temp_dir.clone(); + let shutdown_clone_two = Arc::clone(&shutdown_clone); + tokio::spawn(async move { + start_regtest_end(&temp_dir_clone, tx, shutdown_clone_two, Some(inner_node)) + .await + .expect("Error starting regtest"); + }); + + match timeout(Duration::from_secs(300), rx).await { + Ok(k) => { + k?; + tracing::info!("Regtest set up"); + } + Err(_) => { + tracing::error!("regtest setup timed out after 5 minutes"); + anyhow::bail!("Could not set up regtest"); + } + } + + println!("lnd port: {}", args.ldk_port); + + // Start LND mint + let lnd_handle = start_lnd_mint(&temp_dir, args.lnd_port, shutdown_clone.clone()).await?; + + // Start LDK mint + let ldk_handle = start_ldk_mint( + &temp_dir, + args.ldk_port, + shutdown_clone.clone(), + Some(rt_clone), + ) + .await?; + + // Start CLN mint + let cln_handle = start_cln_mint(&temp_dir, args.cln_port, shutdown_clone.clone()).await?; + + let cancel_token = Arc::new(CancellationToken::new()); + + // Set up Ctrl+C handler before waiting for mints to be ready + let ctrl_c_token = Arc::clone(&cancel_token); + + let s_u = shutdown_clone.clone(); + tokio::spawn(async move { + signal::ctrl_c() + .await + .expect("failed to install CTRL+C handler"); + tracing::info!("Shutdown signal received during mint setup"); + println!("\nReceived Ctrl+C, shutting down..."); + ctrl_c_token.cancel(); + s_u.notify_waiters(); + }); + + match tokio::try_join!( + shared::wait_for_mint_ready_with_shutdown( + args.lnd_port, + 100, + Arc::clone(&cancel_token) + ), + shared::wait_for_mint_ready_with_shutdown( + args.ldk_port, + 100, + Arc::clone(&cancel_token) + ), + shared::wait_for_mint_ready_with_shutdown( + args.cln_port, + 100, + Arc::clone(&cancel_token) + ), + ) { + Ok(_) => println!("All mints are ready!"), + Err(e) => { + if cancel_token.is_cancelled() { + bail!("Startup canceled by user"); + } + eprintln!("Error waiting for mints to be ready: {e}"); + return Err(e); + } + } + + if cancel_token.is_cancelled() { + bail!("Token canceled"); + } + + println!("All regtest mints started successfully!"); + println!("CLN mint: http://{}:{}", args.mint_addr, args.cln_port); + println!("LND mint: http://{}:{}", args.mint_addr, args.lnd_port); + println!("LDK mint: http://{}:{}", args.mint_addr, args.ldk_port); + shared::display_mint_info(args.cln_port, &temp_dir, &args.database_type); // Using CLN port for display + println!(); + println!("Environment variables set:"); + println!( + " CDK_TEST_MINT_URL=http://{}:{}", + args.mint_addr, args.cln_port + ); + println!( + " CDK_TEST_MINT_URL_2=http://{}:{}", + args.mint_addr, args.lnd_port + ); + println!( + " CDK_TEST_MINT_URL_3=http://{}:{}", + args.mint_addr, args.ldk_port + ); + println!(" CDK_ITESTS_DIR={}", temp_dir.display()); + println!(); + println!("You can now run integration tests with:"); + println!(" cargo test -p cdk-integration-tests --test regtest"); + println!(" cargo test -p cdk-integration-tests --test happy_path_mint_wallet"); + println!(" etc."); + println!(); + + println!("Press Ctrl+C to stop the mints..."); + + // Create a future to wait for either Ctrl+C signal or unexpected mint termination + let shutdown_future = async { + // Wait for either SIGINT (Ctrl+C) or SIGTERM + let mut sigterm = signal::unix::signal(SignalKind::terminate()) + .expect("Failed to create SIGTERM signal handler"); + tokio::select! { + _ = signal::ctrl_c() => { + tracing::info!("Received SIGINT (Ctrl+C), shutting down mints..."); + } + _ = sigterm.recv() => { + tracing::info!("Received SIGTERM, shutting down mints..."); + } + } + println!("\nShutdown signal received, shutting down mints..."); + shutdown_clone.notify_waiters(); + }; + + // Monitor mint handles for unexpected termination + let monitor_mints = async { + loop { + if cln_handle.is_finished() { + println!("CLN mint finished unexpectedly"); + return; + } + if lnd_handle.is_finished() { + println!("LND mint finished unexpectedly"); + return; + } + if ldk_handle.is_finished() { + println!("LDK mint finished unexpectedly"); + return; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + + // Wait for either shutdown signal or mint termination + tokio::select! { + _ = shutdown_clone_one.notified() => { + println!("Shutdown signal received, waiting for mints to stop..."); + } + _ = monitor_mints => { + println!("One or more mints terminated unexpectedly"); + } + _ = shutdown_future => () + } + + // Wait for mints to finish gracefully + if let Err(e) = tokio::try_join!(ldk_handle, cln_handle, lnd_handle) { + eprintln!("Error waiting for mints to shut down: {e}"); + } + + println!("All services shut down successfully"); + + Ok(()) + }) +} diff --git a/crates/cdk-integration-tests/src/cli.rs b/crates/cdk-integration-tests/src/cli.rs new file mode 100644 index 000000000..0c2ea30ea --- /dev/null +++ b/crates/cdk-integration-tests/src/cli.rs @@ -0,0 +1,42 @@ +//! Common CLI and logging utilities for CDK integration test binaries +//! +//! This module provides standardized CLI argument parsing and logging setup +//! for integration test binaries. + +use clap::Parser; +use tracing_subscriber::EnvFilter; + +/// Common CLI arguments for CDK integration test binaries +#[derive(Parser, Debug)] +pub struct CommonArgs { + /// Enable logging (default is false) + #[arg(long, default_value_t = false)] + pub enable_logging: bool, + + /// Logging level when enabled (default is debug) + #[arg(long, default_value = "debug")] + pub log_level: tracing::Level, +} + +/// Initialize logging based on CLI arguments +pub fn init_logging(enable_logging: bool, log_level: tracing::Level) { + if enable_logging { + let default_filter = log_level.to_string(); + + // Common filters to reduce noise + let hyper_filter = "hyper=warn"; + let h2_filter = "h2=warn"; + let rustls_filter = "rustls=warn"; + let reqwest_filter = "reqwest=warn"; + let tower_filter = "tower_http=warn"; + + let env_filter = EnvFilter::new(format!( + "{default_filter},{hyper_filter},{h2_filter},{rustls_filter},{reqwest_filter},{tower_filter}" + )); + + // Ok if successful, Err if already initialized + let _ = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .try_init(); + } +} diff --git a/crates/cdk-integration-tests/src/init_auth_mint.rs b/crates/cdk-integration-tests/src/init_auth_mint.rs index 822334dae..63ca543e8 100644 --- a/crates/cdk-integration-tests/src/init_auth_mint.rs +++ b/crates/cdk-integration-tests/src/init_auth_mint.rs @@ -29,16 +29,18 @@ where percent_fee_reserve: 1.0, }; - let fake_wallet = FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0); - - let mut mint_builder = MintBuilder::new(); + let fake_wallet = FakeWallet::new( + fee_reserve, + HashMap::default(), + HashSet::default(), + 2, + CurrencyUnit::Sat, + ); - mint_builder = mint_builder - .with_localstore(Arc::new(database)) - .with_keystore(Arc::new(key_store)); + let mut mint_builder = MintBuilder::new(Arc::new(database)); - mint_builder = mint_builder - .add_ln_backend( + mint_builder + .add_payment_processor( CurrencyUnit::Sat, PaymentMethod::Bolt11, MintMeltLimits::new(1, 300), @@ -46,10 +48,14 @@ where ) .await?; - mint_builder = - mint_builder.set_clear_auth_settings(openid_discovery, "cashu-client".to_string()); + let auth_database = Arc::new(auth_database); - mint_builder = mint_builder.set_blind_auth_settings(50); + mint_builder = mint_builder.with_auth( + auth_database.clone(), + openid_discovery, + "cashu-client".to_string(), + vec![], + ); let blind_auth_endpoints = vec![ ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt11), @@ -71,6 +77,8 @@ where acc }); + mint_builder = mint_builder.with_blind_auth(50, blind_auth_endpoints.keys().cloned().collect()); + let mut tx = auth_database.begin_transaction().await?; tx.add_protected_endpoints(blind_auth_endpoints).await?; @@ -85,15 +93,13 @@ where tx.commit().await?; - mint_builder = mint_builder.with_auth_localstore(Arc::new(auth_database)); - let mnemonic = Mnemonic::generate(12)?; - mint_builder = mint_builder - .with_description("fake test mint".to_string()) - .with_seed(mnemonic.to_seed_normalized("").to_vec()); + mint_builder = mint_builder.with_description("fake test mint".to_string()); - let _mint = mint_builder.build().await?; + let _mint = mint_builder + .build_with_seed(Arc::new(key_store), &mnemonic.to_seed_normalized("")) + .await?; todo!("Need to start this a cdk mintd keeping as ref for now"); } diff --git a/crates/cdk-integration-tests/src/init_pure_tests.rs b/crates/cdk-integration-tests/src/init_pure_tests.rs index 155f03e34..fc76324e0 100644 --- a/crates/cdk-integration-tests/src/init_pure_tests.rs +++ b/crates/cdk-integration-tests/src/init_pure_tests.rs @@ -8,6 +8,8 @@ use std::{env, fs}; use anyhow::{anyhow, bail, Result}; use async_trait::async_trait; use bip39::Mnemonic; +use cashu::quote_id::QuoteId; +use cashu::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; use cdk::amount::SplitTarget; use cdk::cdk_database::{self, WalletDatabase}; use cdk::mint::{MintBuilder, MintMeltLimits}; @@ -21,14 +23,12 @@ use cdk::nuts::{ use cdk::types::{FeeReserve, QuoteTTL}; use cdk::util::unix_time; use cdk::wallet::{AuthWallet, MintConnector, Wallet, WalletBuilder}; -use cdk::{Amount, Error, Mint}; +use cdk::{Amount, Error, Mint, StreamExt}; use cdk_fake_wallet::FakeWallet; -use tokio::sync::{Notify, RwLock}; +use tokio::sync::RwLock; use tracing_subscriber::EnvFilter; use uuid::Uuid; -use crate::wait_for_mint_to_be_paid; - pub struct DirectMintConnection { pub mint: Mint, auth_wallet: Arc>>, @@ -55,6 +55,24 @@ impl Debug for DirectMintConnection { /// Convert the requests and responses between the [String] and [Uuid] variants as necessary. #[async_trait] impl MintConnector for DirectMintConnection { + async fn resolve_dns_txt(&self, _domain: &str) -> Result, Error> { + panic!("Not implemented"); + } + + async fn fetch_lnurl_pay_request( + &self, + _url: &str, + ) -> Result { + unimplemented!("Lightning address not supported in DirectMintConnection") + } + + async fn fetch_lnurl_invoice( + &self, + _url: &str, + ) -> Result { + unimplemented!("Lightning address not supported in DirectMintConnection") + } + async fn get_mint_keys(&self) -> Result, Error> { Ok(self.mint.pubkeys().keysets) } @@ -72,7 +90,7 @@ impl MintConnector for DirectMintConnection { request: MintQuoteBolt11Request, ) -> Result, Error> { self.mint - .get_mint_bolt11_quote(request) + .get_mint_quote(request.into()) .await .map(Into::into) } @@ -81,16 +99,15 @@ impl MintConnector for DirectMintConnection { &self, quote_id: &str, ) -> Result, Error> { - let quote_id_uuid = Uuid::from_str(quote_id).unwrap(); self.mint - .check_mint_quote("e_id_uuid) + .check_mint_quote(&QuoteId::from_str(quote_id)?) .await .map(Into::into) } async fn post_mint(&self, request: MintRequest) -> Result { - let request_uuid = request.try_into().unwrap(); - self.mint.process_mint_request(request_uuid).await + let request_id: MintRequest = request.try_into().unwrap(); + self.mint.process_mint_request(request_id).await } async fn post_melt_quote( @@ -98,7 +115,7 @@ impl MintConnector for DirectMintConnection { request: MeltQuoteBolt11Request, ) -> Result, Error> { self.mint - .get_melt_bolt11_quote(&request) + .get_melt_quote(request.into()) .await .map(Into::into) } @@ -107,9 +124,8 @@ impl MintConnector for DirectMintConnection { &self, quote_id: &str, ) -> Result, Error> { - let quote_id_uuid = Uuid::from_str(quote_id).unwrap(); self.mint - .check_melt_quote("e_id_uuid) + .check_melt_quote(&QuoteId::from_str(quote_id)?) .await .map(Into::into) } @@ -119,7 +135,7 @@ impl MintConnector for DirectMintConnection { request: MeltRequest, ) -> Result, Error> { let request_uuid = request.try_into().unwrap(); - self.mint.melt_bolt11(&request_uuid).await.map(Into::into) + self.mint.melt(&request_uuid).await.map(Into::into) } async fn post_swap(&self, swap_request: SwapRequest) -> Result { @@ -152,15 +168,66 @@ impl MintConnector for DirectMintConnection { *auth_wallet = wallet; } + + async fn post_mint_bolt12_quote( + &self, + request: MintQuoteBolt12Request, + ) -> Result, Error> { + let res: MintQuoteBolt12Response = + self.mint.get_mint_quote(request.into()).await?.try_into()?; + Ok(res.into()) + } + + async fn get_mint_quote_bolt12_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let quote: MintQuoteBolt12Response = self + .mint + .check_mint_quote(&QuoteId::from_str(quote_id)?) + .await? + .try_into()?; + + Ok(quote.into()) + } + + /// Melt Quote [NUT-23] + async fn post_melt_bolt12_quote( + &self, + request: MeltQuoteBolt12Request, + ) -> Result, Error> { + self.mint + .get_melt_quote(request.into()) + .await + .map(Into::into) + } + /// Melt Quote Status [NUT-23] + async fn get_melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + self.mint + .check_melt_quote(&QuoteId::from_str(quote_id)?) + .await + .map(Into::into) + } + /// Melt [NUT-23] + async fn post_melt_bolt12( + &self, + _request: MeltRequest, + ) -> Result, Error> { + // Implementation to be added later + Err(Error::UnsupportedPaymentMethod) + } } pub fn setup_tracing() { let default_filter = "debug"; - let sqlx_filter = "sqlx=warn"; + let h2_filter = "h2=warn"; let hyper_filter = "hyper=warn"; - let env_filter = EnvFilter::new(format!("{default_filter},{sqlx_filter},{hyper_filter}")); + let env_filter = EnvFilter::new(format!("{default_filter},{h2_filter},{hyper_filter}")); // Ok if successful, Err if already initialized // Allows us to setup tracing at the start of several parallel tests @@ -173,25 +240,22 @@ pub async fn create_and_start_test_mint() -> Result { // Read environment variable to determine database type let db_type = env::var("CDK_TEST_DB_TYPE").expect("Database type set"); - let mut mint_builder = match db_type.to_lowercase().as_str() { - "memory" => MintBuilder::new() - .with_localstore(Arc::new(cdk_sqlite::mint::memory::empty().await?)) - .with_keystore(Arc::new(cdk_sqlite::mint::memory::empty().await?)), + let localstore = match db_type.to_lowercase().as_str() { + "memory" => Arc::new(cdk_sqlite::mint::memory::empty().await?), _ => { // Create a temporary directory for SQLite database let temp_dir = create_temp_dir("cdk-test-sqlite-mint")?; let path = temp_dir.join("mint.db").to_str().unwrap().to_string(); - let database = Arc::new( - cdk_sqlite::MintSqliteDatabase::new(&path) + Arc::new( + cdk_sqlite::MintSqliteDatabase::new(path.as_str()) .await .expect("Could not create sqlite db"), - ); - MintBuilder::new() - .with_localstore(database.clone()) - .with_keystore(database) + ) } }; + let mut mint_builder = MintBuilder::new(localstore.clone()); + let fee_reserve = FeeReserve { min_fee_reserve: 1.into(), percent_fee_reserve: 1.0, @@ -201,11 +265,12 @@ pub async fn create_and_start_test_mint() -> Result { fee_reserve.clone(), HashMap::default(), HashSet::default(), - 0, + 2, + CurrencyUnit::Sat, ); - mint_builder = mint_builder - .add_ln_backend( + mint_builder + .add_payment_processor( CurrencyUnit::Sat, PaymentMethod::Bolt11, MintMeltLimits::new(1, 10_000), @@ -218,30 +283,17 @@ pub async fn create_and_start_test_mint() -> Result { mint_builder = mint_builder .with_name("pure test mint".to_string()) .with_description("pure test mint".to_string()) - .with_urls(vec!["https://aaa".to_string()]) - .with_seed(mnemonic.to_seed_normalized("").to_vec()); - - let localstore = mint_builder - .localstore - .as_ref() - .map(|x| x.clone()) - .expect("localstore"); - - let mut tx = localstore.begin_transaction().await?; - tx.set_mint_info(mint_builder.mint_info.clone()).await?; + .with_urls(vec!["https://aaa".to_string()]); let quote_ttl = QuoteTTL::new(10000, 10000); - tx.set_quote_ttl(quote_ttl).await?; - tx.commit().await?; - let mint = mint_builder.build().await?; + let mint = mint_builder + .build_with_seed(localstore.clone(), &mnemonic.to_seed_normalized("")) + .await?; - let mint_clone = mint.clone(); - let shutdown = Arc::new(Notify::new()); - tokio::spawn({ - let shutdown = Arc::clone(&shutdown); - async move { mint_clone.wait_for_paid_invoices(shutdown).await } - }); + mint.set_quote_ttl(quote_ttl).await?; + + mint.start().await?; Ok(mint) } @@ -269,7 +321,7 @@ pub async fn create_test_wallet_for_mint(mint: Mint) -> Result { // Create a temporary directory for SQLite database let temp_dir = create_temp_dir("cdk-test-sqlite-wallet")?; let path = temp_dir.join("wallet.db").to_str().unwrap().to_string(); - let database = cdk_sqlite::WalletSqliteDatabase::new(&path) + let database = cdk_sqlite::WalletSqliteDatabase::new(path.as_str()) .await .expect("Could not create sqlite db"); Arc::new(database) @@ -295,7 +347,7 @@ pub async fn create_test_wallet_for_mint(mint: Mint) -> Result { .mint_url(mint_url.parse().unwrap()) .unit(unit) .localstore(localstore) - .seed(&seed) + .seed(seed) .client(connector) .build()?; @@ -320,10 +372,10 @@ pub async fn fund_wallet( let desired_amount = Amount::from(amount); let quote = wallet.mint_quote(desired_amount, None).await?; - wait_for_mint_to_be_paid(&wallet, "e.id, 60).await?; - Ok(wallet - .mint("e.id, split_target.unwrap_or_default(), None) - .await? + .proof_stream(quote, split_target.unwrap_or_default(), None) + .next() + .await + .expect("proofs")? .total_amount()?) } diff --git a/crates/cdk-integration-tests/src/init_regtest.rs b/crates/cdk-integration-tests/src/init_regtest.rs index fa021f8e6..8bb668654 100644 --- a/crates/cdk-integration-tests/src/init_regtest.rs +++ b/crates/cdk-integration-tests/src/init_regtest.rs @@ -1,11 +1,16 @@ use std::env; +use std::net::Ipv4Addr; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; use cdk::types::FeeReserve; use cdk_cln::Cln as CdkCln; +use cdk_common::database::mint::DynMintKVStore; use cdk_lnd::Lnd as CdkLnd; +use cdk_sqlite::mint::memory; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::Node; use ln_regtest_rs::bitcoin_client::BitcoinClient; use ln_regtest_rs::bitcoind::Bitcoind; use ln_regtest_rs::cln::Clnd; @@ -31,21 +36,41 @@ pub const LND_TWO_RPC_ADDR: &str = "localhost:10010"; pub const CLN_ADDR: &str = "127.0.0.1:19846"; pub const CLN_TWO_ADDR: &str = "127.0.0.1:19847"; -pub fn get_mint_addr() -> String { - env::var("CDK_ITESTS_MINT_ADDR").expect("Mint address not set") +/// Configuration for regtest environment +pub struct RegtestConfig { + pub mint_addr: String, + pub cln_port: u16, + pub lnd_port: u16, + pub temp_dir: PathBuf, } -pub fn get_mint_port(which: &str) -> u16 { - let dir = env::var(format!("CDK_ITESTS_MINT_PORT_{which}")).expect("Mint port not set"); - dir.parse().unwrap() +impl Default for RegtestConfig { + fn default() -> Self { + Self { + mint_addr: "127.0.0.1".to_string(), + cln_port: 8085, + lnd_port: 8087, + temp_dir: std::env::temp_dir().join("cdk-itests-default"), + } + } } -pub fn get_mint_url(which: &str) -> String { - format!("http://{}:{}", get_mint_addr(), get_mint_port(which)) +pub fn get_mint_url_with_config(config: &RegtestConfig, which: &str) -> String { + let port = match which { + "0" => config.cln_port, + "1" => config.lnd_port, + _ => panic!("Unknown mint identifier: {which}"), + }; + format!("http://{}:{}", config.mint_addr, port) } -pub fn get_mint_ws_url(which: &str) -> String { - format!("ws://{}:{}/v1/ws", get_mint_addr(), get_mint_port(which)) +pub fn get_mint_ws_url_with_config(config: &RegtestConfig, which: &str) -> String { + let port = match which { + "0" => config.cln_port, + "1" => config.lnd_port, + _ => panic!("Unknown mint identifier: {which}"), + }; + format!("ws://{}:{}/v1/ws", config.mint_addr, port) } pub fn get_temp_dir() -> PathBuf { @@ -54,15 +79,19 @@ pub fn get_temp_dir() -> PathBuf { dir.parse().expect("Valid path buf") } -pub fn get_bitcoin_dir() -> PathBuf { - let dir = get_temp_dir().join(BITCOIN_DIR); +pub fn get_temp_dir_with_config(config: &RegtestConfig) -> &PathBuf { + &config.temp_dir +} + +pub fn get_bitcoin_dir(temp_dir: &Path) -> PathBuf { + let dir = temp_dir.join(BITCOIN_DIR); std::fs::create_dir_all(&dir).unwrap(); dir } -pub fn init_bitcoind() -> Bitcoind { +pub fn init_bitcoind(work_dir: &Path) -> Bitcoind { Bitcoind::new( - get_bitcoin_dir(), + get_bitcoin_dir(work_dir), BITCOIND_ADDR.parse().unwrap(), BITCOIN_RPC_USER.to_string(), BITCOIN_RPC_PASS.to_string(), @@ -81,14 +110,14 @@ pub fn init_bitcoin_client() -> Result { ) } -pub fn get_cln_dir(name: &str) -> PathBuf { - let dir = get_temp_dir().join("cln").join(name); +pub fn get_cln_dir(work_dir: &Path, name: &str) -> PathBuf { + let dir = work_dir.join("cln").join(name); std::fs::create_dir_all(&dir).unwrap(); dir } -pub fn get_lnd_dir(name: &str) -> PathBuf { - let dir = get_temp_dir().join("lnd").join(name); +pub fn get_lnd_dir(work_dir: &Path, name: &str) -> PathBuf { + let dir = work_dir.join("lnd").join(name); std::fs::create_dir_all(&dir).unwrap(); dir } @@ -101,9 +130,14 @@ pub fn get_lnd_macaroon_path(lnd_dir: &Path) -> PathBuf { lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon") } -pub async fn init_lnd(lnd_dir: PathBuf, lnd_addr: &str, lnd_rpc_addr: &str) -> Lnd { +pub async fn init_lnd( + work_dir: &Path, + lnd_dir: PathBuf, + lnd_addr: &str, + lnd_rpc_addr: &str, +) -> Lnd { Lnd::new( - get_bitcoin_dir(), + get_bitcoin_dir(work_dir), lnd_dir, lnd_addr.parse().unwrap(), lnd_rpc_addr.to_string(), @@ -116,6 +150,9 @@ pub async fn init_lnd(lnd_dir: PathBuf, lnd_addr: &str, lnd_rpc_addr: &str) -> L pub fn generate_block(bitcoin_client: &BitcoinClient) -> Result<()> { let mine_to_address = bitcoin_client.get_new_address()?; + let blocks = 10; + tracing::info!("Mining {blocks} blocks to {mine_to_address}"); + bitcoin_client.generate_blocks(&mine_to_address, 10)?; Ok(()) @@ -129,7 +166,8 @@ pub async fn create_cln_backend(cln_client: &ClnClient) -> Result { percent_fee_reserve: 1.0, }; - Ok(CdkCln::new(rpc_path, fee_reserve).await?) + let kv_store: DynMintKVStore = Arc::new(memory::empty().await?); + Ok(CdkCln::new(rpc_path, fee_reserve, kv_store).await?) } pub async fn create_lnd_backend(lnd_client: &LndClient) -> Result { @@ -138,11 +176,14 @@ pub async fn create_lnd_backend(lnd_client: &LndClient) -> Result { percent_fee_reserve: 1.0, }; + let kv_store: DynMintKVStore = Arc::new(memory::empty().await?); + Ok(CdkLnd::new( lnd_client.address.clone(), lnd_client.cert_file.clone(), lnd_client.macaroon_file.clone(), fee_reserve, + kv_store, ) .await?) } @@ -192,8 +233,13 @@ where Ok(()) } -pub async fn start_regtest_end(sender: Sender<()>, notify: Arc) -> anyhow::Result<()> { - let mut bitcoind = init_bitcoind(); +pub async fn start_regtest_end( + work_dir: &Path, + sender: Sender<()>, + notify: Arc, + ldk_node: Option>, +) -> anyhow::Result<()> { + let mut bitcoind = init_bitcoind(work_dir); bitcoind.start_bitcoind()?; let bitcoin_client = init_bitcoin_client()?; @@ -203,9 +249,9 @@ pub async fn start_regtest_end(sender: Sender<()>, notify: Arc) -> anyho let new_add = bitcoin_client.get_new_address()?; bitcoin_client.generate_blocks(&new_add, 200).unwrap(); - let cln_one_dir = get_cln_dir("one"); + let cln_one_dir = get_cln_dir(work_dir, "one"); let mut clnd = Clnd::new( - get_bitcoin_dir(), + get_bitcoin_dir(work_dir), cln_one_dir.clone(), CLN_ADDR.into(), BITCOIN_RPC_USER.to_string(), @@ -220,9 +266,9 @@ pub async fn start_regtest_end(sender: Sender<()>, notify: Arc) -> anyho fund_ln(&bitcoin_client, &cln_client).await.unwrap(); // Create second cln - let cln_two_dir = get_cln_dir("two"); + let cln_two_dir = get_cln_dir(work_dir, "two"); let mut clnd_two = Clnd::new( - get_bitcoin_dir(), + get_bitcoin_dir(work_dir), cln_two_dir.clone(), CLN_TWO_ADDR.into(), BITCOIN_RPC_USER.to_string(), @@ -236,10 +282,10 @@ pub async fn start_regtest_end(sender: Sender<()>, notify: Arc) -> anyho fund_ln(&bitcoin_client, &cln_two_client).await.unwrap(); - let lnd_dir = get_lnd_dir("one"); + let lnd_dir = get_lnd_dir(work_dir, "one"); println!("{}", lnd_dir.display()); - let mut lnd = init_lnd(lnd_dir.clone(), LND_ADDR, LND_RPC_ADDR).await; + let mut lnd = init_lnd(work_dir, lnd_dir.clone(), LND_ADDR, LND_RPC_ADDR).await; lnd.start_lnd().unwrap(); tracing::info!("Started lnd node"); @@ -252,11 +298,25 @@ pub async fn start_regtest_end(sender: Sender<()>, notify: Arc) -> anyho lnd_client.wait_chain_sync().await.unwrap(); + if let Some(node) = ldk_node.as_ref() { + tracing::info!("Starting ldk node"); + node.start()?; + let addr = node.onchain_payment().new_address().unwrap(); + bitcoin_client.send_to_address(&addr.to_string(), 5_000_000)?; + } + fund_ln(&bitcoin_client, &lnd_client).await.unwrap(); // create second lnd node - let lnd_two_dir = get_lnd_dir("two"); - let mut lnd_two = init_lnd(lnd_two_dir.clone(), LND_TWO_ADDR, LND_TWO_RPC_ADDR).await; + let work_dir = get_temp_dir(); + let lnd_two_dir = get_lnd_dir(&work_dir, "two"); + let mut lnd_two = init_lnd( + &work_dir, + lnd_two_dir.clone(), + LND_TWO_ADDR, + LND_TWO_RPC_ADDR, + ) + .await; lnd_two.start_lnd().unwrap(); tracing::info!("Started second lnd node"); @@ -296,12 +356,108 @@ pub async fn start_regtest_end(sender: Sender<()>, notify: Arc) -> anyho tracing::info!("Opened channel between cln and lnd two"); generate_block(&bitcoin_client)?; - cln_client.wait_channels_active().await?; - cln_two_client.wait_channels_active().await?; - lnd_client.wait_channels_active().await?; - lnd_two_client.wait_channels_active().await?; + if let Some(node) = ldk_node { + let pubkey = node.node_id(); + let listen_addr = node.listening_addresses(); + let listen_addr = listen_addr.as_ref().unwrap().first().unwrap(); + + let (listen_addr, port) = match listen_addr { + SocketAddress::TcpIpV4 { addr, port } => (Ipv4Addr::from(*addr).to_string(), port), + _ => panic!(), + }; + + tracing::info!("Opening channel from cln to ldk"); + + cln_client + .connect_peer(pubkey.to_string(), listen_addr.clone(), *port) + .await?; + + cln_client + .open_channel(1_500_000, &pubkey.to_string(), Some(750_000)) + .await + .unwrap(); + + generate_block(&bitcoin_client)?; + + let cln_two_info = cln_two_client.get_connect_info().await?; + + cln_client + .connect_peer(cln_two_info.pubkey, listen_addr.clone(), cln_two_info.port) + .await?; + + tracing::info!("Opening channel from lnd to ldk"); + + let cln_info = cln_client.get_connect_info().await?; + + node.connect( + cln_info.pubkey.parse()?, + SocketAddress::TcpIpV4 { + addr: cln_info + .address + .split('.') + .map(|part| part.parse()) + .collect::, _>>()? + .try_into() + .unwrap(), + port: cln_info.port, + }, + true, + )?; + + let lnd_info = lnd_client.get_connect_info().await?; + + node.connect( + lnd_info.pubkey.parse()?, + SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: lnd_info.port, + }, + true, + )?; + + // lnd_client + // .open_channel(1_500_000, &pubkey.to_string(), Some(750_000)) + // .await + // .unwrap(); + + generate_block(&bitcoin_client)?; + lnd_client.wait_chain_sync().await?; + + node.open_announced_channel( + lnd_info.pubkey.parse()?, + SocketAddress::TcpIpV4 { + addr: [127, 0, 0, 1], + port: lnd_info.port, + }, + 1_000_000, + Some(500_000_000), + None, + )?; + + generate_block(&bitcoin_client)?; + + tracing::info!("Ldk channels opened"); + + node.sync_wallets()?; + + tracing::info!("Ldk wallet synced"); + + cln_client.wait_channels_active().await?; + + lnd_client.wait_channels_active().await?; + + node.stop()?; + } else { + cln_client.wait_channels_active().await?; + + lnd_client.wait_channels_active().await?; + + generate_block(&bitcoin_client)?; + } } + tracing::info!("Regtest channels active"); + // Send notification that regtest set up is complete sender.send(()).expect("Could not send oneshot"); diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index 304757111..65e274647 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -1,20 +1,51 @@ +//! Integration Test Library +//! +//! This crate provides shared functionality for CDK integration tests. +//! It includes utilities for setting up test environments, funding wallets, +//! and common test operations across different test scenarios. +//! +//! Test Categories Supported: +//! - Pure in-memory tests (no external dependencies) +//! - Regtest environment tests (with actual Lightning nodes) +//! - Authenticated mint tests +//! - Multi-mint scenarios +//! +//! Key Components: +//! - Test environment initialization +//! - Wallet funding utilities +//! - Lightning Network client helpers +//! - Proof state management utilities + use std::env; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{anyhow, bail, Result}; use cashu::Bolt11Invoice; use cdk::amount::{Amount, SplitTarget}; -use cdk::nuts::{MintQuoteState, NotificationPayload, State}; -use cdk::wallet::WalletSubscription; -use cdk::Wallet; +use cdk::{StreamExt, Wallet}; use cdk_fake_wallet::create_fake_invoice; -use init_regtest::{get_lnd_dir, get_mint_url, LND_RPC_ADDR}; -use ln_regtest_rs::ln_client::{LightningClient, LndClient}; -use tokio::time::{sleep, timeout, Duration}; +use init_regtest::{get_lnd_dir, LND_RPC_ADDR}; +use ln_regtest_rs::ln_client::{ClnClient, LightningClient, LndClient}; + +use crate::init_regtest::get_cln_dir; +pub mod cli; pub mod init_auth_mint; pub mod init_pure_tests; pub mod init_regtest; +pub mod shared; + +/// Generate standard keyset amounts as powers of 2 +/// +/// Returns a vector of amounts: [1, 2, 4, 8, 16, 32, ..., 2^(n-1)] +/// where n is the number of amounts to generate. +/// +/// # Arguments +/// * `max_order` - The maximum power of 2 (exclusive). For example, max_order=32 generates amounts up to 2^31 +pub fn standard_keyset_amounts(max_order: u32) -> Vec { + (0..max_order).map(|n| 2u64.pow(n)).collect() +} pub async fn fund_wallet(wallet: Arc, amount: Amount) { let quote = wallet @@ -22,136 +53,31 @@ pub async fn fund_wallet(wallet: Arc, amount: Amount) { .await .expect("Could not get mint quote"); - wait_for_mint_to_be_paid(&wallet, "e.id, 60) - .await - .expect("Waiting for mint failed"); - let _proofs = wallet - .mint("e.id, SplitTarget::default(), None) + .proof_stream(quote, SplitTarget::default(), None) + .next() .await - .expect("Could not mint"); -} - -// Get all pending from wallet and attempt to swap -// Will panic if there are no pending -// Will return Ok if swap fails as expected -pub async fn attempt_to_swap_pending(wallet: &Wallet) -> Result<()> { - let pending = wallet - .localstore - .get_proofs(None, None, Some(vec![State::Pending]), None) - .await?; - - assert!(!pending.is_empty()); - - let swap = wallet - .swap( - None, - SplitTarget::None, - pending.into_iter().map(|p| p.proof).collect(), - None, - false, - ) - .await; - - match swap { - Ok(_swap) => { - bail!("These proofs should be pending") - } - Err(err) => match err { - cdk::error::Error::TokenPending => (), - _ => { - println!("{err:?}"); - bail!("Wrong error") - } - }, - } - - Ok(()) -} - -pub async fn wait_for_mint_to_be_paid( - wallet: &Wallet, - mint_quote_id: &str, - timeout_secs: u64, -) -> Result<()> { - let mut subscription = wallet - .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![ - mint_quote_id.to_owned(), - ])) - .await; - // Create the timeout future - let wait_future = async { - while let Some(msg) = subscription.recv().await { - if let NotificationPayload::MintQuoteBolt11Response(response) = msg { - if response.state == MintQuoteState::Paid { - return Ok(()); - } - } - } - Err(anyhow!("Subscription ended without quote being paid")) - }; - - let timeout_future = timeout(Duration::from_secs(timeout_secs), wait_future); - - let check_interval = Duration::from_secs(5); - - let periodic_task = async { - loop { - match wallet.mint_quote_state(mint_quote_id).await { - Ok(result) => { - if result.state == MintQuoteState::Paid { - tracing::info!("mint quote paid via poll"); - return Ok(()); - } - } - Err(e) => { - tracing::error!("Could not check mint quote status: {:?}", e); - } - } - sleep(check_interval).await; - } - }; - - tokio::select! { - result = timeout_future => { - match result { - Ok(payment_result) => payment_result, - Err(_) => Err(anyhow!("Timeout waiting for mint quote to be paid")), - } - } - result = periodic_task => { - result // Now propagates the result from periodic checks - } - } + .expect("proofs") + .expect("proofs with no error"); } -/// Gets the mint URL from environment variable or falls back to default -/// -/// Checks the CDK_TEST_MINT_URL environment variable: -/// - If set, returns that URL -/// - Otherwise falls back to the default URL from get_mint_url("0") pub fn get_mint_url_from_env() -> String { match env::var("CDK_TEST_MINT_URL") { Ok(url) => url, - Err(_) => get_mint_url("0"), + Err(_) => panic!("Mint url not set"), } } -/// Gets the second mint URL from environment variable or falls back to default -/// -/// Checks the CDK_TEST_MINT_URL_2 environment variable: -/// - If set, returns that URL -/// - Otherwise falls back to the default URL from get_mint_url("1") pub fn get_second_mint_url_from_env() -> String { match env::var("CDK_TEST_MINT_URL_2") { Ok(url) => url, - Err(_) => get_mint_url("1"), + Err(_) => panic!("Mint url not set"), } } -// This is the ln wallet we use to send/receive ln payements as the wallet -pub async fn init_lnd_client() -> LndClient { - let lnd_dir = get_lnd_dir("one"); +// This is the ln wallet we use to send/receive ln payments as the wallet +pub async fn init_lnd_client(work_dir: &Path) -> LndClient { + let lnd_dir = get_lnd_dir(work_dir, "one"); let cert_file = lnd_dir.join("tls.cert"); let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon"); LndClient::new(format!("https://{LND_RPC_ADDR}"), cert_file, macaroon_file) @@ -163,12 +89,18 @@ pub async fn init_lnd_client() -> LndClient { /// /// This is useful for tests that need to pay invoices in regtest mode but /// should be skipped in other environments. -pub async fn pay_if_regtest(invoice: &Bolt11Invoice) -> Result<()> { +pub async fn pay_if_regtest(_work_dir: &Path, invoice: &Bolt11Invoice) -> Result<()> { // Check if the invoice is for the regtest network if invoice.network() == bitcoin::Network::Regtest { - println!("Regtest invoice"); - let lnd_client = init_lnd_client().await; - lnd_client.pay_invoice(invoice.to_string()).await?; + let client = get_test_client().await; + let mut tries = 0; + while let Err(err) = client.pay_invoice(invoice.to_string()).await { + println!("Could not pay invoice.retrying {err}"); + tries += 1; + if tries > 10 { + bail!("Could not pay invoice"); + } + } Ok(()) } else { // Not a regtest invoice, just return Ok @@ -197,9 +129,8 @@ pub fn is_regtest_env() -> bool { /// create a real regtest invoice or a fake one for testing. pub async fn create_invoice_for_env(amount_sat: Option) -> Result { if is_regtest_env() { - // In regtest mode, create a real invoice - let lnd_client = init_lnd_client().await; - lnd_client + let client = get_test_client().await; + client .create_invoice(amount_sat) .await .map_err(|e| anyhow!("Failed to create regtest invoice: {}", e)) @@ -212,3 +143,80 @@ pub async fn create_invoice_for_env(amount_sat: Option) -> Result { Ok(fake_invoice.to_string()) } } + +// This is the ln wallet we use to send/receive ln payments as the wallet +async fn _get_lnd_client() -> LndClient { + let temp_dir = get_work_dir(); + + // The LND mint uses the second LND node (LND_TWO_RPC_ADDR = localhost:10010) + let lnd_dir = get_lnd_dir(&temp_dir, "one"); + let cert_file = lnd_dir.join("tls.cert"); + let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon"); + + println!("Looking for LND cert file: {cert_file:?}"); + println!("Looking for LND macaroon file: {macaroon_file:?}"); + println!("Connecting to LND at: https://{LND_RPC_ADDR}"); + + // Connect to LND + LndClient::new( + format!("https://{LND_RPC_ADDR}"), + cert_file.clone(), + macaroon_file.clone(), + ) + .await + .expect("Could not connect to lnd rpc") +} + +/// Returns a Lightning client based on the CDK_TEST_LIGHTNING_CLIENT environment variable. +/// +/// Reads the CDK_TEST_LIGHTNING_CLIENT environment variable: +/// - "cln" or "CLN": returns a CLN client +/// - Anything else (or unset): returns an LND client (default) +pub async fn get_test_client() -> Box { + match env::var("CDK_TEST_LIGHTNING_CLIENT") { + Ok(val) => { + let val = val.to_lowercase(); + match val.as_str() { + "cln" => Box::new(create_cln_client_with_retry().await), + _ => Box::new(_get_lnd_client().await), + } + } + Err(_) => Box::new(_get_lnd_client().await), // Default to LND + } +} + +fn get_work_dir() -> PathBuf { + match env::var("CDK_ITESTS_DIR") { + Ok(dir) => { + let path = PathBuf::from(dir); + println!("Using temp directory from CDK_ITESTS_DIR: {path:?}"); + path + } + Err(_) => { + panic!("Unknown temp dir"); + } + } +} + +// Helper function to create CLN client with retries +async fn create_cln_client_with_retry() -> ClnClient { + let mut retries = 0; + let max_retries = 10; + + let cln_dir = get_cln_dir(&get_work_dir(), "one"); + loop { + match ClnClient::new(cln_dir.clone(), None).await { + Ok(client) => return client, + Err(e) => { + retries += 1; + if retries >= max_retries { + panic!("Could not connect to CLN client after {max_retries} retries: {e}"); + } + println!( + "Failed to connect to CLN (attempt {retries}/{max_retries}): {e}. Retrying in 7 seconds..." + ); + tokio::time::sleep(tokio::time::Duration::from_secs(7)).await; + } + } + } +} diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs new file mode 100644 index 000000000..3bdee6e13 --- /dev/null +++ b/crates/cdk-integration-tests/src/shared.rs @@ -0,0 +1,328 @@ +//! Shared utilities for mint integration tests +//! +//! This module provides common functionality used across different +//! integration test binaries to reduce code duplication. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use cdk_axum::cache; +use cdk_mintd::config::{Database, DatabaseEngine}; +use tokio::signal; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +use crate::cli::{init_logging, CommonArgs}; + +/// Default minimum mint amount for test mints +const DEFAULT_MIN_MINT: u64 = 1; +/// Default maximum mint amount for test mints +const DEFAULT_MAX_MINT: u64 = 500_000; +/// Default minimum melt amount for test mints +const DEFAULT_MIN_MELT: u64 = 1; +/// Default maximum melt amount for test mints +const DEFAULT_MAX_MELT: u64 = 500_000; + +/// Wait for mint to be ready by checking its info endpoint, with optional shutdown signal +pub async fn wait_for_mint_ready_with_shutdown( + port: u16, + timeout_secs: u64, + shutdown_notify: Arc, +) -> Result<()> { + let url = format!("http://127.0.0.1:{port}/v1/info"); + let start_time = std::time::Instant::now(); + + println!("Waiting for mint on port {port} to be ready..."); + + loop { + // Check if timeout has been reached + if start_time.elapsed().as_secs() > timeout_secs { + return Err(anyhow::anyhow!("Timeout waiting for mint on port {}", port)); + } + + if shutdown_notify.is_cancelled() { + return Err(anyhow::anyhow!("Canceled waiting for {}", port)); + } + + tokio::select! { + // Try to make a request to the mint info endpoint + result = reqwest::get(&url) => { + match result { + Ok(response) => { + if response.status().is_success() { + println!("Mint on port {port} is ready"); + return Ok(()); + } else { + println!( + "Mint on port {} returned status: {}", + port, + response.status() + ); + } + } + Err(e) => { + println!("Error connecting to mint on port {port}: {e}"); + } + } + } + + // Check for shutdown signal + _ = shutdown_notify.cancelled() => { + return Err(anyhow::anyhow!( + "Shutdown requested while waiting for mint on port {}", + port + )); + } + + + + } + } +} + +/// Initialize working directory +pub fn init_working_directory(work_dir: &str) -> Result { + let temp_dir = PathBuf::from_str(work_dir)?; + + // Create the temp directory if it doesn't exist + fs::create_dir_all(&temp_dir)?; + + Ok(temp_dir) +} + +/// Write environment variables to .env file +pub fn write_env_file(temp_dir: &Path, env_vars: &[(&str, &str)]) -> Result<()> { + let mut env_content = String::new(); + for (key, value) in env_vars { + env_content.push_str(&format!("{key}={value}\n")); + } + + let env_file_path = temp_dir.join(".env"); + + fs::write(&env_file_path, &env_content) + .map(|_| { + println!( + "Environment variables written to: {}", + env_file_path.display() + ); + }) + .map_err(|e| anyhow::anyhow!("Could not write .env file: {}", e)) +} + +/// Wait for .env file to be created +pub async fn wait_for_env_file(temp_dir: &Path, timeout_secs: u64) -> Result<()> { + let env_file_path = temp_dir.join(".env"); + let start_time = std::time::Instant::now(); + + println!( + "Waiting for .env file to be created at: {}", + env_file_path.display() + ); + + loop { + // Check if timeout has been reached + if start_time.elapsed().as_secs() > timeout_secs { + return Err(anyhow::anyhow!( + "Timeout waiting for .env file at {}", + env_file_path.display() + )); + } + + // Check if the file exists + if env_file_path.exists() { + println!(".env file found at: {}", env_file_path.display()); + return Ok(()); + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } +} + +/// Setup common logging based on CLI arguments +pub fn setup_logging(common_args: &CommonArgs) { + init_logging(common_args.enable_logging, common_args.log_level); +} + +/// Create shutdown handler for graceful termination +pub fn create_shutdown_handler() -> Arc { + Arc::new(Notify::new()) +} + +/// Wait for Ctrl+C signal +pub async fn wait_for_shutdown_signal(shutdown: Arc) { + signal::ctrl_c() + .await + .expect("failed to install CTRL+C handler"); + + println!("\nReceived Ctrl+C, shutting down..."); + shutdown.notify_waiters(); +} + +/// Common mint information display +pub fn display_mint_info(port: u16, temp_dir: &Path, database_type: &str) { + println!("Mint started successfully!"); + println!("Mint URL: http://127.0.0.1:{port}"); + println!("Temp directory: {temp_dir:?}"); + println!("Database type: {database_type}"); +} + +/// Create settings for a fake wallet mint +pub fn create_fake_wallet_settings( + port: u16, + database: &str, + mnemonic: Option, + signatory_config: Option<(String, String)>, // (url, certs_dir) + fake_wallet_config: Option, +) -> cdk_mintd::config::Settings { + cdk_mintd::config::Settings { + info: cdk_mintd::config::Info { + url: format!("http://127.0.0.1:{port}"), + quote_ttl: None, + + listen_host: "127.0.0.1".to_string(), + listen_port: port, + seed: None, + mnemonic, + signatory_url: signatory_config.as_ref().map(|(url, _)| url.clone()), + signatory_certs: signatory_config + .as_ref() + .map(|(_, certs_dir)| certs_dir.clone()), + input_fee_ppk: None, + http_cache: cache::Config::default(), + logging: cdk_mintd::config::LoggingConfig { + output: cdk_mintd::config::LoggingOutput::Both, + console_level: Some("debug".to_string()), + file_level: Some("debug".to_string()), + }, + enable_swagger_ui: None, + }, + mint_info: cdk_mintd::config::MintInfo::default(), + ln: cdk_mintd::config::Ln { + ln_backend: cdk_mintd::config::LnBackend::FakeWallet, + invoice_description: None, + min_mint: DEFAULT_MIN_MINT.into(), + max_mint: DEFAULT_MAX_MINT.into(), + min_melt: DEFAULT_MIN_MELT.into(), + max_melt: DEFAULT_MAX_MELT.into(), + }, + cln: None, + lnbits: None, + lnd: None, + ldk_node: None, + fake_wallet: fake_wallet_config, + grpc_processor: None, + database: Database { + engine: DatabaseEngine::from_str(database).expect("valid database"), + postgres: None, + }, + auth_database: None, + mint_management_rpc: None, + auth: None, + prometheus: Some(Default::default()), + portal_wallet: None, + } +} + +/// Create settings for a CLN mint +pub fn create_cln_settings( + port: u16, + _cln_rpc_path: PathBuf, + mnemonic: String, + cln_config: cdk_mintd::config::Cln, +) -> cdk_mintd::config::Settings { + cdk_mintd::config::Settings { + info: cdk_mintd::config::Info { + url: format!("http://127.0.0.1:{port}"), + quote_ttl: None, + + listen_host: "127.0.0.1".to_string(), + listen_port: port, + seed: None, + mnemonic: Some(mnemonic), + signatory_url: None, + signatory_certs: None, + input_fee_ppk: None, + http_cache: cache::Config::default(), + logging: cdk_mintd::config::LoggingConfig { + output: cdk_mintd::config::LoggingOutput::Both, + console_level: Some("debug".to_string()), + file_level: Some("debug".to_string()), + }, + enable_swagger_ui: None, + }, + mint_info: cdk_mintd::config::MintInfo::default(), + ln: cdk_mintd::config::Ln { + ln_backend: cdk_mintd::config::LnBackend::Cln, + invoice_description: None, + min_mint: DEFAULT_MIN_MINT.into(), + max_mint: DEFAULT_MAX_MINT.into(), + min_melt: DEFAULT_MIN_MELT.into(), + max_melt: DEFAULT_MAX_MELT.into(), + }, + cln: Some(cln_config), + lnbits: None, + lnd: None, + ldk_node: None, + fake_wallet: None, + grpc_processor: None, + database: cdk_mintd::config::Database::default(), + auth_database: None, + mint_management_rpc: None, + auth: None, + prometheus: Some(Default::default()), + portal_wallet: None, + } +} + +/// Create settings for an LND mint +pub fn create_lnd_settings( + port: u16, + lnd_config: cdk_mintd::config::Lnd, + mnemonic: String, +) -> cdk_mintd::config::Settings { + cdk_mintd::config::Settings { + info: cdk_mintd::config::Info { + quote_ttl: None, + url: format!("http://127.0.0.1:{port}"), + listen_host: "127.0.0.1".to_string(), + listen_port: port, + seed: None, + mnemonic: Some(mnemonic), + signatory_url: None, + signatory_certs: None, + input_fee_ppk: None, + http_cache: cache::Config::default(), + logging: cdk_mintd::config::LoggingConfig { + output: cdk_mintd::config::LoggingOutput::Both, + console_level: Some("debug".to_string()), + file_level: Some("debug".to_string()), + }, + enable_swagger_ui: None, + }, + mint_info: cdk_mintd::config::MintInfo::default(), + ln: cdk_mintd::config::Ln { + ln_backend: cdk_mintd::config::LnBackend::Lnd, + invoice_description: None, + min_mint: DEFAULT_MIN_MINT.into(), + max_mint: DEFAULT_MAX_MINT.into(), + min_melt: DEFAULT_MIN_MELT.into(), + max_melt: DEFAULT_MAX_MELT.into(), + }, + cln: None, + lnbits: None, + ldk_node: None, + lnd: Some(lnd_config), + fake_wallet: None, + grpc_processor: None, + database: cdk_mintd::config::Database::default(), + auth_database: None, + mint_management_rpc: None, + auth: None, + prometheus: Some(Default::default()), + portal_wallet: None, + } +} diff --git a/crates/cdk-integration-tests/tests/async_melt.rs b/crates/cdk-integration-tests/tests/async_melt.rs new file mode 100644 index 000000000..db6733940 --- /dev/null +++ b/crates/cdk-integration-tests/tests/async_melt.rs @@ -0,0 +1,146 @@ +//! Async Melt Integration Tests +//! +//! This file contains tests for async melt functionality using the Prefer: respond-async header. +//! +//! Test Scenarios: +//! - Async melt returns PENDING state immediately +//! - Synchronous melt still works correctly (backward compatibility) +//! - Background task completion +//! - Quote polling pattern + +use std::sync::Arc; + +use bip39::Mnemonic; +use cdk::amount::SplitTarget; +use cdk::nuts::{CurrencyUnit, MeltQuoteState}; +use cdk::wallet::Wallet; +use cdk::StreamExt; +use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; +use cdk_sqlite::wallet::memory; + +const MINT_URL: &str = "http://127.0.0.1:8086"; + +/// Test: Async melt returns PENDING state immediately +/// +/// This test validates that when calling melt with Prefer: respond-async header, +/// the mint returns immediately with PENDING state. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_async_melt_returns_pending() { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + // Step 1: Mint some tokens + let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); + + let _proofs = proof_streams + .next() + .await + .expect("payment") + .expect("no error"); + + let balance = wallet.total_balance().await.unwrap(); + assert_eq!(balance, 100.into()); + + // Step 2: Create a melt quote + let fake_invoice_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Paid, + check_payment_state: MeltQuoteState::Paid, + pay_err: false, + check_err: false, + }; + + let invoice = create_fake_invoice( + 50_000, // 50 sats in millisats + serde_json::to_string(&fake_invoice_description).unwrap(), + ); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); + + // Step 3: Call melt (wallet handles proof selection internally) + let start_time = std::time::Instant::now(); + + // This should complete and return the final state + // TODO: Add Prefer: respond-async header support to wallet.melt() + let melt_response = wallet.melt(&melt_quote.id).await.unwrap(); + + let elapsed = start_time.elapsed(); + + // For now, this is synchronous, so it will take longer + println!("Melt took {:?}", elapsed); + + // Step 4: Verify the melt completed successfully + assert_eq!( + melt_response.state, + MeltQuoteState::Paid, + "Melt should complete with PAID state" + ); +} + +/// Test: Synchronous melt still works correctly +/// +/// This test ensures backward compatibility - melt without Prefer header +/// still blocks until completion and returns the final state. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_sync_melt_completes_fully() { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + // Step 1: Mint some tokens + let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); + + let _proofs = proof_streams + .next() + .await + .expect("payment") + .expect("no error"); + + let balance = wallet.total_balance().await.unwrap(); + assert_eq!(balance, 100.into()); + + // Step 2: Create a melt quote + let fake_invoice_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Paid, + check_payment_state: MeltQuoteState::Paid, + pay_err: false, + check_err: false, + }; + + let invoice = create_fake_invoice( + 50_000, // 50 sats in millisats + serde_json::to_string(&fake_invoice_description).unwrap(), + ); + + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); + + // Step 3: Call synchronous melt + let melt_response = wallet.melt(&melt_quote.id).await.unwrap(); + + // Step 5: Verify response shows payment completed + assert_eq!( + melt_response.state, + MeltQuoteState::Paid, + "Synchronous melt should return PAID state" + ); + + // Step 6: Verify the quote is PAID in the mint + let quote_state = wallet.melt_quote_status(&melt_quote.id).await.unwrap(); + assert_eq!( + quote_state.state, + MeltQuoteState::Paid, + "Quote should be PAID" + ); +} diff --git a/crates/cdk-integration-tests/tests/bolt12.rs b/crates/cdk-integration-tests/tests/bolt12.rs new file mode 100644 index 000000000..ce484ba71 --- /dev/null +++ b/crates/cdk-integration-tests/tests/bolt12.rs @@ -0,0 +1,465 @@ +use std::env; +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{bail, Result}; +use bip39::Mnemonic; +use cashu::amount::SplitTarget; +use cashu::nut23::Amountless; +use cashu::{Amount, CurrencyUnit, MintRequest, MintUrl, PreMintSecrets, ProofsMethods}; +use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletBuilder}; +use cdk_integration_tests::get_mint_url_from_env; +use cdk_integration_tests::init_regtest::{get_cln_dir, get_temp_dir}; +use cdk_sqlite::wallet::memory; +use ln_regtest_rs::ln_client::ClnClient; + +// Helper function to get temp directory from environment or fallback +fn get_test_temp_dir() -> PathBuf { + match env::var("CDK_ITESTS_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => get_temp_dir(), // fallback to default + } +} + +// Helper function to create CLN client with retries +async fn create_cln_client_with_retry(cln_dir: PathBuf) -> Result { + let mut retries = 0; + let max_retries = 10; + loop { + match ClnClient::new(cln_dir.clone(), None).await { + Ok(client) => return Ok(client), + Err(e) => { + retries += 1; + if retries >= max_retries { + bail!( + "Could not connect to CLN client after {} retries: {}", + max_retries, + e + ); + } + println!( + "Failed to connect to CLN (attempt {}/{}): {}. Retrying in 7 seconds...", + retries, max_retries, e + ); + tokio::time::sleep(tokio::time::Duration::from_secs(7)).await; + } + } + } +} + +/// Tests basic BOLT12 minting functionality: +/// - Creates a wallet +/// - Gets a BOLT12 quote for a specific amount (100 sats) +/// - Pays the quote using Core Lightning +/// - Mints tokens and verifies the correct amount is received +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_mint() { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .unwrap(); + + let mint_amount = Amount::from(100); + + let mint_quote = wallet + .mint_bolt12_quote(Some(mint_amount), None) + .await + .unwrap(); + + assert_eq!(mint_quote.amount, Some(mint_amount)); + + let work_dir = get_test_temp_dir(); + let cln_one_dir = get_cln_dir(&work_dir, "one"); + let cln_client = create_cln_client_with_retry(cln_one_dir.clone()) + .await + .unwrap(); + cln_client + .pay_bolt12_offer(None, mint_quote.request.clone()) + .await + .unwrap(); + + let proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) + .await + .unwrap(); + + assert_eq!(proofs.total_amount().unwrap(), 100.into()); +} + +/// Tests multiple payments to a single BOLT12 quote: +/// - Creates a wallet and gets a BOLT12 quote without specifying amount +/// - Makes two separate payments (10,000 sats and 11,000 sats) to the same quote +/// - Verifies that each payment can be minted separately and correctly +/// - Tests the functionality of reusing a quote for multiple payments +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_mint_multiple() -> Result<()> { + let mint_url = MintUrl::from_str(&get_mint_url_from_env())?; + + let wallet = WalletBuilder::new() + .mint_url(mint_url) + .unit(CurrencyUnit::Sat) + .localstore(Arc::new(memory::empty().await?)) + .seed(Mnemonic::generate(12)?.to_seed_normalized("")) + .target_proof_count(3) + .use_http_subscription() + .build()?; + + let mint_quote = wallet.mint_bolt12_quote(None, None).await?; + + let work_dir = get_test_temp_dir(); + let cln_one_dir = get_cln_dir(&work_dir, "one"); + let cln_client = create_cln_client_with_retry(cln_one_dir.clone()).await?; + cln_client + .pay_bolt12_offer(Some(10000), mint_quote.request.clone()) + .await + .unwrap(); + + let proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) + .await?; + + assert_eq!(proofs.total_amount().unwrap(), 10.into()); + + cln_client + .pay_bolt12_offer(Some(11_000), mint_quote.request.clone()) + .await + .unwrap(); + + let proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) + .await?; + + assert_eq!(proofs.total_amount().unwrap(), 11.into()); + + Ok(()) +} + +/// Tests that multiple wallets can pay the same BOLT12 offer: +/// - Creates a BOLT12 offer through CLN that both wallets will pay +/// - Creates two separate wallets with different minting amounts +/// - Has each wallet get their own quote and make payments +/// - Verifies both wallets can successfully mint their tokens +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_multiple_wallets() -> Result<()> { + // Create first wallet + let wallet_one = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + // Create second wallet + let wallet_two = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + // Create a BOLT12 offer that both wallets will use + let work_dir = get_test_temp_dir(); + let cln_one_dir = get_cln_dir(&work_dir, "one"); + let cln_client = create_cln_client_with_retry(cln_one_dir.clone()).await?; + // First wallet payment + let quote_one = wallet_one + .mint_bolt12_quote(Some(10_000.into()), None) + .await?; + cln_client + .pay_bolt12_offer(None, quote_one.request.clone()) + .await?; + + let proofs_one = wallet_one + .wait_and_mint_quote( + quote_one.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) + .await?; + + assert_eq!(proofs_one.total_amount()?, 10_000.into()); + + // Second wallet payment + let quote_two = wallet_two + .mint_bolt12_quote(Some(15_000.into()), None) + .await?; + cln_client + .pay_bolt12_offer(None, quote_two.request.clone()) + .await?; + + let proofs_two = wallet_two + .wait_and_mint_quote( + quote_two.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) + .await?; + + assert_eq!(proofs_two.total_amount()?, 15_000.into()); + + let offer = cln_client + .get_bolt12_offer(None, false, "test_multiple_wallets".to_string()) + .await?; + + let wallet_one_melt_quote = wallet_one + .melt_bolt12_quote( + offer.to_string(), + Some(cashu::MeltOptions::Amountless { + amountless: Amountless { + amount_msat: 1500.into(), + }, + }), + ) + .await?; + + let wallet_two_melt_quote = wallet_two + .melt_bolt12_quote( + offer.to_string(), + Some(cashu::MeltOptions::Amountless { + amountless: Amountless { + amount_msat: 1000.into(), + }, + }), + ) + .await?; + + let melted = wallet_one.melt(&wallet_one_melt_quote.id).await?; + + assert!(melted.preimage.is_some()); + + let melted_two = wallet_two.melt(&wallet_two_melt_quote.id).await?; + + assert!(melted_two.preimage.is_some()); + + Ok(()) +} + +/// Tests the BOLT12 melting (spending) functionality: +/// - Creates a wallet and mints 20,000 sats using BOLT12 +/// - Creates a BOLT12 offer for 10,000 sats +/// - Tests melting (spending) tokens using the BOLT12 offer +/// - Verifies the correct amount is melted +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_melt() -> Result<()> { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + let mint_amount = Amount::from(20_000); + + // Create a single-use BOLT12 quote + let mint_quote = wallet.mint_bolt12_quote(Some(mint_amount), None).await?; + + assert_eq!(mint_quote.amount, Some(mint_amount)); + // Pay the quote + let work_dir = get_test_temp_dir(); + let cln_one_dir = get_cln_dir(&work_dir, "one"); + let cln_client = create_cln_client_with_retry(cln_one_dir.clone()).await?; + cln_client + .pay_bolt12_offer(None, mint_quote.request.clone()) + .await?; + + let _proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) + .await?; + + let offer = cln_client + .get_bolt12_offer(Some(10_000), true, "hhhhhhhh".to_string()) + .await?; + + let quote = wallet.melt_bolt12_quote(offer.to_string(), None).await?; + + let melt = wallet.melt("e.id).await?; + + assert_eq!(melt.amount, 10.into()); + + Ok(()) +} + +/// Tests security validation for BOLT12 minting to prevent overspending: +/// - Creates a wallet and gets an open-ended BOLT12 quote +/// - Makes a payment of 10,000 millisats +/// - Attempts to mint more tokens (500 sats) than were actually paid for +/// - Verifies that the mint correctly rejects the oversized mint request +/// - Ensures proper error handling with TransactionUnbalanced error +/// This test is crucial for ensuring the economic security of the minting process +/// by preventing users from minting more tokens than they have paid for. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_regtest_bolt12_mint_extra() -> Result<()> { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await?), + Mnemonic::generate(12)?.to_seed_normalized(""), + None, + )?; + + // Create a single-use BOLT12 quote + let mint_quote = wallet.mint_bolt12_quote(None, None).await?; + + let state = wallet.mint_bolt12_quote_state(&mint_quote.id).await?; + + assert_eq!(state.amount_paid, Amount::ZERO); + assert_eq!(state.amount_issued, Amount::ZERO); + + let active_keyset_id = wallet.fetch_active_keyset().await?.id; + + let pay_amount_msats = 10_000; + + let work_dir = get_test_temp_dir(); + let cln_one_dir = get_cln_dir(&work_dir, "one"); + let cln_client = create_cln_client_with_retry(cln_one_dir.clone()).await?; + cln_client + .pay_bolt12_offer(Some(pay_amount_msats), mint_quote.request.clone()) + .await?; + + let payment = wallet + .wait_for_payment(&mint_quote, tokio::time::Duration::from_secs(15)) + .await? + .unwrap(); + + let state = wallet.mint_bolt12_quote_state(&mint_quote.id).await?; + + assert_eq!(payment, state.amount_paid); + assert_eq!(state.amount_paid, (pay_amount_msats / 1_000).into()); + assert_eq!(state.amount_issued, Amount::ZERO); + + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 500.into(), + &SplitTarget::None, + &fee_and_amounts, + )?; + + let quote_info = wallet + .localstore + .get_mint_quote(&mint_quote.id) + .await? + .expect("there is a quote"); + + let mut mint_request = MintRequest { + quote: mint_quote.id, + outputs: pre_mint.blinded_messages(), + signature: None, + }; + + if let Some(secret_key) = quote_info.secret_key { + mint_request.sign(secret_key)?; + } + + let http_client = HttpClient::new(get_mint_url_from_env().parse().unwrap(), None); + + let response = http_client.post_mint(mint_request.clone()).await; + + match response { + Err(err) => match err { + cdk::Error::TransactionUnbalanced(_, _, _) => (), + err => { + bail!("Wrong mint error returned: {}", err); + } + }, + Ok(_) => { + bail!("Should not have allowed second payment"); + } + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_attempt_to_mint_unpaid() { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + let mint_amount = Amount::from(100); + + let mint_quote = wallet + .mint_bolt12_quote(Some(mint_amount), None) + .await + .unwrap(); + + assert_eq!(mint_quote.amount, Some(mint_amount)); + + let proofs = wallet + .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None) + .await; + + match proofs { + Err(err) => { + if !matches!(err, cdk::Error::UnpaidQuote) { + panic!("Wrong error quote should be unpaid: {}", err); + } + } + Ok(_) => { + panic!("Minting should not be allowed"); + } + } + + let mint_quote = wallet + .mint_bolt12_quote(Some(mint_amount), None) + .await + .unwrap(); + + let state = wallet + .mint_bolt12_quote_state(&mint_quote.id) + .await + .unwrap(); + + assert!(state.amount_paid == Amount::ZERO); + + let proofs = wallet + .mint_bolt12(&mint_quote.id, None, SplitTarget::default(), None) + .await; + + match proofs { + Err(err) => { + if !matches!(err, cdk::Error::UnpaidQuote) { + panic!("Wrong error quote should be unpaid: {}", err); + } + } + Ok(_) => { + panic!("Minting should not be allowed"); + } + } +} diff --git a/crates/cdk-integration-tests/tests/fake_auth.rs b/crates/cdk-integration-tests/tests/fake_auth.rs index d9c97514b..75c306c07 100644 --- a/crates/cdk-integration-tests/tests/fake_auth.rs +++ b/crates/cdk-integration-tests/tests/fake_auth.rs @@ -15,7 +15,7 @@ use cdk::nuts::{ use cdk::wallet::{AuthHttpClient, AuthMintConnector, HttpClient, MintConnector, WalletBuilder}; use cdk::{Error, OidcClient}; use cdk_fake_wallet::create_fake_invoice; -use cdk_integration_tests::{fund_wallet, wait_for_mint_to_be_paid}; +use cdk_integration_tests::fund_wallet; use cdk_sqlite::wallet::memory; const MINT_URL: &str = "http://127.0.0.1:8087"; @@ -36,12 +36,12 @@ async fn test_invalid_credentials() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); let mint_info = wallet - .get_mint_info() + .fetch_mint_info() .await .expect("mint info") .expect("could not get mint info"); @@ -274,10 +274,10 @@ async fn test_mint_blind_auth() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); - let mint_info = wallet.get_mint_info().await.unwrap().unwrap(); + let mint_info = wallet.fetch_mint_info().await.unwrap().unwrap(); let (access_token, _) = get_access_token(&mint_info).await; @@ -304,12 +304,12 @@ async fn test_mint_with_auth() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); let mint_info = wallet - .get_mint_info() + .fetch_mint_info() .await .expect("mint info") .expect("could not get mint info"); @@ -329,19 +329,17 @@ async fn test_mint_with_auth() { let mint_amount: Amount = 100.into(); - let mint_quote = wallet - .mint_quote(mint_amount, None) - .await - .expect("failed to get mint quote"); - - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .expect("failed to wait for payment"); + let quote = wallet.mint_quote(mint_amount, None).await.unwrap(); let proofs = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .wait_and_mint_quote( + quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .expect("could not mint"); + .expect("payment"); assert!(proofs.total_amount().expect("Could not get proofs amount") == mint_amount); } @@ -354,10 +352,10 @@ async fn test_swap_with_auth() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); - let mint_info = wallet.get_mint_info().await.unwrap().unwrap(); + let mint_info = wallet.fetch_mint_info().await.unwrap().unwrap(); let (access_token, _) = get_access_token(&mint_info).await; wallet.set_cat(access_token).await.unwrap(); @@ -407,12 +405,12 @@ async fn test_melt_with_auth() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); let mint_info = wallet - .get_mint_info() + .fetch_mint_info() .await .expect("Mint info not found") .expect("Mint info not found"); @@ -447,14 +445,14 @@ async fn test_mint_auth_over_max() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); let wallet = Arc::new(wallet); let mint_info = wallet - .get_mint_info() + .fetch_mint_info() .await .expect("Mint info not found") .expect("Mint info not found"); @@ -489,10 +487,10 @@ async fn test_reuse_auth_proof() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); - let mint_info = wallet.get_mint_info().await.unwrap().unwrap(); + let mint_info = wallet.fetch_mint_info().await.unwrap().unwrap(); let (access_token, _) = get_access_token(&mint_info).await; @@ -514,7 +512,7 @@ async fn test_reuse_auth_proof() { .await .expect("Quote should be allowed"); - assert!(quote.amount == 10.into()); + assert!(quote.amount == Some(10.into())); } wallet @@ -541,10 +539,10 @@ async fn test_melt_with_invalid_auth() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); - let mint_info = wallet.get_mint_info().await.unwrap().unwrap(); + let mint_info = wallet.fetch_mint_info().await.unwrap().unwrap(); let (access_token, _) = get_access_token(&mint_info).await; @@ -604,12 +602,12 @@ async fn test_refresh_access_token() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); let mint_info = wallet - .get_mint_info() + .fetch_mint_info() .await .expect("mint info") .expect("could not get mint info"); @@ -645,7 +643,7 @@ async fn test_refresh_access_token() { .await .expect("failed to get mint quote with refreshed token"); - assert_eq!(mint_quote.amount, mint_amount); + assert_eq!(mint_quote.amount, Some(mint_amount)); // Verify the total number of auth tokens let total_auth_proofs = wallet.get_unspent_auth_proofs().await.unwrap(); @@ -660,12 +658,12 @@ async fn test_invalid_refresh_token() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); let mint_info = wallet - .get_mint_info() + .fetch_mint_info() .await .expect("mint info") .expect("could not get mint info"); @@ -696,12 +694,12 @@ async fn test_auth_token_spending_order() { .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) .unit(CurrencyUnit::Sat) .localstore(db.clone()) - .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .seed(Mnemonic::generate(12).unwrap().to_seed_normalized("")) .build() .expect("Wallet"); let mint_info = wallet - .get_mint_info() + .fetch_mint_info() .await .expect("mint info") .expect("could not get mint info"); @@ -731,7 +729,7 @@ async fn test_auth_token_spending_order() { .await .expect("failed to get mint quote"); - assert_eq!(mint_quote.amount, 10.into()); + assert_eq!(mint_quote.amount, Some(10.into())); // Check remaining tokens after each operation let remaining = wallet.get_unspent_auth_proofs().await.unwrap(); @@ -753,7 +751,7 @@ async fn get_access_token(mint_info: &MintInfo) -> (String, String) { .expect("Nutxx defined") .openid_discovery; - let oidc_client = OidcClient::new(openid_discovery); + let oidc_client = OidcClient::new(openid_discovery, None); // Get the token endpoint from the OIDC configuration let token_url = oidc_client @@ -811,7 +809,7 @@ async fn get_custom_access_token( .expect("Nutxx defined") .openid_discovery; - let oidc_client = OidcClient::new(openid_discovery); + let oidc_client = OidcClient::new(openid_discovery, None); // Get the token endpoint from the OIDC configuration let token_url = oidc_client diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs index e2de57c41..f618ee950 100644 --- a/crates/cdk-integration-tests/tests/fake_wallet.rs +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -1,4 +1,21 @@ +//! Fake Wallet Integration Tests +//! +//! This file contains tests for the fake wallet backend functionality. +//! The fake wallet simulates Lightning Network behavior for testing purposes, +//! allowing verification of mint behavior in various payment scenarios without +//! requiring a real Lightning node. +//! +//! Test Scenarios: +//! - Pending payment states and proof handling +//! - Payment failure cases and proof state management +//! - Change output verification in melt operations +//! - Witness signature validation +//! - Cross-unit transaction validation +//! - Overflow and balance validation +//! - Duplicate proof detection + use std::sync::Arc; +use std::time::Duration; use bip39::Mnemonic; use cashu::Amount; @@ -10,8 +27,8 @@ use cdk::nuts::{ }; use cdk::wallet::types::TransactionDirection; use cdk::wallet::{HttpClient, MintConnector, Wallet}; +use cdk::StreamExt; use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; -use cdk_integration_tests::{attempt_to_swap_pending, wait_for_mint_to_be_paid}; use cdk_sqlite::wallet::memory; const MINT_URL: &str = "http://127.0.0.1:8086"; @@ -23,21 +40,20 @@ async fn test_fake_tokens_pending() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Pending, @@ -54,7 +70,13 @@ async fn test_fake_tokens_pending() { assert!(melt.is_err()); - attempt_to_swap_pending(&wallet).await.unwrap(); + // melt failed, but there is new code to reclaim unspent proofs + assert!(!wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await + .unwrap() + .is_empty()); } /// Tests that if the pay error fails and the check returns unknown or failed, @@ -65,21 +87,20 @@ async fn test_fake_melt_payment_fail() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("Failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Unknown, @@ -111,15 +132,8 @@ async fn test_fake_melt_payment_fail() { let melt = wallet.melt(&melt_quote.id).await; assert!(melt.is_err()); - // The mint should have unset proofs from pending since payment failed - let all_proof = wallet.get_unspent_proofs().await.unwrap(); - let states = wallet.check_proofs_spent(all_proof).await.unwrap(); - for state in states { - assert!(state.state == State::Unspent); - } - let wallet_bal = wallet.total_balance().await.unwrap(); - assert_eq!(wallet_bal, 100.into()); + assert_eq!(wallet_bal, 98.into()); } /// Tests that when both the pay_invoice and check_invoice both fail, @@ -130,21 +144,20 @@ async fn test_fake_melt_payment_fail_and_check() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("Failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Unknown, @@ -161,13 +174,12 @@ async fn test_fake_melt_payment_fail_and_check() { let melt = wallet.melt(&melt_quote.id).await; assert!(melt.is_err()); - let pending = wallet + assert!(!wallet .localstore .get_proofs(None, None, Some(vec![State::Pending]), None) .await - .unwrap(); - - assert!(!pending.is_empty()); + .unwrap() + .is_empty()); } /// Tests that when the ln backend returns a failed status but does not error, @@ -178,21 +190,20 @@ async fn test_fake_melt_payment_return_fail_status() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("Failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Failed, @@ -209,6 +220,16 @@ async fn test_fake_melt_payment_return_fail_status() { let melt = wallet.melt(&melt_quote.id).await; assert!(melt.is_err()); + wallet.check_all_pending_proofs().await.unwrap(); + + let pending = wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await + .unwrap(); + + assert!(pending.is_empty()); + let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Unknown, check_payment_state: MeltQuoteState::Unknown, @@ -224,13 +245,14 @@ async fn test_fake_melt_payment_return_fail_status() { let melt = wallet.melt(&melt_quote.id).await; assert!(melt.is_err()); - let pending = wallet + wallet.check_all_pending_proofs().await.unwrap(); + + assert!(!wallet .localstore .get_proofs(None, None, Some(vec![State::Pending]), None) .await - .unwrap(); - - assert!(pending.is_empty()); + .unwrap() + .is_empty()); } /// Tests that when the ln backend returns an error with unknown status, @@ -241,21 +263,20 @@ async fn test_fake_melt_payment_error_unknown() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .unwrap(); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Failed, @@ -270,7 +291,7 @@ async fn test_fake_melt_payment_error_unknown() { // The melt should error at the payment invoice command let melt = wallet.melt(&melt_quote.id).await; - assert_eq!(melt.unwrap_err().to_string(), "Payment failed"); + assert!(melt.is_err()); let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Unknown, @@ -285,15 +306,14 @@ async fn test_fake_melt_payment_error_unknown() { // The melt should error at the payment invoice command let melt = wallet.melt(&melt_quote.id).await; - assert_eq!(melt.unwrap_err().to_string(), "Payment failed"); + assert!(melt.is_err()); - let pending = wallet + assert!(!wallet .localstore .get_proofs(None, None, Some(vec![State::Pending]), None) .await - .unwrap(); - - assert!(pending.is_empty()); + .unwrap() + .is_empty()); } /// Tests that when the ln backend returns an error but the second check returns paid, @@ -304,21 +324,22 @@ async fn test_fake_melt_payment_err_paid() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("Failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); + + let old_balance = wallet.total_balance().await.expect("balance"); let fake_description = FakeInvoiceDescription { pay_invoice_state: MeltQuoteState::Failed, @@ -332,10 +353,23 @@ async fn test_fake_melt_payment_err_paid() { let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); // The melt should error at the payment invoice command - let melt = wallet.melt(&melt_quote.id).await; - assert!(melt.is_err()); + let melt = wallet.melt(&melt_quote.id).await.unwrap(); - attempt_to_swap_pending(&wallet).await.unwrap(); + assert!(melt.fee_paid == Amount::ZERO); + assert!(melt.amount == Amount::from(7)); + + // melt failed, but there is new code to reclaim unspent proofs + assert_eq!( + old_balance - melt.amount, + wallet.total_balance().await.expect("new balance") + ); + + assert!(wallet + .localstore + .get_proofs(None, None, Some(vec![State::Pending]), None) + .await + .unwrap() + .is_empty()); } /// Tests that change outputs in a melt quote are correctly handled @@ -345,21 +379,20 @@ async fn test_fake_melt_change_in_quote() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("Failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let transaction = wallet .list_transactions(Some(TransactionDirection::Incoming)) @@ -381,10 +414,16 @@ async fn test_fake_melt_change_in_quote() { let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); - let keyset = wallet.get_active_mint_keyset().await.unwrap(); + let keyset = wallet.fetch_active_keyset().await.unwrap(); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let premint_secrets = - PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + keyset.id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let client = HttpClient::new(MINT_URL.parse().unwrap(), None); @@ -408,40 +447,6 @@ async fn test_fake_melt_change_in_quote() { assert_eq!(melt_change, check); } -/// Tests that the correct database type is used based on environment variables -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -async fn test_database_type() { - // Get the database type and work dir from environment - let db_type = std::env::var("CDK_MINTD_DATABASE").expect("MINT_DATABASE env var should be set"); - let work_dir = - std::env::var("CDK_MINTD_WORK_DIR").expect("CDK_MINTD_WORK_DIR env var should be set"); - - // Check that the correct database file exists - match db_type.as_str() { - "REDB" => { - let db_path = std::path::Path::new(&work_dir).join("cdk-mintd.redb"); - assert!( - db_path.exists(), - "Expected redb database file to exist at {:?}", - db_path - ); - } - "SQLITE" => { - let db_path = std::path::Path::new(&work_dir).join("cdk-mintd.sqlite"); - assert!( - db_path.exists(), - "Expected sqlite database file to exist at {:?}", - db_path - ); - } - "MEMORY" => { - // Memory database has no file to check - println!("Memory database in use - no file to check"); - } - _ => panic!("Unknown database type: {}", db_type), - } -} - /// Tests minting tokens with a valid witness signature #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_fake_mint_with_witness() { @@ -449,20 +454,19 @@ async fn test_fake_mint_with_witness() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let mint_amount = proofs.total_amount().unwrap(); @@ -476,23 +480,33 @@ async fn test_fake_mint_without_witness() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + let mut payment_streams = wallet.payment_stream(&mint_quote); + + payment_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let http_client = HttpClient::new(MINT_URL.parse().unwrap(), None); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let premint_secrets = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let request = MintRequest { quote: mint_quote.id, @@ -516,23 +530,33 @@ async fn test_fake_mint_with_wrong_witness() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + let mut payment_streams = wallet.payment_stream(&mint_quote); + + payment_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let http_client = HttpClient::new(MINT_URL.parse().unwrap(), None); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let premint_secrets = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let mut request = MintRequest { quote: mint_quote.id, @@ -562,21 +586,31 @@ async fn test_fake_mint_inflated() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + let mut payment_streams = wallet.payment_stream(&mint_quote); + + payment_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 500.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 500.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let quote_info = wallet .localstore @@ -620,34 +654,50 @@ async fn test_fake_mint_multiple_units() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + let mut payment_streams = wallet.payment_stream(&mint_quote); + + payment_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let pre_mint = PreMintSecrets::random(active_keyset_id, 50.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 50.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let wallet_usd = Wallet::new( MINT_URL, CurrencyUnit::Usd, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); - let active_keyset_id = wallet_usd.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet_usd.fetch_active_keyset().await.unwrap().id; - let usd_pre_mint = - PreMintSecrets::random(active_keyset_id, 50.into(), &SplitTarget::None).unwrap(); + let usd_pre_mint = PreMintSecrets::random( + active_keyset_id, + 50.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let quote_info = wallet .localstore @@ -697,43 +747,46 @@ async fn test_fake_mint_multiple_unit_swap() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); + wallet.refresh_keysets().await.unwrap(); + let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::None, None) + let proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let wallet_usd = Wallet::new( MINT_URL, CurrencyUnit::Usd, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create usd wallet"); + wallet_usd.refresh_keysets().await.unwrap(); let mint_quote = wallet_usd.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet_usd, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = + wallet_usd.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let usd_proofs = wallet_usd - .mint(&mint_quote.id, SplitTarget::None, None) + let usd_proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); { let inputs: Proofs = vec![ @@ -745,6 +798,7 @@ async fn test_fake_mint_multiple_unit_swap() { active_keyset_id, inputs.total_amount().unwrap(), &SplitTarget::None, + &fee_and_amounts, ) .unwrap(); @@ -767,17 +821,27 @@ async fn test_fake_mint_multiple_unit_swap() { } { - let usd_active_keyset_id = wallet_usd.get_active_mint_keyset().await.unwrap().id; + let usd_active_keyset_id = wallet_usd.fetch_active_keyset().await.unwrap().id; let inputs: Proofs = proofs.into_iter().take(2).collect(); let total_inputs = inputs.total_amount().unwrap(); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let half = total_inputs / 2.into(); - let usd_pre_mint = - PreMintSecrets::random(usd_active_keyset_id, half, &SplitTarget::None).unwrap(); - let pre_mint = - PreMintSecrets::random(active_keyset_id, total_inputs - half, &SplitTarget::None) - .unwrap(); + let usd_pre_mint = PreMintSecrets::random( + usd_active_keyset_id, + half, + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + total_inputs - half, + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let mut usd_outputs = usd_pre_mint.blinded_messages(); let mut sat_outputs = pre_mint.blinded_messages(); @@ -810,21 +874,20 @@ async fn test_fake_mint_multiple_unit_melt() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::None, None) + let mut proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); println!("Minted sat"); @@ -832,7 +895,7 @@ async fn test_fake_mint_multiple_unit_melt() { MINT_URL, CurrencyUnit::Usd, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -840,14 +903,17 @@ async fn test_fake_mint_multiple_unit_melt() { let mint_quote = wallet_usd.mint_quote(100.into(), None).await.unwrap(); println!("Minted quote usd"); - wait_for_mint_to_be_paid(&wallet_usd, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = + wallet_usd.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let usd_proofs = wallet_usd - .mint(&mint_quote.id, SplitTarget::None, None) + let mut usd_proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); + + usd_proofs.reverse(); + proofs.reverse(); { let inputs: Proofs = vec![ @@ -878,22 +944,29 @@ async fn test_fake_mint_multiple_unit_melt() { } { + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let inputs: Proofs = vec![proofs.first().expect("There is a proof").clone()]; let input_amount: u64 = inputs.total_amount().unwrap().into(); let invoice = create_fake_invoice((input_amount - 1) * 1000, "".to_string()); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; - let usd_active_keyset_id = wallet_usd.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let usd_active_keyset_id = wallet_usd.fetch_active_keyset().await.unwrap().id; let usd_pre_mint = PreMintSecrets::random( usd_active_keyset_id, inputs.total_amount().unwrap() + 100.into(), &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::None, + &fee_and_amounts, ) .unwrap(); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None).unwrap(); let mut usd_outputs = usd_pre_mint.blinded_messages(); let mut sat_outputs = pre_mint.blinded_messages(); @@ -928,31 +1001,31 @@ async fn test_fake_mint_input_output_mismatch() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::None, None) + let proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let wallet_usd = Wallet::new( MINT_URL, CurrencyUnit::Usd, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new usd wallet"); - let usd_active_keyset_id = wallet_usd.get_active_mint_keyset().await.unwrap().id; + let usd_active_keyset_id = wallet_usd.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let inputs = proofs; @@ -960,6 +1033,7 @@ async fn test_fake_mint_input_output_mismatch() { usd_active_keyset_id, inputs.total_amount().unwrap(), &SplitTarget::None, + &fee_and_amounts, ) .unwrap(); @@ -986,24 +1060,30 @@ async fn test_fake_mint_swap_inflated() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::None, None) + let proofs = proof_streams + .next() .await - .unwrap(); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; - let pre_mint = - PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None).unwrap(); + .expect("payment") + .expect("no error"); + + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 101.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs, pre_mint.blinded_messages()); @@ -1030,25 +1110,31 @@ async fn test_fake_mint_swap_spend_after_fail() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::None, None) + let proofs = proof_streams + .next() .await - .unwrap(); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + .expect("payment") + .expect("no error"); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None).unwrap(); + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); @@ -1057,8 +1143,13 @@ async fn test_fake_mint_swap_spend_after_fail() { assert!(response.is_ok()); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 101.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); @@ -1073,8 +1164,13 @@ async fn test_fake_mint_swap_spend_after_fail() { Ok(_) => panic!("Should not have allowed swap with unbalanced"), } - let pre_mint = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs, pre_mint.blinded_messages()); @@ -1101,25 +1197,31 @@ async fn test_fake_mint_melt_spend_after_fail() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::None, None) + let proofs = proof_streams + .next() .await - .unwrap(); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + .expect("payment") + .expect("no error"); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::None).unwrap(); + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); @@ -1128,8 +1230,13 @@ async fn test_fake_mint_melt_spend_after_fail() { assert!(response.is_ok()); - let pre_mint = - PreMintSecrets::random(active_keyset_id, 101.into(), &SplitTarget::None).unwrap(); + let pre_mint = PreMintSecrets::random( + active_keyset_id, + 101.into(), + &SplitTarget::None, + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_mint.blinded_messages()); @@ -1173,23 +1280,23 @@ async fn test_fake_mint_duplicate_proofs_swap() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::None, None) + let proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let inputs = vec![proofs[0].clone(), proofs[0].clone()]; @@ -1197,6 +1304,7 @@ async fn test_fake_mint_duplicate_proofs_swap() { active_keyset_id, inputs.total_amount().unwrap(), &SplitTarget::None, + &fee_and_amounts, ) .unwrap(); @@ -1222,6 +1330,7 @@ async fn test_fake_mint_duplicate_proofs_swap() { let blinded_message = pre_mint.blinded_messages(); + let inputs = vec![proofs[0].clone()]; let outputs = vec![blinded_message[0].clone(), blinded_message[0].clone()]; let swap_request = SwapRequest::new(inputs, outputs); @@ -1252,21 +1361,20 @@ async fn test_fake_mint_duplicate_proofs_melt() { MINT_URL, CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); - let proofs = wallet - .mint(&mint_quote.id, SplitTarget::None, None) + let proofs = proof_streams + .next() .await - .unwrap(); + .expect("payment") + .expect("no error"); let inputs = vec![proofs[0].clone(), proofs[0].clone()]; @@ -1291,3 +1399,338 @@ async fn test_fake_mint_duplicate_proofs_melt() { } } } + +/// Tests that wallet automatically recovers proofs after a failed melt operation +/// by swapping them to new proofs, preventing loss of funds +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_wallet_proof_recovery_after_failed_melt() { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + // Mint 100 sats + let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); + let _roof_streams = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + Duration::from_secs(1000), + ) + .await; + + assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(100)); + + // Create a melt quote that will fail + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Unknown, + check_payment_state: MeltQuoteState::Unpaid, + pay_err: true, + check_err: false, + }; + + let invoice = create_fake_invoice(1000, serde_json::to_string(&fake_description).unwrap()); + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); + + // Attempt to melt - this should fail but trigger proof recovery + let melt_result = wallet.melt(&melt_quote.id).await; + assert!(melt_result.is_err(), "Melt should have failed"); + + // Verify wallet still has balance (proofs recovered) + assert_eq!( + wallet.total_balance().await.unwrap(), + Amount::from(100), + "Balance should be recovered" + ); + + // Verify we can still spend the recovered proofs + let valid_invoice = create_fake_invoice(7000, "".to_string()); + let valid_melt_quote = wallet + .melt_quote(valid_invoice.to_string(), None) + .await + .unwrap(); + + let successful_melt = wallet.melt(&valid_melt_quote.id).await; + assert!( + successful_melt.is_ok(), + "Should be able to spend recovered proofs" + ); +} + +/// Tests that concurrent melt attempts for the same invoice result in exactly one success +/// +/// This test verifies the race condition protection: when multiple melt quotes exist for the +/// same invoice and all are attempted concurrently, only one should succeed due to +/// the FOR UPDATE locking on quotes with the same request_lookup_id. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_concurrent_melt_same_invoice() { + const NUM_WALLETS: usize = 4; + + // Create multiple wallets to simulate concurrent requests + let mut wallets = Vec::with_capacity(NUM_WALLETS); + for i in 0..NUM_WALLETS { + let wallet = Arc::new( + Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect(&format!("failed to create wallet {}", i)), + ); + wallets.push(wallet); + } + + // Mint proofs for all wallets + for (i, wallet) in wallets.iter().enumerate() { + let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); + let mut proof_streams = + wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); + proof_streams + .next() + .await + .expect(&format!("payment for wallet {}", i)) + .expect("no error"); + } + + // Create a single invoice that all wallets will try to pay + let fake_description = FakeInvoiceDescription::default(); + let invoice = create_fake_invoice(9000, serde_json::to_string(&fake_description).unwrap()); + + // All wallets create melt quotes for the same invoice + let mut melt_quotes = Vec::with_capacity(NUM_WALLETS); + for wallet in &wallets { + let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); + melt_quotes.push(melt_quote); + } + + // Verify all quotes have the same request (same invoice = same lookup_id) + for quote in &melt_quotes[1..] { + assert_eq!( + melt_quotes[0].request, quote.request, + "All quotes should be for the same invoice" + ); + } + + // Attempt all melts concurrently + let mut handles = Vec::with_capacity(NUM_WALLETS); + for (wallet, quote) in wallets.iter().zip(melt_quotes.iter()) { + let wallet_clone = Arc::clone(wallet); + let quote_id = quote.id.clone(); + handles.push(tokio::spawn( + async move { wallet_clone.melt("e_id).await }, + )); + } + + // Collect results + let mut results = Vec::with_capacity(NUM_WALLETS); + for handle in handles { + results.push(handle.await.expect("task panicked")); + } + + // Count successes and failures + let success_count = results.iter().filter(|r| r.is_ok()).count(); + let failure_count = results.iter().filter(|r| r.is_err()).count(); + + assert_eq!( + success_count, 1, + "Expected exactly one successful melt, got {}. Results: {:?}", + success_count, results + ); + assert_eq!( + failure_count, + NUM_WALLETS - 1, + "Expected {} failed melts, got {}", + NUM_WALLETS - 1, + failure_count + ); + + // Verify all failures were due to duplicate detection + for result in &results { + if let Err(err) = result { + let err_str = err.to_string().to_lowercase(); + assert!( + err_str.contains("duplicate") + || err_str.contains("already paid") + || err_str.contains("pending"), + "Expected duplicate/already paid/pending error, got: {}", + err + ); + } + } +} + +/// Tests that wallet automatically recovers proofs after a failed swap operation +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_wallet_proof_recovery_after_failed_swap() { + let wallet = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + // Mint 100 sats + let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); + let mut proof_streams = wallet.proof_stream(mint_quote.clone(), SplitTarget::default(), None); + let initial_proofs = proof_streams + .next() + .await + .expect("payment") + .expect("no error"); + + let initial_ys: Vec<_> = initial_proofs.iter().map(|p| p.y().unwrap()).collect(); + + assert_eq!(wallet.total_balance().await.unwrap(), Amount::from(100)); + + let unspent_proofs = wallet.get_unspent_proofs().await.unwrap(); + + // Create an invalid swap by manually constructing a request that will fail + // We'll use the wallet's swap with invalid parameters to trigger a failure + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + // Create invalid swap request (requesting more than we have) + let preswap = PreMintSecrets::random( + active_keyset_id, + 1000.into(), // More than the 100 we have + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); + + let swap_request = SwapRequest::new(unspent_proofs.clone(), preswap.blinded_messages()); + + // Use HTTP client directly to bypass wallet's validation and trigger recovery + let http_client = HttpClient::new(MINT_URL.parse().unwrap(), None); + let response = http_client.post_swap(swap_request).await; + assert!(response.is_err(), "Swap should have failed"); + + // Note: The HTTP client doesn't trigger the wallet's try_proof_operation wrapper + // So we need to test through the wallet's own methods + // After the failed HTTP request, the proofs are still in the wallet's database + + // Verify balance is still available after the failed operation + assert_eq!( + wallet.total_balance().await.unwrap(), + Amount::from(100), + "Balance should still be available" + ); + + // Verify we can perform a successful swap operation + let successful_swap = wallet + .swap(None, SplitTarget::None, unspent_proofs, None, false) + .await; + + assert!( + successful_swap.is_ok(), + "Should be able to swap after failed operation" + ); + + // Verify the proofs were swapped to new ones + let final_proofs = wallet.get_unspent_proofs().await.unwrap(); + let final_ys: Vec<_> = final_proofs.iter().map(|p| p.y().unwrap()).collect(); + + // The Ys should be different after the successful swap + assert!( + initial_ys.iter().any(|y| !final_ys.contains(y)), + "Proofs should have been swapped to new ones" + ); +} + +/// Tests that melt_proofs works correctly with proofs that are not already in the wallet's database. +/// This is similar to the receive flow where proofs come from an external source. +/// +/// Flow: +/// 1. Wallet A mints proofs (proofs ARE in Wallet A's database) +/// 2. Wallet B creates a melt quote +/// 3. Wallet B calls melt_proofs with proofs from Wallet A (proofs are NOT in Wallet B's database) +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_melt_proofs_external() { + // Create sender wallet (Wallet A) and mint some proofs + let wallet_sender = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create sender wallet"); + + let mint_quote = wallet_sender.mint_quote(100.into(), None).await.unwrap(); + + let mut proof_streams = + wallet_sender.proof_stream(mint_quote.clone(), SplitTarget::default(), None); + + let proofs = proof_streams + .next() + .await + .expect("payment") + .expect("no error"); + + assert_eq!(proofs.total_amount().unwrap(), Amount::from(100)); + + // Create receiver/melter wallet (Wallet B) with a separate database + // These proofs are NOT in Wallet B's database + let wallet_melter = Wallet::new( + MINT_URL, + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create melter wallet"); + + // Verify proofs are not in the melter wallet's database + let melter_proofs = wallet_melter.get_unspent_proofs().await.unwrap(); + assert!( + melter_proofs.is_empty(), + "Melter wallet should have no proofs initially" + ); + + // Create a fake invoice for melting + let fake_description = FakeInvoiceDescription::default(); + let invoice = create_fake_invoice(9000, serde_json::to_string(&fake_description).unwrap()); + + // Wallet B creates a melt quote + let melt_quote = wallet_melter + .melt_quote(invoice.to_string(), None) + .await + .unwrap(); + + // Wallet B calls melt_proofs with external proofs (from Wallet A) + // These proofs are NOT in wallet_melter's database + let melted = wallet_melter + .melt_proofs(&melt_quote.id, proofs.clone()) + .await + .unwrap(); + + // Verify the melt succeeded + assert_eq!(melted.amount, Amount::from(9)); + assert_eq!(melted.fee_paid, 1.into()); + + // Verify change was returned (100 input - 9 melt amount = 91 change, minus fee reserve) + assert!(melted.change.is_some()); + let change_amount = melted.change.unwrap().total_amount().unwrap(); + assert!(change_amount > Amount::ZERO, "Should have received change"); + + // Verify the melter wallet now has the change proofs + let melter_balance = wallet_melter.total_balance().await.unwrap(); + assert_eq!(melter_balance, change_amount); + + // Verify a transaction was recorded + let transactions = wallet_melter + .list_transactions(Some(TransactionDirection::Outgoing)) + .await + .unwrap(); + assert_eq!(transactions.len(), 1); + assert_eq!(transactions[0].amount, Amount::from(9)); +} diff --git a/crates/cdk-integration-tests/tests/ffi_minting_integration.rs b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs new file mode 100644 index 000000000..81f4ee0ba --- /dev/null +++ b/crates/cdk-integration-tests/tests/ffi_minting_integration.rs @@ -0,0 +1,361 @@ +//! FFI Minting Integration Tests +//! +//! These tests verify the FFI wallet minting functionality through the complete +//! mint-to-tokens workflow, similar to the Swift bindings tests. The tests use +//! the actual FFI layer to ensure compatibility with language bindings. +//! +//! The tests include: +//! 1. Creating mint quotes through the FFI layer +//! 2. Simulating payment for development/testing environments +//! 3. Minting tokens and verifying amounts +//! 4. Testing the complete quote state transitions +//! 5. Validating proof generation and verification + +use std::env; +use std::path::PathBuf; +use std::str::FromStr; +use std::time::Duration; + +use bip39::Mnemonic; +use cdk_ffi::sqlite::WalletSqliteDatabase; +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}; +use lightning_invoice::Bolt11Invoice; +use tokio::time::timeout; + +// Helper function to get temp directory from environment or fallback +fn get_test_temp_dir() -> PathBuf { + match env::var("CDK_ITESTS_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => panic!("Unknown test dir"), + } +} + +/// Create a test FFI wallet with in-memory database +async fn create_test_ffi_wallet() -> FfiWallet { + let db = WalletSqliteDatabase::new_in_memory().expect("Failed to create in-memory database"); + let mnemonic = Mnemonic::generate(12).unwrap().to_string(); + let config = WalletConfig { + target_proof_count: Some(3), + }; + + FfiWallet::new( + get_mint_url_from_env(), + CurrencyUnit::Sat, + mnemonic, + db, + config, + ) + .expect("Failed to create FFI wallet") +} + +/// Tests the complete FFI minting flow from quote creation to token minting +/// +/// This test replicates the Swift integration test functionality: +/// 1. Creates an FFI wallet with in-memory database +/// 2. Creates a mint quote for 1000 sats +/// 3. Verifies the quote properties (amount, state, expiry) +/// 4. Simulates payment in test environments +/// 5. Mints tokens using the paid quote +/// 6. Verifies the minted proofs have the correct total amount +/// 7. Validates the wallet balance after minting +/// +/// This ensures the FFI layer properly handles the complete minting workflow +/// that language bindings (Swift, Python, Kotlin) will use. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_ffi_full_minting_flow() { + let wallet = create_test_ffi_wallet().await; + + // Verify initial wallet state + let initial_balance = wallet + .total_balance() + .await + .expect("Failed to get initial balance"); + assert_eq!(initial_balance.value, 0, "Initial balance should be zero"); + + // Test minting amount (1000 sats, matching Swift test) + let mint_amount = Amount::new(1000); + + // Step 1: Create a mint quote + let quote = wallet + .mint_quote(mint_amount, Some("FFI Integration Test".to_string())) + .await + .expect("Failed to create mint quote"); + + // Verify quote properties + assert_eq!( + quote.amount, + Some(mint_amount), + "Quote amount should match requested amount" + ); + assert_eq!(quote.unit, CurrencyUnit::Sat, "Quote unit should be sats"); + assert_eq!( + quote.state, + QuoteState::Unpaid, + "Initial quote state should be unpaid" + ); + assert!( + !quote.request.is_empty(), + "Quote should have a payment request" + ); + assert!(!quote.id.is_empty(), "Quote should have an ID"); + + // Verify the quote can be parsed as a valid invoice + let invoice = Bolt11Invoice::from_str("e.request) + .expect("Quote request should be a valid Lightning invoice"); + + // In test environments, simulate payment + pay_if_regtest(&get_test_temp_dir(), &invoice) + .await + .expect("Failed to pay invoice in test environment"); + + // Give the mint time to process the payment in test environments + tokio::time::sleep(Duration::from_millis(1000)).await; + + // Step 2: Wait for payment and mint tokens + // We'll use a timeout to avoid hanging in case of issues + let mint_result = timeout(Duration::from_secs(30), async { + // Keep checking quote status until it's paid, then mint + let mut attempts = 0; + let max_attempts = 10; + + loop { + attempts += 1; + if attempts > max_attempts { + panic!( + "Quote never transitioned to paid state after {} attempts", + max_attempts + ); + } + + // In a real scenario, we'd check quote status, but for integration tests + // we'll try to mint directly and handle any errors + match wallet.mint(quote.id.clone(), SplitTarget::None, None).await { + Ok(proofs) => break proofs, + Err(e) => { + // If quote isn't paid yet, wait and retry + if e.to_string().contains("quote not paid") || e.to_string().contains("unpaid") + { + tokio::time::sleep(Duration::from_millis(2000)).await; + continue; + } else { + panic!("Unexpected error while minting: {}", e); + } + } + } + } + }) + .await + .expect("Timeout waiting for minting to complete"); + + // Step 3: Verify minted proofs + assert!( + !mint_result.is_empty(), + "Should have minted at least one proof" + ); + + // Calculate total amount of minted proofs + 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" + ); + + // Verify each proof has valid properties + for proof in &mint_result { + assert!( + 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"); + } + + // Step 4: Verify wallet balance after minting + let final_balance = wallet + .total_balance() + .await + .expect("Failed to get final balance"); + assert_eq!( + final_balance.value, mint_amount.value, + "Final wallet balance should equal minted amount" + ); + + println!( + "✅ FFI minting test completed successfully: minted {} sats in {} proofs", + total_minted, + mint_result.len() + ); +} + +/// Tests FFI wallet quote creation and validation +/// +/// This test focuses on the quote creation aspects: +/// 1. Creates quotes for different amounts +/// 2. Verifies quote properties and validation +/// 3. Tests quote serialization/deserialization +/// 4. Ensures quotes have proper expiry times +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_ffi_mint_quote_creation() { + let wallet = create_test_ffi_wallet().await; + + // Test different quote amounts + let test_amounts = vec![100, 500, 1000, 2100]; // Including amount that requires split + + for amount_value in test_amounts { + let amount = Amount::new(amount_value); + let description = format!("Test quote for {} sats", amount_value); + + let quote = wallet + .mint_quote(amount, Some(description.clone())) + .await + .unwrap_or_else(|_| panic!("Failed to create quote for {} sats", amount_value)); + + // Verify quote properties + assert_eq!(quote.amount, Some(amount)); + assert_eq!(quote.unit, CurrencyUnit::Sat); + assert_eq!(quote.state, QuoteState::Unpaid); + assert!(!quote.id.is_empty()); + assert!(!quote.request.is_empty()); + + // Verify the payment request is a valid Lightning invoice + let invoice = Bolt11Invoice::from_str("e.request) + .expect("Quote request should be a valid Lightning invoice"); + + // The invoice amount should match the quote amount (in millisats) + assert_eq!( + invoice.amount_milli_satoshis(), + Some(amount_value * 1000), + "Invoice amount should match quote amount" + ); + + // Test quote JSON serialization (useful for bindings that need 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!( + "✅ Quote created for {} sats: ID={}, Invoice amount={}msat", + amount_value, + quote.id, + invoice.amount_milli_satoshis().unwrap_or(0) + ); + } +} + +/// Tests error handling in FFI minting operations +/// +/// This test verifies proper error handling: +/// 1. Invalid mint URLs +/// 2. Invalid amounts (zero, too large) +/// 3. Attempting to mint unpaid quotes +/// 4. Network connectivity issues +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_ffi_minting_error_handling() { + // Test invalid mint URL + let db = WalletSqliteDatabase::new_in_memory().expect("Failed to create database"); + let mnemonic = Mnemonic::generate(12).unwrap().to_string(); + let config = WalletConfig { + target_proof_count: Some(3), + }; + + let invalid_wallet_result = FfiWallet::new( + "invalid-url".to_string(), + CurrencyUnit::Sat, + mnemonic.clone(), + db, + config.clone(), + ); + assert!( + invalid_wallet_result.is_err(), + "Should fail to create wallet with invalid URL" + ); + + // Test with valid wallet for other error cases + let wallet = create_test_ffi_wallet().await; + + // Test zero amount quote (should fail) + let zero_amount_result = wallet.mint_quote(Amount::new(0), None).await; + assert!( + zero_amount_result.is_err(), + "Should fail to create quote with zero amount" + ); + + // Test minting with non-existent quote ID + let invalid_mint_result = wallet + .mint("non-existent-quote-id".to_string(), SplitTarget::None, None) + .await; + assert!( + invalid_mint_result.is_err(), + "Should fail to mint with non-existent quote ID" + ); + + println!("✅ Error handling tests completed successfully"); +} + +/// Tests FFI wallet configuration options +/// +/// This test verifies different wallet configurations: +/// 1. Different target proof counts +/// 2. Different currency units (if supported) +/// 3. Wallet restoration with same mnemonic +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_ffi_wallet_configuration() { + let mint_url = get_mint_url_from_env(); + let mnemonic = Mnemonic::generate(12).unwrap().to_string(); + + // Test different target proof counts + let proof_counts = vec![1, 3, 5, 10]; + + for target_count in proof_counts { + let db = WalletSqliteDatabase::new_in_memory().expect("Failed to create database"); + let config = WalletConfig { + target_proof_count: Some(target_count), + }; + + let wallet = FfiWallet::new( + mint_url.clone(), + CurrencyUnit::Sat, + mnemonic.clone(), + db, + config, + ) + .expect("Failed to create wallet"); + + // Verify wallet properties + assert_eq!(wallet.mint_url().url, mint_url); + assert_eq!(wallet.unit(), CurrencyUnit::Sat); + + println!( + "✅ Wallet created with target proof count: {}", + target_count + ); + } + + // Test wallet restoration with same mnemonic + let db1 = WalletSqliteDatabase::new_in_memory().expect("Failed to create database"); + let db2 = WalletSqliteDatabase::new_in_memory().expect("Failed to create database"); + + let config = WalletConfig { + target_proof_count: Some(3), + }; + + let wallet1 = FfiWallet::new( + mint_url.clone(), + CurrencyUnit::Sat, + mnemonic.clone(), + db1, + config.clone(), + ) + .expect("Failed to create first wallet"); + + let wallet2 = FfiWallet::new(mint_url, CurrencyUnit::Sat, mnemonic, db2, config) + .expect("Failed to create second wallet"); + + // Both wallets should have the same mint URL and unit + assert_eq!(wallet1.mint_url().url, wallet2.mint_url().url); + assert_eq!(wallet1.unit(), wallet2.unit()); + + println!("✅ Wallet configuration tests completed successfully"); +} diff --git a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs b/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs index 2f596388c..2612d13e9 100644 --- a/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs +++ b/crates/cdk-integration-tests/tests/happy_path_mint_wallet.rs @@ -9,8 +9,10 @@ //! whether to use real Lightning Network payments (regtest mode) or simulated payments. use core::panic; +use std::collections::HashMap; use std::env; use std::fmt::Debug; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; @@ -18,12 +20,11 @@ use std::time::Duration; use bip39::Mnemonic; use cashu::{MeltRequest, PreMintSecrets}; use cdk::amount::{Amount, SplitTarget}; +use cdk::mint_url::MintUrl; use cdk::nuts::nut00::ProofsMethods; use cdk::nuts::{CurrencyUnit, MeltQuoteState, NotificationPayload, State}; -use cdk::wallet::{HttpClient, MintConnector, Wallet}; -use cdk_integration_tests::{ - create_invoice_for_env, get_mint_url_from_env, pay_if_regtest, wait_for_mint_to_be_paid, -}; +use cdk::wallet::{HttpClient, MintConnector, MultiMintWallet, Wallet}; +use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest}; use cdk_sqlite::wallet::memory; use futures::{SinkExt, StreamExt}; use lightning_invoice::Bolt11Invoice; @@ -32,36 +33,49 @@ use tokio::time::timeout; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::protocol::Message; -async fn get_notification> + Unpin, E: Debug>( +// Helper function to get temp directory from environment or fallback +fn get_test_temp_dir() -> PathBuf { + match env::var("CDK_ITESTS_DIR") { + Ok(dir) => PathBuf::from(dir), + Err(_) => panic!("Unknown test dir"), + } +} + +async fn get_notifications> + Unpin, E: Debug>( reader: &mut T, timeout_to_wait: Duration, -) -> (String, NotificationPayload) { - let msg = timeout(timeout_to_wait, reader.next()) - .await - .expect("timeout") - .unwrap() - .unwrap(); - - let mut response: serde_json::Value = - serde_json::from_str(msg.to_text().unwrap()).expect("valid json"); - - let mut params_raw = response - .as_object_mut() - .expect("object") - .remove("params") - .expect("valid params"); - - let params_map = params_raw.as_object_mut().expect("params is object"); - - ( - params_map - .remove("subId") + total: usize, +) -> Vec<(String, NotificationPayload)> { + let mut results = Vec::new(); + for _ in 0..total { + let msg = timeout(timeout_to_wait, reader.next()) + .await + .expect("timeout") .unwrap() - .as_str() - .unwrap() - .to_string(), - serde_json::from_value(params_map.remove("payload").unwrap()).unwrap(), - ) + .unwrap(); + + let mut response: serde_json::Value = + serde_json::from_str(msg.to_text().unwrap()).expect("valid json"); + + let mut params_raw = response + .as_object_mut() + .expect("object") + .remove("params") + .expect("valid params"); + + let params_map = params_raw.as_object_mut().expect("params is object"); + + results.push(( + params_map + .remove("subId") + .unwrap() + .as_str() + .unwrap() + .to_string(), + serde_json::from_value(params_map.remove("payload").unwrap()).unwrap(), + )) + } + results } /// Tests a complete mint-melt round trip with WebSocket notifications @@ -82,7 +96,7 @@ async fn test_happy_mint_melt_round_trip() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -98,12 +112,19 @@ async fn test_happy_mint_melt_round_trip() { let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); - pay_if_regtest(&invoice).await.unwrap(); + pay_if_regtest(&get_test_temp_dir(), &invoice) + .await + .unwrap(); let proofs = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); + .expect("payment"); let mint_amount = proofs.total_amount().unwrap(); @@ -147,11 +168,29 @@ async fn test_happy_mint_melt_round_trip() { assert_eq!(response_json, expected_json); - let melt_response = wallet.melt(&melt.id).await.unwrap(); + let mut metadata = HashMap::new(); + metadata.insert("test".to_string(), "value".to_string()); + + let melt_response = wallet + .melt_with_metadata(&melt.id, metadata.clone()) + .await + .unwrap(); assert!(melt_response.preimage.is_some()); - assert!(melt_response.state == MeltQuoteState::Paid); + assert_eq!(melt_response.state, MeltQuoteState::Paid); + + let txs = wallet.list_transactions(None).await.unwrap(); + let tx = txs + .into_iter() + .find(|tx| tx.quote_id == Some(melt.id.clone())) + .unwrap(); + assert_eq!(tx.amount, melt.amount); + assert_eq!(tx.metadata, metadata); + + let mut notifications = get_notifications(&mut reader, Duration::from_millis(15000), 3).await; + notifications.reverse(); + + let (sub_id, payload) = notifications.pop().unwrap(); - let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await; // first message is the current state assert_eq!("test-sub", sub_id); let payload = match payload { @@ -164,7 +203,7 @@ async fn test_happy_mint_melt_round_trip() { assert_eq!(payload.state, MeltQuoteState::Unpaid); // get current state - let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await; + let (sub_id, payload) = notifications.pop().unwrap(); assert_eq!("test-sub", sub_id); let payload = match payload { NotificationPayload::MeltQuoteBolt11Response(melt) => melt, @@ -174,7 +213,7 @@ async fn test_happy_mint_melt_round_trip() { assert_eq!(payload.state, MeltQuoteState::Pending); // get current state - let (sub_id, payload) = get_notification(&mut reader, Duration::from_millis(15000)).await; + let (sub_id, payload) = notifications.pop().unwrap(); assert_eq!("test-sub", sub_id); let payload = match payload { NotificationPayload::MeltQuoteBolt11Response(melt) => melt, @@ -201,7 +240,7 @@ async fn test_happy_mint() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -210,19 +249,22 @@ async fn test_happy_mint() { let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap(); - assert_eq!(mint_quote.amount, mint_amount); + assert_eq!(mint_quote.amount, Some(mint_amount)); let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); - pay_if_regtest(&invoice).await.unwrap(); - - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + pay_if_regtest(&get_test_temp_dir(), &invoice) .await .unwrap(); let proofs = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); + .expect("payment"); let mint_amount = proofs.total_amount().unwrap(); @@ -250,7 +292,7 @@ async fn test_restore() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &seed, + seed, None, ) .expect("failed to create new wallet"); @@ -258,16 +300,19 @@ async fn test_restore() { let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); - pay_if_regtest(&invoice).await.unwrap(); - - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + pay_if_regtest(&get_test_temp_dir(), &invoice) .await .unwrap(); - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); + .expect("payment"); assert_eq!(wallet.total_balance().await.unwrap(), 100.into()); @@ -275,7 +320,7 @@ async fn test_restore() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &seed, + seed, None, ) .expect("failed to create new wallet"); @@ -285,6 +330,8 @@ async fn test_restore() { let restored = wallet_2.restore().await.unwrap(); let proofs = wallet_2.get_unspent_proofs().await.unwrap(); + assert!(!proofs.is_empty()); + let expected_fee = wallet.get_proofs_fee(&proofs).await.unwrap(); wallet_2 .swap(None, SplitTarget::default(), proofs, None, false) @@ -310,6 +357,154 @@ async fn test_restore() { } } +/// Tests that the melt quote status can be checked after a melt has completed +/// +/// This test verifies: +/// 1. Mint tokens +/// 2. Create a melt quote and execute the melt +/// 3. Check the melt quote status via the wallet +/// 4. Verify the quote is in the Paid state +/// +/// This ensures the mint correctly reports the melt quote status after completion. +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_melt_quote_status_after_melt() { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); + + let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); + pay_if_regtest(&get_test_temp_dir(), &invoice) + .await + .unwrap(); + + let proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) + .await + .expect("mint failed"); + + let mint_amount = proofs.total_amount().unwrap(); + assert_eq!(mint_amount, 100.into()); + + let invoice = create_invoice_for_env(Some(50)).await.unwrap(); + + let melt_quote = wallet.melt_quote(invoice, None).await.unwrap(); + + let melt_response = wallet.melt(&melt_quote.id).await.unwrap(); + assert_eq!(melt_response.state, MeltQuoteState::Paid); + + let quote_status = wallet.melt_quote_status(&melt_quote.id).await.unwrap(); + assert_eq!( + quote_status.state, + MeltQuoteState::Paid, + "Melt quote should be in Paid state after successful melt" + ); + + let db_quote = wallet + .localstore + .get_melt_quote(&melt_quote.id) + .await + .unwrap() + .unwrap(); + + assert_eq!( + db_quote.state, + MeltQuoteState::Paid, + "Melt quote should be in Paid state after successful melt" + ); +} + +/// Tests that the melt quote status can be checked via MultiMintWallet after a melt has completed +/// +/// This test verifies the same flow as test_melt_quote_status_after_melt but using +/// the MultiMintWallet abstraction: +/// 1. Create a MultiMintWallet and add a mint +/// 2. Mint tokens via the multi mint wallet +/// 3. Create a melt quote and execute the melt +/// 4. Check the melt quote status via check_melt_quote +/// 5. Verify the quote is in the Paid state +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_melt_quote_status_after_melt_multi_mint_wallet() { + let seed = Mnemonic::generate(12).unwrap().to_seed_normalized(""); + let localstore = Arc::new(memory::empty().await.unwrap()); + + let multi_mint_wallet = MultiMintWallet::new(localstore.clone(), seed, CurrencyUnit::Sat) + .await + .expect("failed to create multi mint wallet"); + + let mint_url = MintUrl::from_str(&get_mint_url_from_env()).expect("invalid mint url"); + multi_mint_wallet + .add_mint(mint_url.clone()) + .await + .expect("failed to add mint"); + + let mint_quote = multi_mint_wallet + .mint_quote(&mint_url, 100.into(), None) + .await + .unwrap(); + + let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); + pay_if_regtest(&get_test_temp_dir(), &invoice) + .await + .unwrap(); + + let _proofs = multi_mint_wallet + .wait_for_mint_quote(&mint_url, &mint_quote.id, SplitTarget::default(), None, 60) + .await + .expect("mint failed"); + + let balance = multi_mint_wallet.total_balance().await.unwrap(); + assert_eq!(balance, 100.into()); + + let invoice = create_invoice_for_env(Some(50)).await.unwrap(); + + let melt_quote = multi_mint_wallet + .melt_quote(&mint_url, invoice, None) + .await + .unwrap(); + + let melt_response = multi_mint_wallet + .melt_with_mint(&mint_url, &melt_quote.id) + .await + .unwrap(); + assert_eq!(melt_response.state, MeltQuoteState::Paid); + + let quote_status = multi_mint_wallet + .check_melt_quote(&mint_url, &melt_quote.id) + .await + .unwrap(); + assert_eq!( + quote_status.state, + MeltQuoteState::Paid, + "Melt quote should be in Paid state after successful melt (via MultiMintWallet)" + ); + + use cdk_common::database::WalletDatabase; + + let db_quote = localstore + .get_melt_quote(&melt_quote.id) + .await + .unwrap() + .unwrap(); + + assert_eq!( + db_quote.state, + MeltQuoteState::Paid, + "Melt quote should be in Paid state after successful melt" + ); +} + /// Tests that change outputs in a melt quote are correctly handled /// /// This test verifies the following workflow: @@ -326,7 +521,7 @@ async fn test_fake_melt_change_in_quote() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -335,16 +530,17 @@ async fn test_fake_melt_change_in_quote() { let bolt11 = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); - pay_if_regtest(&bolt11).await.unwrap(); + pay_if_regtest(&get_test_temp_dir(), &bolt11).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + let _proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); - - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) - .await - .unwrap(); + .expect("payment"); let invoice = create_invoice_for_env(Some(9)).await.unwrap(); @@ -352,10 +548,16 @@ async fn test_fake_melt_change_in_quote() { let melt_quote = wallet.melt_quote(invoice.to_string(), None).await.unwrap(); - let keyset = wallet.get_active_mint_keyset().await.unwrap(); + let keyset = wallet.fetch_active_keyset().await.unwrap(); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let premint_secrets = - PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + keyset.id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let client = HttpClient::new(get_mint_url_from_env().parse().unwrap(), None); @@ -395,25 +597,26 @@ async fn test_pay_invoice_twice() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - pay_if_regtest(&mint_quote.request.parse().unwrap()) - .await - .unwrap(); - - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + pay_if_regtest(&get_test_temp_dir(), &mint_quote.request.parse().unwrap()) .await .unwrap(); let proofs = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); + .expect("payment"); let mint_amount = proofs.total_amount().unwrap(); @@ -425,15 +628,26 @@ async fn test_pay_invoice_twice() { let melt = wallet.melt(&melt_quote.id).await.unwrap(); - let melt_two = wallet.melt_quote(invoice, None).await; + // Creating a second quote for the same invoice is allowed + let melt_quote_two = wallet.melt_quote(invoice, None).await.unwrap(); + + // But attempting to melt (pay) the second quote should fail + // since the first quote with the same lookup_id is already paid + let melt_two = wallet.melt(&melt_quote_two.id).await; match melt_two { - Err(err) => match err { - cdk::Error::RequestAlreadyPaid => (), - err => { - panic!("Wrong invoice already paid: {}", err.to_string()); + Err(err) => { + let err_str = err.to_string().to_lowercase(); + if !err_str.contains("duplicate") + && !err_str.contains("already paid") + && !err_str.contains("request already paid") + { + panic!( + "Expected duplicate/already paid error, got: {}", + err.to_string() + ); } - }, + } Ok(_) => { panic!("Should not have allowed second payment"); } diff --git a/crates/cdk-integration-tests/tests/integration_tests_pure.rs b/crates/cdk-integration-tests/tests/integration_tests_pure.rs index fc60de484..52099fcb2 100644 --- a/crates/cdk-integration-tests/tests/integration_tests_pure.rs +++ b/crates/cdk-integration-tests/tests/integration_tests_pure.rs @@ -3,11 +3,17 @@ //! These tests verify the interaction between mint and wallet components, simulating real-world usage scenarios. //! They test the complete flow of operations including wallet funding, token swapping, sending tokens between wallets, //! and other operations that require client-mint interaction. +//! +//! Test Environment: +//! - Uses pure in-memory mint instances for fast execution +//! - Tests run concurrently with multi-threaded tokio runtime +//! - No external dependencies (Lightning nodes, databases) required use std::assert_eq; use std::collections::{HashMap, HashSet}; use std::hash::RandomState; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use cashu::amount::SplitTarget; @@ -19,7 +25,7 @@ use cashu::{ }; use cdk::mint::Mint; use cdk::nuts::nut00::ProofsMethods; -use cdk::subscription::{IndexableParams, Params}; +use cdk::subscription::Params; use cdk::wallet::types::{TransactionDirection, TransactionId}; use cdk::wallet::{ReceiveOptions, SendMemo, SendOptions}; use cdk::Amount; @@ -70,11 +76,8 @@ async fn test_swap_to_send() { .expect("Failed to get ys") ) ); - let token = wallet_alice - .send( - prepared_send, - Some(SendMemo::for_token("test_swapt_to_send")), - ) + let token = prepared_send + .confirm(Some(SendMemo::for_token("test_swapt_to_send"))) .await .expect("Failed to send token"); let keysets_info = wallet_alice.get_mint_keysets().await.unwrap(); @@ -180,6 +183,19 @@ async fn test_mint_nut06() { .expect("Failed to get balance"); assert_eq!(Amount::from(64), balance_alice); + // Verify keyset amounts after minting + let keyset_id = mint_bob.pubkeys().keysets.first().unwrap().id; + let total_issued = mint_bob.total_issued().await.unwrap(); + let issued_amount = total_issued + .get(&keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + assert_eq!( + issued_amount, + Amount::from(64), + "Should have issued 64 sats" + ); + let transaction = wallet_alice .list_transactions(None) .await @@ -194,7 +210,7 @@ async fn test_mint_nut06() { let initial_mint_url = wallet_alice.mint_url.clone(); let mint_info_before = wallet_alice - .get_mint_info() + .fetch_mint_info() .await .expect("Failed to get mint info") .unwrap(); @@ -241,11 +257,13 @@ async fn test_mint_double_spend() { let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); let keyset_id = keys.id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let preswap = PreMintSecrets::random( keyset_id, proofs.total_amount().unwrap(), &SplitTarget::default(), + &fee_and_amounts, ) .unwrap(); @@ -258,6 +276,7 @@ async fn test_mint_double_spend() { keyset_id, proofs.total_amount().unwrap(), &SplitTarget::default(), + &fee_and_amounts, ) .unwrap(); @@ -298,14 +317,30 @@ async fn test_attempt_to_swap_by_overflowing() { let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); let keyset_id = keys.id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let pre_mint_amount = - PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap(); - let pre_mint_amount_two = - PreMintSecrets::random(keyset_id, amount.into(), &SplitTarget::default()).unwrap(); + let pre_mint_amount = PreMintSecrets::random( + keyset_id, + amount.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); + let pre_mint_amount_two = PreMintSecrets::random( + keyset_id, + amount.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); - let mut pre_mint = - PreMintSecrets::random(keyset_id, 1.into(), &SplitTarget::default()).unwrap(); + let mut pre_mint = PreMintSecrets::random( + keyset_id, + 1.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); pre_mint.combine(pre_mint_amount); pre_mint.combine(pre_mint_amount_two); @@ -318,9 +353,9 @@ async fn test_attempt_to_swap_by_overflowing() { cdk::Error::NUT03(cdk::nuts::nut03::Error::Amount(_)) => (), cdk::Error::AmountOverflow => (), cdk::Error::AmountError(_) => (), + cdk::Error::TransactionUnbalanced(_, _, _) => (), _ => { - println!("{:?}", err); - panic!("Wrong error returned in swap overflow") + panic!("Wrong error returned in swap overflow {:?}", err); } }, } @@ -352,9 +387,16 @@ async fn test_swap_unbalanced() { let keyset_id = get_keyset_id(&mint_bob).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + // Try to swap for less than the input amount (95 < 100) - let preswap = PreMintSecrets::random(keyset_id, 95.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let preswap = PreMintSecrets::random( + keyset_id, + 95.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); @@ -367,8 +409,13 @@ async fn test_swap_unbalanced() { } // Try to swap for more than the input amount (101 > 100) - let preswap = PreMintSecrets::random(keyset_id, 101.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let preswap = PreMintSecrets::random( + keyset_id, + 101.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); @@ -406,12 +453,14 @@ pub async fn test_p2pk_swap() { let secret = SecretKey::generate(); let spending_conditions = SpendingConditions::new_p2pk(secret.public_key(), None); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let pre_swap = PreMintSecrets::with_conditions( keyset_id, 100.into(), &SplitTarget::default(), &spending_conditions, + &fee_and_amounts, ) .unwrap(); @@ -429,7 +478,13 @@ pub async fn test_p2pk_swap() { ) .unwrap(); - let pre_swap = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + let pre_swap = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), pre_swap.blinded_messages()); @@ -443,16 +498,12 @@ pub async fn test_p2pk_swap() { .collect(); let mut listener = mint_bob - .pubsub_manager - .try_subscribe::( - Params { - kind: cdk::nuts::nut17::Kind::ProofState, - filters: public_keys_to_listen.clone(), - id: "test".into(), - } - .into(), - ) - .await + .pubsub_manager() + .subscribe(Params { + kind: cdk::nuts::nut17::Kind::ProofState, + filters: public_keys_to_listen.clone(), + id: Arc::new("test".into()), + }) .expect("valid subscription"); match mint_bob.process_swap_request(swap_request).await { @@ -479,9 +530,8 @@ pub async fn test_p2pk_swap() { sleep(Duration::from_secs(1)).await; let mut msgs = HashMap::new(); - while let Ok((sub_id, msg)) = listener.try_recv() { - assert_eq!(sub_id, "test".into()); - match msg { + while let Some(msg) = listener.try_recv() { + match msg.into_inner() { NotificationPayload::ProofState(ProofState { y, state, .. }) => { msgs.entry(y.to_string()) .or_insert_with(Vec::new) @@ -503,7 +553,7 @@ pub async fn test_p2pk_swap() { ); } - assert!(listener.try_recv().is_err(), "no other event is happening"); + assert!(listener.try_recv().is_none(), "no other event is happening"); assert!(msgs.is_empty(), "Only expected key events are received"); } @@ -515,7 +565,11 @@ async fn test_swap_overpay_underpay_fee() { .expect("Failed to create test mint"); mint_bob - .rotate_keyset(CurrencyUnit::Sat, 32, 1) + .rotate_keyset( + CurrencyUnit::Sat, + cdk_integration_tests::standard_keyset_amounts(32), + 1, + ) .await .unwrap(); @@ -535,8 +589,15 @@ async fn test_swap_overpay_underpay_fee() { let keys = mint_bob.pubkeys().keysets.first().unwrap().clone().keys; let keyset_id = Id::v1_from_keys(&keys); + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); - let preswap = PreMintSecrets::random(keyset_id, 9998.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 9998.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); @@ -552,7 +613,13 @@ async fn test_swap_overpay_underpay_fee() { }, } - let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 1000.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(proofs.clone(), preswap.blinded_messages()); @@ -577,7 +644,11 @@ async fn test_mint_enforce_fee() { .expect("Failed to create test mint"); mint_bob - .rotate_keyset(CurrencyUnit::Sat, 32, 1) + .rotate_keyset( + CurrencyUnit::Sat, + cdk_integration_tests::standard_keyset_amounts(32), + 1, + ) .await .unwrap(); @@ -601,10 +672,17 @@ async fn test_mint_enforce_fee() { let keys = mint_bob.pubkeys().keysets.first().unwrap().clone(); let keyset_id = keys.id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let five_proofs: Vec<_> = proofs.drain(..5).collect(); - let preswap = PreMintSecrets::random(keyset_id, 5.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 5.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages()); @@ -620,7 +698,13 @@ async fn test_mint_enforce_fee() { }, } - let preswap = PreMintSecrets::random(keyset_id, 4.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 4.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(five_proofs.clone(), preswap.blinded_messages()); @@ -630,7 +714,13 @@ async fn test_mint_enforce_fee() { let thousnad_proofs: Vec<_> = proofs.drain(..1001).collect(); - let preswap = PreMintSecrets::random(keyset_id, 1000.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 1000.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages()); @@ -646,7 +736,13 @@ async fn test_mint_enforce_fee() { }, } - let preswap = PreMintSecrets::random(keyset_id, 999.into(), &SplitTarget::default()).unwrap(); + let preswap = PreMintSecrets::random( + keyset_id, + 999.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .unwrap(); let swap_request = SwapRequest::new(thousnad_proofs.clone(), preswap.blinded_messages()); @@ -661,7 +757,11 @@ async fn test_mint_change_with_fee_melt() { .expect("Failed to create test mint"); mint_bob - .rotate_keyset(CurrencyUnit::Sat, 32, 1) + .rotate_keyset( + CurrencyUnit::Sat, + cdk_integration_tests::standard_keyset_amounts(32), + 1, + ) .await .unwrap(); @@ -678,6 +778,28 @@ async fn test_mint_change_with_fee_melt() { .await .expect("Failed to fund wallet"); + let keyset_id = mint_bob.pubkeys().keysets.first().unwrap().id; + + // Check amounts after minting + let total_issued = mint_bob.total_issued().await.unwrap(); + let total_redeemed = mint_bob.total_redeemed().await.unwrap(); + let initial_issued = total_issued.get(&keyset_id).copied().unwrap_or_default(); + let initial_redeemed = total_redeemed + .get(&keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + assert_eq!( + initial_issued, + Amount::from(100), + "Should have issued 100 sats, got {:?}", + total_issued + ); + assert_eq!( + initial_redeemed, + Amount::ZERO, + "Should have redeemed 0 sats initially, " + ); + let proofs = wallet_alice .get_unspent_proofs() .await @@ -696,6 +818,29 @@ async fn test_mint_change_with_fee_melt() { .unwrap(); assert_eq!(w.change.unwrap().total_amount().unwrap(), 97.into()); + + // Check amounts after melting + // Melting redeems 100 sats and issues 97 sats as change + let total_issued = mint_bob.total_issued().await.unwrap(); + let total_redeemed = mint_bob.total_redeemed().await.unwrap(); + let after_issued = total_issued + .get(&keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + let after_redeemed = total_redeemed + .get(&keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + assert_eq!( + after_issued, + Amount::from(197), + "Should have issued 197 sats total (100 initial + 97 change)" + ); + assert_eq!( + after_redeemed, + Amount::from(100), + "Should have redeemed 100 sats from the melt" + ); } /// Tests concurrent double-spending attempts by trying to use the same proofs /// in 3 swap transactions simultaneously using tokio tasks @@ -720,18 +865,34 @@ async fn test_concurrent_double_spend_swap() { .expect("Could not get proofs"); let keyset_id = get_keyset_id(&mint_bob).await; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); // Create 3 identical swap requests with the same proofs - let preswap1 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let 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()); - let preswap2 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + 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 preswap3 = PreMintSecrets::random(keyset_id, 100.into(), &SplitTarget::default()) - .expect("Failed to create preswap"); + let preswap3 = PreMintSecrets::random( + keyset_id, + 100.into(), + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); let swap_request3 = SwapRequest::new(proofs.clone(), preswap3.blinded_messages()); // Spawn 3 concurrent tasks to process the swap requests @@ -773,7 +934,7 @@ async fn test_concurrent_double_spend_swap() { // Verify that all proofs are marked as spent in the mint let states = mint_bob - .localstore + .localstore() .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::>()) .await .expect("Failed to get proof state"); @@ -832,11 +993,11 @@ async fn test_concurrent_double_spend_melt() { let melt_request3 = melt_request.clone(); // Spawn 3 concurrent tasks to process the melt requests - let task1 = tokio::spawn(async move { mint_clone1.melt_bolt11(&melt_request).await }); + let task1 = tokio::spawn(async move { mint_clone1.melt(&melt_request).await }); - let task2 = tokio::spawn(async move { mint_clone2.melt_bolt11(&melt_request2).await }); + let task2 = tokio::spawn(async move { mint_clone2.melt(&melt_request2).await }); - let task3 = tokio::spawn(async move { mint_clone3.melt_bolt11(&melt_request3).await }); + let task3 = tokio::spawn(async move { mint_clone3.melt(&melt_request3).await }); // Wait for all tasks to complete let results = tokio::try_join!(task1, task2, task3).expect("Tasks failed to complete"); @@ -870,7 +1031,7 @@ async fn test_concurrent_double_spend_melt() { // Verify that all proofs are marked as spent in the mint let states = mint_bob - .localstore + .localstore() .get_proofs_states(&proofs.iter().map(|p| p.y().unwrap()).collect::>()) .await .expect("Failed to get proof state"); diff --git a/crates/cdk-integration-tests/tests/ldk_node.rs b/crates/cdk-integration-tests/tests/ldk_node.rs new file mode 100644 index 000000000..6f8b8bf40 --- /dev/null +++ b/crates/cdk-integration-tests/tests/ldk_node.rs @@ -0,0 +1,63 @@ +use anyhow::Result; +use cdk_integration_tests::get_mint_url_from_env; + +#[tokio::test] +async fn test_ldk_node_mint_info() -> Result<()> { + // This test just verifies that the LDK-Node mint is running and responding + let mint_url = get_mint_url_from_env(); + + // Create an HTTP client + let client = reqwest::Client::new(); + + // Make a request to the info endpoint + let response = client.get(format!("{}/v1/info", mint_url)).send().await?; + + // Check that we got a successful response + assert_eq!(response.status(), 200); + + // Try to parse the response as JSON + let info: serde_json::Value = response.json().await?; + + // Verify that we got some basic fields + assert!(info.get("name").is_some()); + assert!(info.get("version").is_some()); + assert!(info.get("description").is_some()); + + println!("LDK-Node mint info: {:?}", info); + + Ok(()) +} + +#[tokio::test] +async fn test_ldk_node_mint_quote() -> Result<()> { + // This test verifies that we can create a mint quote with the LDK-Node mint + let mint_url = get_mint_url_from_env(); + + // Create an HTTP client + let client = reqwest::Client::new(); + + // Create a mint quote request + let quote_request = serde_json::json!({ + "amount": 1000, + "unit": "sat" + }); + + // Make a request to create a mint quote + let response = client + .post(format!("{}/v1/mint/quote/bolt11", mint_url)) + .json("e_request) + .send() + .await?; + + // Print the response for debugging + let status = response.status(); + let text = response.text().await?; + println!("Mint quote response status: {}", status); + println!("Mint quote response body: {}", text); + + // For now, we'll just check that we get a response (even if it's an error) + // In a real test, we'd want to verify the quote was created correctly + assert!(status.is_success() || status.as_u16() < 500); + + Ok(()) +} diff --git a/crates/cdk-integration-tests/tests/mint.rs b/crates/cdk-integration-tests/tests/mint.rs index 33c4dd479..5ec0ca963 100644 --- a/crates/cdk-integration-tests/tests/mint.rs +++ b/crates/cdk-integration-tests/tests/mint.rs @@ -1,14 +1,20 @@ -//! Mint tests +//! Mint Tests //! //! This file contains tests that focus on the mint's internal functionality without client interaction. //! These tests verify the mint's behavior in isolation, such as keyset management, database operations, //! and other mint-specific functionality that doesn't require wallet clients. +//! +//! Test Categories: +//! - Keyset rotation and management +//! - Database transaction handling +//! - Internal state transitions +//! - Fee calculation and enforcement +//! - Proof validation and state management use std::collections::{HashMap, HashSet}; use std::sync::Arc; use bip39::Mnemonic; -use cdk::cdk_database::MintDatabase; use cdk::mint::{MintBuilder, MintMeltLimits}; use cdk::nuts::{CurrencyUnit, PaymentMethod}; use cdk::types::{FeeReserve, QuoteTTL}; @@ -27,16 +33,23 @@ async fn test_correct_keyset() { let database = memory::empty().await.expect("valid db instance"); - let fake_wallet = FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0); + let fake_wallet = FakeWallet::new( + fee_reserve, + HashMap::default(), + HashSet::default(), + 0, + CurrencyUnit::Sat, + ); - let mut mint_builder = MintBuilder::new(); let localstore = Arc::new(database); - mint_builder = mint_builder - .with_localstore(localstore.clone()) - .with_keystore(localstore.clone()); + let mut mint_builder = MintBuilder::new(localstore.clone()); mint_builder = mint_builder - .add_ln_backend( + .with_name("regtest mint".to_string()) + .with_description("regtest mint".to_string()); + + mint_builder + .add_payment_processor( CurrencyUnit::Sat, PaymentMethod::Bolt11, MintMeltLimits::new(1, 5_000), @@ -44,22 +57,15 @@ async fn test_correct_keyset() { ) .await .unwrap(); + // .with_seed(mnemonic.to_seed_normalized("").to_vec()); - mint_builder = mint_builder - .with_name("regtest mint".to_string()) - .with_description("regtest mint".to_string()) - .with_seed(mnemonic.to_seed_normalized("").to_vec()); - - let mint = mint_builder.build().await.unwrap(); - let mut tx = localstore.begin_transaction().await.unwrap(); - - tx.set_mint_info(mint_builder.mint_info.clone()) + let mint = mint_builder + .build_with_seed(localstore.clone(), &mnemonic.to_seed_normalized("")) .await .unwrap(); - let quote_ttl = QuoteTTL::new(10000, 10000); - tx.set_quote_ttl(quote_ttl).await.unwrap(); - tx.commit().await.unwrap(); + let quote_ttl = QuoteTTL::new(10000, 10000); + mint.set_quote_ttl(quote_ttl).await.unwrap(); let active = mint.get_active_keysets(); @@ -68,7 +74,13 @@ async fn test_correct_keyset() { .expect("There is a keyset for unit"); let old_keyset_info = mint.get_keyset_info(active).expect("There is keyset"); - mint.rotate_keyset(CurrencyUnit::Sat, 32, 0).await.unwrap(); + mint.rotate_keyset( + CurrencyUnit::Sat, + cdk_integration_tests::standard_keyset_amounts(32), + 0, + ) + .await + .unwrap(); let active = mint.get_active_keysets(); @@ -80,8 +92,13 @@ async fn test_correct_keyset() { assert_ne!(keyset_info.id, old_keyset_info.id); - mint.rotate_keyset(CurrencyUnit::Sat, 32, 0).await.unwrap(); - let mint = mint_builder.build().await.unwrap(); + mint.rotate_keyset( + CurrencyUnit::Sat, + cdk_integration_tests::standard_keyset_amounts(32), + 0, + ) + .await + .unwrap(); let active = mint.get_active_keysets(); @@ -93,3 +110,166 @@ async fn test_correct_keyset() { assert_ne!(new_keyset_info.id, keyset_info.id); } + +/// Test concurrent payment processing to verify race condition fix +/// +/// This test simulates the real-world race condition where multiple concurrent +/// payment notifications arrive for the same payment_id. Before the fix, this +/// would cause "Payment ID already exists" errors. After the fix, all but one +/// should gracefully handle the duplicate and return a Duplicate error. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_concurrent_duplicate_payment_handling() { + use cashu::PaymentMethod; + use cdk::cdk_database::{MintDatabase, MintQuotesDatabase}; + use cdk::mint::MintQuote; + use cdk::Amount; + use cdk_common::payment::PaymentIdentifier; + use tokio::task::JoinSet; + + // Create a test mint with in-memory database + let mnemonic = Mnemonic::generate(12).unwrap(); + let fee_reserve = FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }; + + let database = Arc::new(memory::empty().await.expect("valid db instance")); + + let fake_wallet = FakeWallet::new( + fee_reserve, + HashMap::default(), + HashSet::default(), + 0, + CurrencyUnit::Sat, + ); + + let mut mint_builder = MintBuilder::new(database.clone()); + + mint_builder = mint_builder + .with_name("concurrent test mint".to_string()) + .with_description("testing concurrent payment handling".to_string()); + + mint_builder + .add_payment_processor( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + MintMeltLimits::new(1, 5_000), + Arc::new(fake_wallet), + ) + .await + .unwrap(); + + let mint = mint_builder + .build_with_seed(database.clone(), &mnemonic.to_seed_normalized("")) + .await + .unwrap(); + + let quote_ttl = QuoteTTL::new(10000, 10000); + mint.set_quote_ttl(quote_ttl).await.unwrap(); + + // Create a mint quote + let current_time = cdk::util::unix_time(); + let mint_quote = MintQuote::new( + None, + "concurrent_test_invoice".to_string(), + CurrencyUnit::Sat, + Some(Amount::from(1000)), + current_time + 3600, // expires in 1 hour + PaymentIdentifier::CustomId("test_lookup_id".to_string()), + None, + Amount::ZERO, + Amount::ZERO, + PaymentMethod::Bolt11, + current_time, + vec![], + vec![], + ); + + // Add the quote to the database + { + let mut tx = MintDatabase::begin_transaction(&*database).await.unwrap(); + tx.add_mint_quote(mint_quote.clone()).await.unwrap(); + tx.commit().await.unwrap(); + } + + // Simulate 10 concurrent payment notifications with the SAME payment_id + let payment_id = "duplicate_payment_test_12345"; + let mut join_set = JoinSet::new(); + + for i in 0..10 { + let db_clone = database.clone(); + let quote_id = mint_quote.id.clone(); + let payment_id_clone = payment_id.to_string(); + + join_set.spawn(async move { + let mut tx = MintDatabase::begin_transaction(&*db_clone).await.unwrap(); + let result = tx + .increment_mint_quote_amount_paid("e_id, Amount::from(10), payment_id_clone) + .await; + + if result.is_ok() { + tx.commit().await.unwrap(); + } + + (i, result) + }); + } + + // Collect results + let mut success_count = 0; + let mut duplicate_errors = 0; + let mut other_errors = Vec::new(); + + while let Some(result) = join_set.join_next().await { + let (task_id, db_result) = result.unwrap(); + match db_result { + Ok(_) => success_count += 1, + Err(e) => { + let err_str = format!("{:?}", e); + if err_str.contains("Duplicate") { + duplicate_errors += 1; + } else { + other_errors.push((task_id, err_str)); + } + } + } + } + + // Verify results + assert_eq!( + success_count, 1, + "Exactly one task should successfully process the payment (got {})", + success_count + ); + assert_eq!( + duplicate_errors, 9, + "Nine tasks should receive Duplicate error (got {})", + duplicate_errors + ); + assert!( + other_errors.is_empty(), + "No unexpected errors should occur. Got: {:?}", + other_errors + ); + + // Verify the quote was incremented exactly once + let final_quote = MintQuotesDatabase::get_mint_quote(&*database, &mint_quote.id) + .await + .unwrap() + .expect("Quote should exist"); + + assert_eq!( + final_quote.amount_paid(), + Amount::from(10), + "Quote amount should be incremented exactly once" + ); + assert_eq!( + final_quote.payments.len(), + 1, + "Should have exactly one payment recorded" + ); + assert_eq!( + final_quote.payments[0].payment_id, payment_id, + "Payment ID should match" + ); +} diff --git a/crates/cdk-integration-tests/tests/regtest.rs b/crates/cdk-integration-tests/tests/regtest.rs index e50aef785..98a764966 100644 --- a/crates/cdk-integration-tests/tests/regtest.rs +++ b/crates/cdk-integration-tests/tests/regtest.rs @@ -1,4 +1,18 @@ -use std::str::FromStr; +//! Regtest Integration Tests +//! +//! This file contains tests that run against actual Lightning Network nodes in regtest mode. +//! These tests require a local development environment with LND nodes configured for regtest. +//! +//! Test Environment Setup: +//! - Uses actual LND nodes connected to a regtest Bitcoin network +//! - Tests real Lightning payment flows including invoice creation and payment +//! - Verifies mint behavior with actual Lightning Network interactions +//! +//! Running Tests: +//! - Requires CDK_TEST_REGTEST=1 environment variable to be set +//! - Requires properly configured LND nodes with TLS certificates and macaroons +//! - Uses real Bitcoin transactions in regtest mode + use std::sync::Arc; use std::time::Duration; @@ -10,62 +24,42 @@ use cdk::nuts::{ NotificationPayload, PreMintSecrets, }; use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription}; -use cdk_integration_tests::init_regtest::{ - get_cln_dir, get_lnd_cert_file_path, get_lnd_dir, get_lnd_macaroon_path, get_mint_port, - LND_RPC_ADDR, LND_TWO_RPC_ADDR, -}; -use cdk_integration_tests::{ - get_mint_url_from_env, get_second_mint_url_from_env, wait_for_mint_to_be_paid, -}; +use cdk_integration_tests::{get_mint_url_from_env, get_second_mint_url_from_env, get_test_client}; use cdk_sqlite::wallet::{self, memory}; use futures::join; -use lightning_invoice::Bolt11Invoice; -use ln_regtest_rs::ln_client::{ClnClient, LightningClient, LndClient}; -use ln_regtest_rs::InvoiceStatus; use tokio::time::timeout; -// This is the ln wallet we use to send/receive ln payements as the wallet -async fn init_lnd_client() -> LndClient { - let lnd_dir = get_lnd_dir("one"); - let cert_file = lnd_dir.join("tls.cert"); - let macaroon_file = lnd_dir.join("data/chain/bitcoin/regtest/admin.macaroon"); - LndClient::new( - format!("https://{}", LND_RPC_ADDR), - cert_file, - macaroon_file, - ) - .await - .unwrap() -} +const LDK_URL: &str = "http://127.0.0.1:8089"; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_internal_payment() { - let lnd_client = init_lnd_client().await; + let ln_client = get_test_client().await; let wallet = Wallet::new( &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); - lnd_client - .pay_invoice(mint_quote.request) + ln_client + .pay_invoice(mint_quote.request.clone()) .await .expect("failed to pay invoice"); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); - - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); + .expect("payment"); assert!(wallet.total_balance().await.unwrap() == 100.into()); @@ -73,7 +67,7 @@ async fn test_internal_payment() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -89,50 +83,51 @@ async fn test_internal_payment() { let _melted = wallet.melt(&melt.id).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + let _proofs = wallet_2 + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); - - let _wallet_2_mint = wallet_2 - .mint(&mint_quote.id, SplitTarget::default(), None) - .await - .unwrap(); - - let check_paid = match get_mint_port("0") { - 8085 => { - let cln_one_dir = get_cln_dir("one"); - let cln_client = ClnClient::new(cln_one_dir.clone(), None).await.unwrap(); - - let payment_hash = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); - cln_client - .check_incoming_payment_status(&payment_hash.payment_hash().to_string()) - .await - .expect("Could not check invoice") - } - 8087 => { - let lnd_two_dir = get_lnd_dir("two"); - let lnd_client = LndClient::new( - format!("https://{}", LND_TWO_RPC_ADDR), - get_lnd_cert_file_path(&lnd_two_dir), - get_lnd_macaroon_path(&lnd_two_dir), - ) - .await - .unwrap(); - let payment_hash = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); - lnd_client - .check_incoming_payment_status(&payment_hash.payment_hash().to_string()) - .await - .expect("Could not check invoice") - } - _ => panic!("Unknown mint port"), - }; - - match check_paid { - InvoiceStatus::Unpaid => (), - _ => { - panic!("Invoice has incorrect status: {:?}", check_paid); - } - } + .expect("payment"); + + // let check_paid = match get_mint_port("0") { + // 8085 => { + // let cln_one_dir = get_cln_dir(&get_temp_dir(), "one"); + // let cln_client = ClnClient::new(cln_one_dir.clone(), None).await.unwrap(); + + // let payment_hash = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); + // cln_client + // .check_incoming_payment_status(&payment_hash.payment_hash().to_string()) + // .await + // .expect("Could not check invoice") + // } + // 8087 => { + // let lnd_two_dir = get_lnd_dir(&get_temp_dir(), "two"); + // let lnd_client = LndClient::new( + // format!("https://{}", LND_TWO_RPC_ADDR), + // get_lnd_cert_file_path(&lnd_two_dir), + // get_lnd_macaroon_path(&lnd_two_dir), + // ) + // .await + // .unwrap(); + // let payment_hash = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); + // lnd_client + // .check_incoming_payment_status(&payment_hash.payment_hash().to_string()) + // .await + // .expect("Could not check invoice") + // } + // _ => panic!("Unknown mint port"), + // }; + + // match check_paid { + // InvoiceStatus::Unpaid => (), + // _ => { + // panic!("Invoice has incorrect status: {:?}", check_paid); + // } + // } let wallet_2_balance = wallet_2.total_balance().await.unwrap(); @@ -149,7 +144,7 @@ async fn test_websocket_connection() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(wallet::memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -170,7 +165,7 @@ async fn test_websocket_connection() { .expect("timeout waiting for unpaid notification") .expect("No paid notification received"); - match msg { + match msg.into_inner() { NotificationPayload::MintQuoteBolt11Response(response) => { assert_eq!(response.quote.to_string(), mint_quote.id); assert_eq!(response.state, MintQuoteState::Unpaid); @@ -178,8 +173,8 @@ async fn test_websocket_connection() { _ => panic!("Unexpected notification type"), } - let lnd_client = init_lnd_client().await; - lnd_client + let ln_client = get_test_client().await; + ln_client .pay_invoice(mint_quote.request) .await .expect("failed to pay invoice"); @@ -190,7 +185,7 @@ async fn test_websocket_connection() { .expect("timeout waiting for paid notification") .expect("No paid notification received"); - match msg { + match msg.into_inner() { NotificationPayload::MintQuoteBolt11Response(response) => { assert_eq!(response.quote.to_string(), mint_quote.id); assert_eq!(response.state, MintQuoteState::Paid); @@ -201,14 +196,18 @@ async fn test_websocket_connection() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_multimint_melt() { - let lnd_client = init_lnd_client().await; + if get_mint_url_from_env() == LDK_URL { + return; + } + + let ln_client = get_test_client().await; let db = Arc::new(memory::empty().await.unwrap()); let wallet1 = Wallet::new( &get_mint_url_from_env(), CurrencyUnit::Sat, db, - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -218,7 +217,7 @@ async fn test_multimint_melt() { &get_second_mint_url_from_env(), CurrencyUnit::Sat, db, - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -227,33 +226,39 @@ async fn test_multimint_melt() { // Fund the wallets let quote = wallet1.mint_quote(mint_amount, None).await.unwrap(); - lnd_client + ln_client .pay_invoice(quote.request.clone()) .await .expect("failed to pay invoice"); - wait_for_mint_to_be_paid(&wallet1, "e.id, 60) - .await - .unwrap(); - wallet1 - .mint("e.id, SplitTarget::default(), None) + + let _proofs = wallet1 + .wait_and_mint_quote( + quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); + .expect("payment"); let quote = wallet2.mint_quote(mint_amount, None).await.unwrap(); - lnd_client + ln_client .pay_invoice(quote.request.clone()) .await .expect("failed to pay invoice"); - wait_for_mint_to_be_paid(&wallet2, "e.id, 60) - .await - .unwrap(); - wallet2 - .mint("e.id, SplitTarget::default(), None) + + let _proofs = wallet2 + .wait_and_mint_quote( + quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); + .expect("payment"); // Get an invoice - let invoice = lnd_client.create_invoice(Some(50)).await.unwrap(); + let invoice = ln_client.create_invoice(Some(50)).await.unwrap(); // Get multi-part melt quotes let melt_options = MeltOptions::Mpp { @@ -286,12 +291,12 @@ async fn test_multimint_melt() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_cached_mint() { - let lnd_client = init_lnd_client().await; + let ln_client = get_test_client().await; let wallet = Wallet::new( &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -299,19 +304,26 @@ async fn test_cached_mint() { let mint_amount = Amount::from(100); let quote = wallet.mint_quote(mint_amount, None).await.unwrap(); - lnd_client + ln_client .pay_invoice(quote.request.clone()) .await .expect("failed to pay invoice"); - wait_for_mint_to_be_paid(&wallet, "e.id, 60) + let _proofs = wallet + .wait_for_payment("e, tokio::time::Duration::from_secs(15)) .await - .unwrap(); + .expect("payment"); - let active_keyset_id = wallet.get_active_mint_keyset().await.unwrap().id; + let active_keyset_id = wallet.fetch_active_keyset().await.unwrap().id; + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); let http_client = HttpClient::new(get_mint_url_from_env().parse().unwrap(), None); - let premint_secrets = - PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap(); + let premint_secrets = PreMintSecrets::random( + active_keyset_id, + 100.into(), + &SplitTarget::default().to_owned(), + &fee_and_amounts, + ) + .unwrap(); let mut request = MintRequest { quote: quote.id, @@ -333,13 +345,13 @@ async fn test_cached_mint() { #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_regtest_melt_amountless() { - let lnd_client = init_lnd_client().await; + let ln_client = get_test_client().await; let wallet = Wallet::new( &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -348,9 +360,9 @@ async fn test_regtest_melt_amountless() { let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap(); - assert_eq!(mint_quote.amount, mint_amount); + assert_eq!(mint_quote.amount, Some(mint_amount)); - lnd_client + ln_client .pay_invoice(mint_quote.request) .await .expect("failed to pay invoice"); @@ -364,7 +376,7 @@ async fn test_regtest_melt_amountless() { assert!(mint_amount == amount); - let invoice = lnd_client.create_invoice(None).await.unwrap(); + let invoice = ln_client.create_invoice(None).await.unwrap(); let options = MeltOptions::new_amountless(5_000); @@ -377,3 +389,57 @@ async fn test_regtest_melt_amountless() { assert!(melt.amount == 5.into()); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_attempt_to_mint_unpaid() { + let wallet = Wallet::new( + &get_mint_url_from_env(), + CurrencyUnit::Sat, + Arc::new(memory::empty().await.unwrap()), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), + None, + ) + .expect("failed to create new wallet"); + + let mint_amount = Amount::from(100); + + let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap(); + + assert_eq!(mint_quote.amount, Some(mint_amount)); + + let proofs = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await; + + match proofs { + Err(err) => { + if !matches!(err, cdk::Error::UnpaidQuote) { + panic!("Wrong error quote should be unpaid: {}", err); + } + } + Ok(_) => { + panic!("Minting should not be allowed"); + } + } + + let mint_quote = wallet.mint_quote(mint_amount, None).await.unwrap(); + + let state = wallet.mint_quote_state(&mint_quote.id).await.unwrap(); + + assert!(state.state == MintQuoteState::Unpaid); + + let proofs = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await; + + match proofs { + Err(err) => { + if !matches!(err, cdk::Error::UnpaidQuote) { + panic!("Wrong error quote should be unpaid: {}", err); + } + } + Ok(_) => { + panic!("Minting should not be allowed"); + } + } +} diff --git a/crates/cdk-integration-tests/tests/test_fees.rs b/crates/cdk-integration-tests/tests/test_fees.rs index da7c24f49..1da698853 100644 --- a/crates/cdk-integration-tests/tests/test_fees.rs +++ b/crates/cdk-integration-tests/tests/test_fees.rs @@ -6,19 +6,24 @@ use cashu::{Bolt11Invoice, ProofsMethods}; use cdk::amount::{Amount, SplitTarget}; use cdk::nuts::CurrencyUnit; use cdk::wallet::{ReceiveOptions, SendKind, SendOptions, Wallet}; -use cdk_integration_tests::{ - create_invoice_for_env, get_mint_url_from_env, pay_if_regtest, wait_for_mint_to_be_paid, -}; +use cdk_integration_tests::init_regtest::get_temp_dir; +use cdk_integration_tests::{create_invoice_for_env, get_mint_url_from_env, pay_if_regtest}; use cdk_sqlite::wallet::memory; +use tracing_subscriber::EnvFilter; #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn test_swap() { + // Set up logging + let default_filter = "debug"; + let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn"; + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + tracing_subscriber::fmt().with_env_filter(env_filter).init(); let seed = Mnemonic::generate(12).unwrap().to_seed_normalized(""); let wallet = Wallet::new( &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &seed, + seed, None, ) .expect("failed to create new wallet"); @@ -26,23 +31,22 @@ async fn test_swap() { let mint_quote = wallet.mint_quote(100.into(), None).await.unwrap(); let invoice = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); - pay_if_regtest(&invoice).await.unwrap(); - - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) - .await - .unwrap(); - - let proofs: Vec = wallet - .get_unspent_proofs() + pay_if_regtest(&get_temp_dir(), &invoice).await.unwrap(); + + let proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap() - .iter() - .map(|p| p.amount) - .collect(); + .expect("payment"); println!("{:?}", proofs); + println!("{:?}", wallet.get_mint_keysets().await.unwrap()); + let send = wallet .prepare_send( 4.into(), @@ -60,7 +64,7 @@ async fn test_swap() { assert_eq!(fee, 1.into()); - let send = wallet.send(send, None).await.unwrap(); + let send = send.confirm(None).await.unwrap(); let rec_amount = wallet .receive(&send.to_string(), ReceiveOptions::default()) @@ -80,7 +84,7 @@ async fn test_fake_melt_change_in_quote() { &get_mint_url_from_env(), CurrencyUnit::Sat, Arc::new(memory::empty().await.unwrap()), - &Mnemonic::generate(12).unwrap().to_seed_normalized(""), + Mnemonic::generate(12).unwrap().to_seed_normalized(""), None, ) .expect("failed to create new wallet"); @@ -89,16 +93,17 @@ async fn test_fake_melt_change_in_quote() { let bolt11 = Bolt11Invoice::from_str(&mint_quote.request).unwrap(); - pay_if_regtest(&bolt11).await.unwrap(); + pay_if_regtest(&get_temp_dir(), &bolt11).await.unwrap(); - wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) - .await - .unwrap(); - - let _mint_amount = wallet - .mint(&mint_quote.id, SplitTarget::default(), None) + let _proofs = wallet + .wait_and_mint_quote( + mint_quote.clone(), + SplitTarget::default(), + None, + tokio::time::Duration::from_secs(60), + ) .await - .unwrap(); + .expect("payment"); let invoice_amount = 9; diff --git a/crates/cdk-integration-tests/tests/test_swap_flow.rs b/crates/cdk-integration-tests/tests/test_swap_flow.rs new file mode 100644 index 000000000..38d9e3439 --- /dev/null +++ b/crates/cdk-integration-tests/tests/test_swap_flow.rs @@ -0,0 +1,1210 @@ +//! 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; + + // Check initial amounts after minting + let total_issued = mint.total_issued().await.unwrap(); + let total_redeemed = mint.total_redeemed().await.unwrap(); + let initial_issued = total_issued + .get(&keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + let initial_redeemed = total_redeemed + .get(&keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + assert_eq!( + initial_issued, + Amount::from(100), + "Should have issued 100 sats" + ); + assert_eq!( + initial_redeemed, + Amount::ZERO, + "Should have redeemed 0 sats initially" + ); + + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).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::>()) + .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::>(), + ) + .await + .expect("Failed to get blind signatures"); + + assert_eq!( + saved_signatures.len(), + swap_response.signatures.len(), + "All signatures should be saved" + ); + + // Check keyset amounts after swap + // Swap redeems old proofs (100 sats) and issues new proofs (100 sats) + let total_issued = mint.total_issued().await.unwrap(); + let total_redeemed = mint.total_redeemed().await.unwrap(); + let after_issued = total_issued + .get(&keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + let after_redeemed = total_redeemed + .get(&keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + assert_eq!( + after_issued, + Amount::from(200), + "Should have issued 200 sats total (initial 100 + swap 100)" + ); + assert_eq!( + after_redeemed, + Amount::from(100), + "Should have redeemed 100 sats from the swap" + ); +} + +/// Tests that duplicate blinded messages are rejected: +/// 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::>())).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::>())).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::>())).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::>())).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::>())).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::>())).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::>()) + .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, + cdk_integration_tests::standard_keyset_amounts(32), + 1, + ) + .await + .expect("Failed to rotate keyset"); + + // Fund with 1000 sats as individual 1-sat proofs using the fee-based keyset + // Wait a bit for keyset to be available + 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::>())).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::>())).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::>())).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 = 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> = 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_default() + .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::>())).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) + } + } + } +} + +/// Tests that wallet correctly increments keyset counters when receiving proofs +/// from multiple keysets and then performing operations with them. +/// +/// This test validates: +/// 1. Wallet can receive proofs from multiple different keysets +/// 2. Counter is correctly incremented for the target keyset during swap +/// 3. Database maintains separate counters for each keyset +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_wallet_multi_keyset_counter_updates() { + setup_tracing(); + let mint = create_and_start_test_mint() + .await + .expect("Failed to create test mint"); + let wallet = create_test_wallet_for_mint(mint.clone()) + .await + .expect("Failed to create test wallet"); + + // Fund wallet with initial 100 sats using first keyset + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet"); + + let first_keyset_id = get_keyset_id(&mint).await; + + // Rotate to a second keyset + mint.rotate_keyset( + CurrencyUnit::Sat, + cdk_integration_tests::standard_keyset_amounts(32), + 0, + ) + .await + .expect("Failed to rotate keyset"); + + // Wait for keyset rotation to propagate + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + // Refresh wallet keysets to know about the new keyset + wallet + .refresh_keysets() + .await + .expect("Failed to refresh wallet keysets"); + + // Fund wallet again with 100 sats using second keyset + fund_wallet(wallet.clone(), 100, None) + .await + .expect("Failed to fund wallet with second keyset"); + + let second_keyset_id = mint + .pubkeys() + .keysets + .iter() + .find(|k| k.id != first_keyset_id) + .expect("Should have second keyset") + .id; + + // Verify we now have proofs from two different keysets + let all_proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let keysets_in_use: std::collections::HashSet<_> = + all_proofs.iter().map(|p| p.keyset_id).collect(); + + assert_eq!( + keysets_in_use.len(), + 2, + "Should have proofs from 2 different keysets" + ); + assert!( + keysets_in_use.contains(&first_keyset_id), + "Should have proofs from first keyset" + ); + assert!( + keysets_in_use.contains(&second_keyset_id), + "Should have proofs from second keyset" + ); + + // Get initial total issued and redeemed for both keysets before swap + let total_issued_before = mint.total_issued().await.unwrap(); + let total_redeemed_before = mint.total_redeemed().await.unwrap(); + + let first_keyset_issued_before = total_issued_before + .get(&first_keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + let first_keyset_redeemed_before = total_redeemed_before + .get(&first_keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + + let second_keyset_issued_before = total_issued_before + .get(&second_keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + let second_keyset_redeemed_before = total_redeemed_before + .get(&second_keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + + tracing::info!( + "Before swap - First keyset: issued={}, redeemed={}", + first_keyset_issued_before, + first_keyset_redeemed_before + ); + tracing::info!( + "Before swap - Second keyset: issued={}, redeemed={}", + second_keyset_issued_before, + second_keyset_redeemed_before + ); + + // Both keysets should have issued 100 sats + assert_eq!( + first_keyset_issued_before, + Amount::from(100), + "First keyset should have issued 100 sats" + ); + assert_eq!( + second_keyset_issued_before, + Amount::from(100), + "Second keyset should have issued 100 sats" + ); + // Neither should have redeemed anything yet + assert_eq!( + first_keyset_redeemed_before, + Amount::ZERO, + "First keyset should have redeemed 0 sats before swap" + ); + assert_eq!( + second_keyset_redeemed_before, + Amount::ZERO, + "Second keyset should have redeemed 0 sats before swap" + ); + + // Now perform a swap with all proofs - this should only increment the counter + // for the active (second) keyset, not for the first keyset + let fee_and_amounts = (0, ((0..32).map(|x| 2u64.pow(x)).collect::>())).into(); + + let total_amount = all_proofs.total_amount().expect("Should get total amount"); + + // Create swap using the active (second) keyset + let preswap = PreMintSecrets::random( + second_keyset_id, + total_amount, + &SplitTarget::default(), + &fee_and_amounts, + ) + .expect("Failed to create preswap"); + + let swap_request = SwapRequest::new(all_proofs.clone(), preswap.blinded_messages()); + + // Execute the swap + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Swap should succeed"); + + // Verify response + assert_eq!( + swap_response.signatures.len(), + preswap.blinded_messages().len(), + "Should receive signature for each blinded message" + ); + + // All the new proofs should be from the second (active) keyset + let keys = mint + .pubkeys() + .keysets + .iter() + .find(|k| k.id == second_keyset_id) + .expect("Should find second keyset") + .keys + .clone(); + + let new_proofs = construct_proofs( + swap_response.signatures, + preswap.rs(), + preswap.secrets(), + &keys, + ) + .expect("Failed to construct proofs"); + + // Verify all new proofs use the second keyset + for proof in &new_proofs { + assert_eq!( + proof.keyset_id, second_keyset_id, + "All new proofs should use the active (second) keyset" + ); + } + + // Verify total issued and redeemed after swap + let total_issued_after = mint.total_issued().await.unwrap(); + let total_redeemed_after = mint.total_redeemed().await.unwrap(); + + let first_keyset_issued_after = total_issued_after + .get(&first_keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + let first_keyset_redeemed_after = total_redeemed_after + .get(&first_keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + + let second_keyset_issued_after = total_issued_after + .get(&second_keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + let second_keyset_redeemed_after = total_redeemed_after + .get(&second_keyset_id) + .copied() + .unwrap_or(Amount::ZERO); + + tracing::info!( + "After swap - First keyset: issued={}, redeemed={}", + first_keyset_issued_after, + first_keyset_redeemed_after + ); + tracing::info!( + "After swap - Second keyset: issued={}, redeemed={}", + second_keyset_issued_after, + second_keyset_redeemed_after + ); + + // After swap: + // - First keyset: issued stays 100, redeemed increases by 100 (all its proofs were spent in swap) + // - Second keyset: issued increases by 200 (original 100 + new 100 from swap output), + // redeemed increases by 100 (its proofs from first funding were spent) + assert_eq!( + first_keyset_issued_after, + Amount::from(100), + "First keyset issued should stay 100 sats (no new issuance)" + ); + assert_eq!( + first_keyset_redeemed_after, + Amount::from(100), + "First keyset should have redeemed 100 sats (all its proofs spent in swap)" + ); + + assert_eq!( + second_keyset_issued_after, + Amount::from(300), + "Second keyset should have issued 300 sats total (100 initial + 100 the second funding + 100 from swap output from the old keyset)" + ); + assert_eq!( + second_keyset_redeemed_after, + Amount::from(100), + "Second keyset should have redeemed 100 sats (its proofs from initial funding spent in swap)" + ); + + // The test verifies that: + // 1. We can have proofs from multiple keysets in a wallet + // 2. Swap operation processes inputs from any keyset but creates outputs using active keyset + // 3. The keyset_counter table correctly handles counters for different keysets independently + // 4. The database upsert logic in increment_keyset_counter works for multiple keysets + // 5. Total issued and redeemed are tracked correctly per keyset during multi-keyset swaps +} diff --git a/crates/cdk-ldk-node/Cargo.toml b/crates/cdk-ldk-node/Cargo.toml new file mode 100644 index 000000000..e252aaa3e --- /dev/null +++ b/crates/cdk-ldk-node/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cdk-ldk-node" +version.workspace = true +edition.workspace = true +authors = ["CDK Developers"] +license.workspace = true +homepage = "https://github.com/cashubtc/cdk" +repository = "https://github.com/cashubtc/cdk.git" +rust-version.workspace = true # MSRV +description = "CDK ln backend for cdk-ldk-node" +readme = "README.md" + +[dependencies] +async-trait.workspace = true +axum.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +futures.workspace = true +tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +thiserror.workspace = true +ldk-node.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } +serde.workspace = true +serde_json.workspace = true +maud = "0.27.0" +tower.workspace = true +tower-http.workspace = true +rust-embed = "8.5.0" +serde_urlencoded = "0.7" +urlencoding = "2.1" diff --git a/crates/cdk-ldk-node/NETWORK_GUIDE.md b/crates/cdk-ldk-node/NETWORK_GUIDE.md new file mode 100644 index 000000000..50488db4c --- /dev/null +++ b/crates/cdk-ldk-node/NETWORK_GUIDE.md @@ -0,0 +1,165 @@ +# LDK Node Network Configuration Guide + +This guide provides configuration examples for running CDK LDK Node on different Bitcoin networks. + +## Table of Contents + +- [Mutinynet (Recommended for Testing)](#mutinynet-recommended-for-testing) +- [Bitcoin Testnet](#bitcoin-testnet) +- [Bitcoin Mainnet](#bitcoin-mainnet) +- [Regtest (Development)](#regtest-development) +- [Docker Deployment](#docker-deployment) +- [Troubleshooting](#troubleshooting) + +## Mutinynet (Recommended for Testing) + +**Mutinynet** is a Bitcoin signet-based test network designed specifically for Lightning Network development with fast block times and reliable infrastructure. + +### Configuration + +```toml +[info] +url = "http://127.0.0.1:8085/" +listen_host = "127.0.0.1" +listen_port = 8085 + +[database] +engine = "sqlite" + +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "signet" +chain_source_type = "esplora" +esplora_url = "https://mutinynet.com/api" +gossip_source_type = "rgs" +rgs_url = "https://rgs.mutinynet.com/snapshot/0" +storage_dir_path = "~/.cdk-ldk-node/mutinynet" +webserver_port = 8091 +``` + +### Environment Variables + +```bash +export CDK_MINTD_LN_BACKEND="ldk-node" +export CDK_MINTD_LDK_NODE_BITCOIN_NETWORK="signet" +export CDK_MINTD_LDK_NODE_ESPLORA_URL="https://mutinynet.com/api" +export CDK_MINTD_LDK_NODE_RGS_URL="https://rgs.mutinynet.com/snapshot/0" +export CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE="rgs" + +cdk-mintd +``` + +### Resources +- **Explorer/Faucet**: +- **Esplora API**: `https://mutinynet.com/api` +- **RGS Endpoint**: `https://rgs.mutinynet.com/snapshot/0` + +## Bitcoin Testnet + +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "testnet" +esplora_url = "https://blockstream.info/testnet/api" +rgs_url = "https://rapidsync.lightningdevkit.org/snapshot" +gossip_source_type = "rgs" +storage_dir_path = "~/.cdk-ldk-node/testnet" +``` + +**Resources**: [Explorer](https://blockstream.info/testnet) | API: `https://blockstream.info/testnet/api` + +## Bitcoin Mainnet + +⚠️ **WARNING**: Uses real Bitcoin! + +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "mainnet" +esplora_url = "https://blockstream.info/api" +rgs_url = "https://rapidsync.lightningdevkit.org/snapshot" +gossip_source_type = "rgs" +storage_dir_path = "/var/lib/cdk-ldk-node/mainnet" # Use absolute path +webserver_host = "127.0.0.1" # CRITICAL: Never bind to 0.0.0.0 in production +webserver_port = 8091 +``` + +**Resources**: [Explorer](https://blockstream.info) | API: `https://blockstream.info/api` + +### Production Security + +🔒 **CRITICAL SECURITY CONSIDERATIONS**: + +1. **Web Interface Security**: The LDK management interface has **NO AUTHENTICATION** and allows sending funds/managing channels. + - **NEVER** bind to `0.0.0.0` or expose publicly + - Only use `127.0.0.1` (localhost) + - Use VPN, SSH tunneling, or reverse proxy with authentication for remote access + +## Regtest (Development) + +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "regtest" +chain_source_type = "bitcoinrpc" +bitcoind_rpc_host = "127.0.0.1" +bitcoind_rpc_port = 18443 +bitcoind_rpc_user = "testuser" +bitcoind_rpc_password = "testpass" +gossip_source_type = "p2p" +``` + +For complete regtest environment: `just regtest` (see [REGTEST_GUIDE.md](../../REGTEST_GUIDE.md)) + +## Docker Deployment + +⚠️ **SECURITY WARNING**: The examples below expose ports for testing. For production, **DO NOT expose port 8091** publicly as the web interface has no authentication and allows sending funds. + +```bash +# Mutinynet example (testing only - web interface exposed) +docker run -d \ + --name cdk-mintd \ + -p 8085:8085 -p 8091:8091 \ + -e CDK_MINTD_LN_BACKEND=ldk-node \ + -e CDK_MINTD_LDK_NODE_BITCOIN_NETWORK=signet \ + -e CDK_MINTD_LDK_NODE_ESPLORA_URL=https://mutinynet.com/api \ + -e CDK_MINTD_LDK_NODE_RGS_URL=https://rgs.mutinynet.com/snapshot/0 \ + -e CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE=rgs \ + cashubtc/cdk-mintd:latest + +# Production example (web interface not exposed) +docker run -d \ + --name cdk-mintd \ + -p 8085:8085 \ + --network host \ + -e CDK_MINTD_LN_BACKEND=ldk-node \ + -e CDK_MINTD_LDK_NODE_BITCOIN_NETWORK=mainnet \ + -e CDK_MINTD_LDK_NODE_WEBSERVER_HOST=127.0.0.1 \ + cashubtc/cdk-mintd:latest +``` + +## Troubleshooting + +### Common Issues +- **RGS sync fails**: Try `gossip_source_type = "p2p"` +- **Connection errors**: Verify API endpoints with curl +- **Port conflicts**: Use `netstat -tuln` to check ports +- **Permissions**: Ensure storage directory is writable + +### Debug Logging +```bash +export CDK_MINTD_LOGGING_CONSOLE_LEVEL="debug" +``` + +### Performance Tips +- Use RGS for faster gossip sync +- PostgreSQL for production +- Monitor initial sync resources diff --git a/crates/cdk-ldk-node/README.md b/crates/cdk-ldk-node/README.md new file mode 100644 index 000000000..db7dcaa0c --- /dev/null +++ b/crates/cdk-ldk-node/README.md @@ -0,0 +1,84 @@ +# CDK LDK Node + +CDK lightning backend for ldk-node, providing Lightning Network functionality for CDK with support for Cashu operations. + +## Features + +- Lightning Network payments (Bolt11 and Bolt12) +- Channel management +- Payment processing for Cashu mint operations +- Web management interface +- Support for multiple Bitcoin networks (Mainnet, Testnet, Signet/Mutinynet, Regtest) +- RGS (Rapid Gossip Sync) and P2P gossip support + +## Quick Start + +### Mutinynet (Recommended for Testing) + +```bash +# Using environment variables (simplest) +export CDK_MINTD_LN_BACKEND="ldk-node" +export CDK_MINTD_LDK_NODE_BITCOIN_NETWORK="signet" +export CDK_MINTD_LDK_NODE_ESPLORA_URL="https://mutinynet.com/api" +export CDK_MINTD_LDK_NODE_RGS_URL="https://rgs.mutinynet.com/snapshot/0" +export CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE="rgs" + +cdk-mintd +``` + +After starting: +- Mint API: +- LDK management interface: +- Get test sats: [mutinynet.com](https://mutinynet.com) + +**For complete network configuration examples, Docker setup, and production deployment, see [NETWORK_GUIDE.md](./NETWORK_GUIDE.md).** + +## Web Management Interface + +The CDK LDK Node includes a built-in web management interface accessible at `http://127.0.0.1:8091` by default. + +⚠️ **SECURITY WARNING**: The web management interface has **NO AUTHENTICATION** and allows sending funds and managing channels. **NEVER expose it publicly** without proper authentication/authorization in front of it. Only bind to localhost (`127.0.0.1`) for security. + +### Key Features +- **Dashboard**: Node status, balance, and recent activity +- **Channel Management**: Open and close Lightning channels +- **Payment Management**: Create invoices, send payments, view history with pagination +- **On-chain Operations**: View balances and manage transactions + +### Configuration + +```toml +[ldk_node] +webserver_host = "127.0.0.1" # IMPORTANT: Only localhost for security +webserver_port = 8091 # 0 = auto-assign port +``` + +Or via environment variables: +- `CDK_MINTD_LDK_NODE_WEBSERVER_HOST` +- `CDK_MINTD_LDK_NODE_WEBSERVER_PORT` + +## Basic Configuration + +### Config File Example + +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "signet" # mainnet, testnet, signet, regtest +esplora_url = "https://mutinynet.com/api" +rgs_url = "https://rgs.mutinynet.com/snapshot/0" +gossip_source_type = "rgs" # rgs or p2p +webserver_port = 8091 +``` + +### Environment Variables + +All options can be set with `CDK_MINTD_LDK_NODE_` prefix: +- `CDK_MINTD_LDK_NODE_BITCOIN_NETWORK` +- `CDK_MINTD_LDK_NODE_ESPLORA_URL` +- `CDK_MINTD_LDK_NODE_RGS_URL` +- `CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE` + +**For detailed network configurations, Docker setup, production deployment, and troubleshooting, see [NETWORK_GUIDE.md](./NETWORK_GUIDE.md).** diff --git a/crates/cdk-ldk-node/src/error.rs b/crates/cdk-ldk-node/src/error.rs new file mode 100644 index 000000000..4464d278a --- /dev/null +++ b/crates/cdk-ldk-node/src/error.rs @@ -0,0 +1,89 @@ +//! LDK Node Errors + +use thiserror::Error; + +/// LDK Node Error +#[derive(Debug, Error)] +pub enum Error { + /// LDK Node error + #[error("LDK Node error: {0}")] + LdkNode(#[from] ldk_node::NodeError), + + /// LDK Build error + #[error("LDK Build error: {0}")] + LdkBuild(#[from] ldk_node::BuildError), + + /// Invalid description + #[error("Invalid description")] + InvalidDescription, + + /// Invalid payment hash + #[error("Invalid payment hash")] + InvalidPaymentHash, + + /// Invalid payment hash length + #[error("Invalid payment hash length")] + InvalidPaymentHashLength, + + /// Invalid payment ID length + #[error("Invalid payment ID length")] + InvalidPaymentIdLength, + + /// Unknown invoice amount + #[error("Unknown invoice amount")] + UnknownInvoiceAmount, + + /// Could not send bolt11 payment + #[error("Could not send bolt11 payment")] + CouldNotSendBolt11, + + /// Could not send bolt11 without amount + #[error("Could not send bolt11 without amount")] + CouldNotSendBolt11WithoutAmount, + + /// Payment not found + #[error("Payment not found")] + PaymentNotFound, + + /// Could not get amount spent + #[error("Could not get amount spent")] + CouldNotGetAmountSpent, + + /// Could not get payment amount + #[error("Could not get payment amount")] + CouldNotGetPaymentAmount, + + /// Unexpected payment kind + #[error("Unexpected payment kind")] + UnexpectedPaymentKind, + + /// Unsupported payment identifier type + #[error("Unsupported payment identifier type")] + UnsupportedPaymentIdentifierType, + + /// Invalid payment direction + #[error("Invalid payment direction")] + InvalidPaymentDirection, + + /// Hex decode error + #[error("Hex decode error: {0}")] + HexDecode(#[from] cdk_common::util::hex::Error), + + /// JSON error + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// Amount conversion error + #[error("Amount conversion error: {0}")] + AmountConversion(#[from] cdk_common::amount::Error), + + /// Invalid hex + #[error("Invalid hex")] + InvalidHex, +} + +impl From for cdk_common::payment::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-ldk-node/src/lib.rs b/crates/cdk-ldk-node/src/lib.rs new file mode 100644 index 000000000..0a89a2fad --- /dev/null +++ b/crates/cdk-ldk-node/src/lib.rs @@ -0,0 +1,996 @@ +//! CDK lightning backend for ldk-node + +#![doc = include_str!("../README.md")] +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use cdk_common::amount::to_unit; +use cdk_common::common::FeeReserve; +use cdk_common::payment::{self, *}; +use cdk_common::util::{hex, unix_time}; +use cdk_common::{Amount, CurrencyUnit, MeltOptions, MeltQuoteState}; +use futures::{Stream, StreamExt}; +use ldk_node::bitcoin::hashes::Hash; +use ldk_node::bitcoin::Network; +use ldk_node::lightning::ln::channelmanager::PaymentId; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description}; +use ldk_node::lightning_types::payment::PaymentHash; +use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus, SendingParameters}; +use ldk_node::{Builder, Event, Node}; +use tokio::runtime::Runtime; +use tokio_stream::wrappers::BroadcastStream; +use tokio_util::sync::CancellationToken; +use tracing::instrument; + +use crate::error::Error; + +mod error; +mod web; + +/// CDK Lightning backend using LDK Node +/// +/// Provides Lightning Network functionality for CDK with support for Cashu operations. +/// Handles payment creation, processing, and event management using the Lightning Development Kit. +#[derive(Clone)] +pub struct CdkLdkNode { + inner: Arc, + fee_reserve: FeeReserve, + wait_invoice_cancel_token: CancellationToken, + wait_invoice_is_active: Arc, + sender: tokio::sync::broadcast::Sender, + receiver: Arc>, + events_cancel_token: CancellationToken, + runtime: Option>, + web_addr: Option, +} + +/// Configuration for connecting to Bitcoin RPC +/// +/// Contains the necessary connection parameters for Bitcoin Core RPC interface. +#[derive(Debug, Clone)] +pub struct BitcoinRpcConfig { + /// Bitcoin RPC server hostname or IP address + pub host: String, + /// Bitcoin RPC server port number + pub port: u16, + /// Username for Bitcoin RPC authentication + pub user: String, + /// Password for Bitcoin RPC authentication + pub password: String, +} + +/// Source of blockchain data for the Lightning node +/// +/// Specifies how the node should connect to the Bitcoin network to retrieve +/// blockchain information and broadcast transactions. +#[derive(Debug, Clone)] +pub enum ChainSource { + /// Use an Esplora server for blockchain data + /// + /// Contains the URL of the Esplora server endpoint + Esplora(String), + /// Use Bitcoin Core RPC for blockchain data + /// + /// Contains the configuration for connecting to Bitcoin Core + BitcoinRpc(BitcoinRpcConfig), +} + +/// Source of Lightning network gossip data +/// +/// Specifies how the node should learn about the Lightning Network topology +/// and routing information. +#[derive(Debug, Clone)] +pub enum GossipSource { + /// Learn gossip through peer-to-peer connections + /// + /// The node will connect to other Lightning nodes and exchange gossip data directly + P2P, + /// Use Rapid Gossip Sync for efficient gossip updates + /// + /// Contains the URL of the RGS server for compressed gossip data + RapidGossipSync(String), +} + +impl CdkLdkNode { + /// Create a new CDK LDK Node instance + /// + /// # Arguments + /// * `network` - Bitcoin network (mainnet, testnet, regtest, signet) + /// * `chain_source` - Source of blockchain data (Esplora or Bitcoin RPC) + /// * `gossip_source` - Source of Lightning network gossip data + /// * `storage_dir_path` - Directory path for node data storage + /// * `fee_reserve` - Fee reserve configuration for payments + /// * `listening_address` - Socket addresses for peer connections + /// * `runtime` - Optional Tokio runtime to use for starting the node + /// + /// # Returns + /// A new `CdkLdkNode` instance ready to be started + /// + /// # Errors + /// Returns an error if the LDK node builder fails to create the node + pub fn new( + network: Network, + chain_source: ChainSource, + gossip_source: GossipSource, + storage_dir_path: String, + fee_reserve: FeeReserve, + listening_address: Vec, + runtime: Option>, + ) -> Result { + let mut builder = Builder::new(); + builder.set_network(network); + tracing::info!("Storage dir of node is {}", storage_dir_path); + builder.set_storage_dir_path(storage_dir_path); + + match chain_source { + ChainSource::Esplora(esplora_url) => { + builder.set_chain_source_esplora(esplora_url, None); + } + ChainSource::BitcoinRpc(BitcoinRpcConfig { + host, + port, + user, + password, + }) => { + builder.set_chain_source_bitcoind_rpc(host, port, user, password); + } + } + + match gossip_source { + GossipSource::P2P => { + builder.set_gossip_source_p2p(); + } + GossipSource::RapidGossipSync(rgs_url) => { + builder.set_gossip_source_rgs(rgs_url); + } + } + + builder.set_listening_addresses(listening_address)?; + + builder.set_node_alias("cdk-ldk-node".to_string())?; + + let node = builder.build()?; + + tracing::info!("Creating tokio channel for payment notifications"); + let (sender, receiver) = tokio::sync::broadcast::channel(8); + + let id = node.node_id(); + + let adr = node.announcement_addresses(); + + tracing::info!( + "Created node {} with address {:?} on network {}", + id, + adr, + network + ); + + Ok(Self { + inner: node.into(), + fee_reserve, + wait_invoice_cancel_token: CancellationToken::new(), + wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + sender, + receiver: Arc::new(receiver), + events_cancel_token: CancellationToken::new(), + runtime, + web_addr: None, + }) + } + + /// Set the web server address for the LDK node management interface + /// + /// # Arguments + /// * `addr` - Socket address for the web server. If None, no web server will be started. + pub fn set_web_addr(&mut self, addr: Option) { + self.web_addr = addr; + } + + /// Get a default web server address using an unused port + /// + /// Returns a SocketAddr with localhost and port 0, which will cause + /// the system to automatically assign an available port + pub fn default_web_addr() -> SocketAddr { + SocketAddr::from(([127, 0, 0, 1], 8091)) + } + + /// Start the CDK LDK Node + /// + /// Starts the underlying LDK node and begins event processing. + /// Sets up event handlers to listen for Lightning events like payment received. + /// + /// # Returns + /// Returns `Ok(())` on successful start, error otherwise + /// + /// # Errors + /// Returns an error if the LDK node fails to start or event handling setup fails + pub fn start_ldk_node(&self) -> Result<(), Error> { + match &self.runtime { + Some(runtime) => { + tracing::info!("Starting cdk-ldk node with existing runtime"); + self.inner.start_with_runtime(Arc::clone(runtime))? + } + None => { + tracing::info!("Starting cdk-ldk-node with new runtime"); + self.inner.start()? + } + }; + let node_config = self.inner.config(); + + tracing::info!("Starting node with network {}", node_config.network); + + tracing::info!("Node status: {:?}", self.inner.status()); + + self.handle_events()?; + + Ok(()) + } + + /// Start the web server for the LDK node management interface + /// + /// Starts a web server that provides a user interface for managing the LDK node. + /// The web interface allows users to view balances, manage channels, create invoices, + /// and send payments. + /// + /// # Arguments + /// * `web_addr` - The socket address to bind the web server to + /// + /// # Returns + /// Returns `Ok(())` on successful start, error otherwise + /// + /// # Errors + /// Returns an error if the web server fails to start + pub fn start_web_server(&self, web_addr: SocketAddr) -> Result<(), Error> { + let web_server = crate::web::WebServer::new(Arc::new(self.clone())); + + tokio::spawn(async move { + if let Err(e) = web_server.serve(web_addr).await { + tracing::error!("Web server error: {}", e); + } + }); + + Ok(()) + } + + /// Stop the CDK LDK Node + /// + /// Gracefully stops the node by cancelling all active tasks and event handlers. + /// This includes: + /// - Cancelling the event handler task + /// - Cancelling any active wait_invoice streams + /// - Stopping the underlying LDK node + /// + /// # Returns + /// Returns `Ok(())` on successful shutdown, error otherwise + /// + /// # Errors + /// Returns an error if the underlying LDK node fails to stop + pub fn stop_ldk_node(&self) -> Result<(), Error> { + tracing::info!("Stopping CdkLdkNode"); + // Cancel all tokio tasks + tracing::info!("Cancelling event handler"); + self.events_cancel_token.cancel(); + + // Cancel any wait_invoice streams + if self.is_wait_invoice_active() { + tracing::info!("Cancelling wait_invoice stream"); + self.wait_invoice_cancel_token.cancel(); + } + + // Stop the LDK node + tracing::info!("Stopping LDK node"); + self.inner.stop()?; + tracing::info!("CdkLdkNode stopped successfully"); + Ok(()) + } + + /// Handle payment received event + async fn handle_payment_received( + node: &Arc, + sender: &tokio::sync::broadcast::Sender, + payment_id: Option, + payment_hash: PaymentHash, + amount_msat: u64, + ) { + tracing::info!( + "Received payment for hash={} of amount={} msat", + payment_hash, + amount_msat + ); + + let payment_id = match payment_id { + Some(id) => id, + None => { + tracing::warn!("Received payment without payment_id"); + return; + } + }; + + let payment_id_hex = hex::encode(payment_id.0); + + if amount_msat == 0 { + tracing::warn!("Payment of no amount"); + return; + } + + tracing::info!( + "Processing payment notification: id={}, amount={} msats", + payment_id_hex, + amount_msat + ); + + let payment_details = match node.payment(&payment_id) { + Some(details) => details, + None => { + tracing::error!("Could not find payment details for id={}", payment_id_hex); + return; + } + }; + + let (payment_identifier, payment_id) = match payment_details.kind { + PaymentKind::Bolt11 { hash, .. } => { + (PaymentIdentifier::PaymentHash(hash.0), hash.to_string()) + } + PaymentKind::Bolt12Offer { hash, offer_id, .. } => match hash { + Some(h) => ( + PaymentIdentifier::OfferId(offer_id.to_string()), + h.to_string(), + ), + None => { + tracing::error!("Bolt12 payment missing hash"); + return; + } + }, + k => { + tracing::warn!("Received payment of kind {:?} which is not supported", k); + return; + } + }; + + let wait_payment_response = WaitPaymentResponse { + payment_identifier, + payment_amount: amount_msat.into(), + unit: CurrencyUnit::Msat, + payment_id, + }; + + match sender.send(wait_payment_response) { + Ok(_) => tracing::info!("Successfully sent payment notification to stream"), + Err(err) => tracing::error!( + "Could not send payment received notification on channel: {}", + err + ), + } + } + + /// Set up event handling for the node + pub fn handle_events(&self) -> Result<(), Error> { + let node = self.inner.clone(); + let sender = self.sender.clone(); + let cancel_token = self.events_cancel_token.clone(); + + tracing::info!("Starting event handler task"); + + tokio::spawn(async move { + tracing::info!("Event handler loop started"); + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + tracing::info!("Event handler cancelled"); + break; + } + event = node.next_event_async() => { + match event { + Event::PaymentReceived { + payment_id, + payment_hash, + amount_msat, + custom_records: _ + } => { + Self::handle_payment_received( + &node, + &sender, + payment_id, + payment_hash, + amount_msat + ).await; + } + event => { + tracing::debug!("Received other ldk node event: {:?}", event); + } + } + + if let Err(err) = node.event_handled() { + tracing::error!("Error handling node event: {}", err); + } else { + tracing::debug!("Successfully handled node event"); + } + } + } + } + tracing::info!("Event handler loop terminated"); + }); + + tracing::info!("Event handler task spawned"); + Ok(()) + } + + /// Get Node used + pub fn node(&self) -> Arc { + Arc::clone(&self.inner) + } +} + +/// Mint payment trait +#[async_trait] +impl MintPayment for CdkLdkNode { + type Err = payment::Error; + + /// Start the payment processor + /// Starts the LDK node and begins event processing + async fn start(&self) -> Result<(), Self::Err> { + self.start_ldk_node().map_err(|e| { + tracing::error!("Failed to start CdkLdkNode: {}", e); + e + })?; + + tracing::info!("CdkLdkNode payment processor started successfully"); + + // Start web server if configured + if let Some(web_addr) = self.web_addr { + tracing::info!("Starting LDK Node web interface on {}", web_addr); + self.start_web_server(web_addr).map_err(|e| { + tracing::error!("Failed to start web server: {}", e); + e + })?; + } else { + tracing::info!("No web server address configured, skipping web interface"); + } + + Ok(()) + } + + /// Stop the payment processor + /// Gracefully stops the LDK node and cancels all background tasks + async fn stop(&self) -> Result<(), Self::Err> { + self.stop_ldk_node().map_err(|e| { + tracing::error!("Failed to stop CdkLdkNode: {}", e); + e.into() + }) + } + + /// Base Settings + async fn get_settings(&self) -> Result { + let settings = Bolt11Settings { + mpp: false, + unit: CurrencyUnit::Msat, + invoice_description: true, + amountless: true, + bolt12: true, + }; + Ok(serde_json::to_value(settings)?) + } + + /// Create a new invoice + #[instrument(skip(self))] + async fn create_incoming_payment_request( + &self, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + match options { + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let amount_msat = to_unit(bolt11_options.amount, unit, &CurrencyUnit::Msat)?; + let description = bolt11_options.description.unwrap_or_default(); + let time = bolt11_options + .unix_expiry + .map(|t| t - unix_time()) + .unwrap_or(36000); + + let description = Bolt11InvoiceDescription::Direct( + Description::new(description).map_err(|_| Error::InvalidDescription)?, + ); + + let payment = self + .inner + .bolt11_payment() + .receive(amount_msat.into(), &description, time as u32) + .map_err(Error::LdkNode)?; + + let payment_hash = payment.payment_hash().to_string(); + let payment_identifier = PaymentIdentifier::PaymentHash( + hex::decode(&payment_hash)? + .try_into() + .map_err(|_| Error::InvalidPaymentHashLength)?, + ); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: payment_identifier, + request: payment.to_string(), + expiry: Some(unix_time() + time), + }) + } + IncomingPaymentOptions::Bolt12(bolt12_options) => { + let Bolt12IncomingPaymentOptions { + description, + amount, + unix_expiry, + } = *bolt12_options; + + let time = unix_expiry.map(|t| (t - unix_time()) as u32); + + let offer = match amount { + Some(amount) => { + let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?; + + self.inner + .bolt12_payment() + .receive( + amount_msat.into(), + &description.unwrap_or("".to_string()), + time, + None, + ) + .map_err(Error::LdkNode)? + } + None => self + .inner + .bolt12_payment() + .receive_variable_amount(&description.unwrap_or("".to_string()), time) + .map_err(Error::LdkNode)?, + }; + let payment_identifier = PaymentIdentifier::OfferId(offer.id().to_string()); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: payment_identifier, + request: offer.to_string(), + expiry: time.map(|a| a as u64), + }) + } + } + } + + /// Get payment quote + /// Used to get fee and amount required for a payment request + #[instrument(skip_all)] + async fn get_payment_quote( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; + + let amount_msat = match bolt11_options.melt_options { + Some(melt_options) => melt_options.amount_msat(), + None => bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + .into(), + }; + + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + + let fee = match relative_fee_reserve > absolute_fee_reserve { + true => relative_fee_reserve, + false => absolute_fee_reserve, + }; + + let payment_hash = bolt11.payment_hash().to_string(); + let payment_hash_bytes = hex::decode(&payment_hash)? + .try_into() + .map_err(|_| Error::InvalidPaymentHashLength)?; + + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::PaymentHash(payment_hash_bytes)), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = bolt12_options.offer; + + let amount_msat = match bolt12_options.melt_options { + Some(melt_options) => melt_options.amount_msat(), + None => { + let amount = offer.amount().ok_or(payment::Error::AmountMismatch)?; + + match amount { + ldk_node::lightning::offers::offer::Amount::Bitcoin { + amount_msats, + } => amount_msats.into(), + _ => return Err(payment::Error::AmountMismatch), + } + } + }; + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + + let fee = match relative_fee_reserve > absolute_fee_reserve { + true => relative_fee_reserve, + false => absolute_fee_reserve, + }; + + Ok(PaymentQuoteResponse { + request_lookup_id: None, + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) + } + } + } + + /// Pay request + #[instrument(skip(self, options))] + async fn make_payment( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; + + let send_params = match bolt11_options + .max_fee_amount + .map(|f| { + to_unit(f, unit, &CurrencyUnit::Msat).map(|amount_msat| SendingParameters { + max_total_routing_fee_msat: Some(Some(amount_msat.into())), + max_channel_saturation_power_of_half: None, + max_total_cltv_expiry_delta: None, + max_path_count: None, + }) + }) + .transpose() + { + Ok(params) => params, + Err(err) => { + tracing::error!("Failed to convert fee amount: {}", err); + return Err(payment::Error::Custom(format!("Invalid fee amount: {err}"))); + } + }; + + let payment_id = match bolt11_options.melt_options { + Some(MeltOptions::Amountless { amountless }) => self + .inner + .bolt11_payment() + .send_using_amount(&bolt11, amountless.amount_msat.into(), send_params) + .map_err(|err| { + tracing::error!("Could not send send amountless bolt11: {}", err); + Error::CouldNotSendBolt11WithoutAmount + })?, + None => self + .inner + .bolt11_payment() + .send(&bolt11, send_params) + .map_err(|err| { + tracing::error!("Could not send bolt11 {}", err); + Error::CouldNotSendBolt11 + })?, + _ => return Err(payment::Error::UnsupportedPaymentOption), + }; + + // Check payment status for up to 10 seconds + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + let (status, payment_details) = loop { + let details = self + .inner + .payment(&payment_id) + .ok_or(Error::PaymentNotFound)?; + + match details.status { + PaymentStatus::Succeeded => break (MeltQuoteState::Paid, details), + PaymentStatus::Failed => { + tracing::error!("Failed to pay bolt11 payment."); + break (MeltQuoteState::Failed, details); + } + PaymentStatus::Pending => { + if start.elapsed() > timeout { + tracing::warn!( + "Paying bolt11 exceeded timeout 10 seconds no longer waitning." + ); + break (MeltQuoteState::Pending, details); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + } + }; + + let payment_proof = match payment_details.kind { + PaymentKind::Bolt11 { + hash: _, + preimage, + secret: _, + } => preimage.map(|p| p.to_string()), + _ => return Err(Error::UnexpectedPaymentKind.into()), + }; + + let total_spent = payment_details + .amount_msat + .ok_or(Error::CouldNotGetAmountSpent)? + + payment_details.fee_paid_msat.unwrap_or_default(); + + let total_spent = to_unit(total_spent, &CurrencyUnit::Msat, unit)?; + + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentHash( + bolt11.payment_hash().to_byte_array(), + ), + payment_proof, + status, + total_spent, + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(bolt12_options) => { + let offer = bolt12_options.offer; + + let payment_id = match bolt12_options.melt_options { + Some(MeltOptions::Amountless { amountless }) => self + .inner + .bolt12_payment() + .send_using_amount(&offer, amountless.amount_msat.into(), None, None) + .map_err(Error::LdkNode)?, + None => self + .inner + .bolt12_payment() + .send(&offer, None, None) + .map_err(Error::LdkNode)?, + _ => return Err(payment::Error::UnsupportedPaymentOption), + }; + + // Check payment status for up to 10 seconds + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + let (status, payment_details) = loop { + let details = self + .inner + .payment(&payment_id) + .ok_or(Error::PaymentNotFound)?; + + match details.status { + PaymentStatus::Succeeded => break (MeltQuoteState::Paid, details), + PaymentStatus::Failed => { + tracing::error!("Payment with id {} failed.", payment_id); + break (MeltQuoteState::Failed, details); + } + PaymentStatus::Pending => { + if start.elapsed() > timeout { + tracing::warn!( + "Payment has been being for 10 seconds. No longer waiting" + ); + break (MeltQuoteState::Pending, details); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + } + }; + + let payment_proof = match payment_details.kind { + PaymentKind::Bolt12Offer { + hash: _, + preimage, + secret: _, + offer_id: _, + payer_note: _, + quantity: _, + } => preimage.map(|p| p.to_string()), + _ => return Err(Error::UnexpectedPaymentKind.into()), + }; + + let total_spent = payment_details + .amount_msat + .ok_or(Error::CouldNotGetAmountSpent)? + + payment_details.fee_paid_msat.unwrap_or_default(); + + let total_spent = to_unit(total_spent, &CurrencyUnit::Msat, unit)?; + + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentId(payment_id.0), + payment_proof, + status, + total_spent, + unit: unit.clone(), + }) + } + } + } + + /// Listen for invoices to be paid to the mint + /// Returns a stream of request_lookup_id once invoices are paid + #[instrument(skip(self))] + async fn wait_payment_event( + &self, + ) -> Result + Send>>, Self::Err> { + tracing::info!("Starting stream for invoices - wait_any_incoming_payment called"); + + // Set active flag to indicate stream is active + self.wait_invoice_is_active.store(true, Ordering::SeqCst); + tracing::debug!("wait_invoice_is_active set to true"); + + let receiver = self.receiver.clone(); + + tracing::info!("Receiver obtained successfully, creating response stream"); + + // Transform the String stream into a WaitPaymentResponse stream + let response_stream = BroadcastStream::new(receiver.resubscribe()); + + // Map the stream to handle BroadcastStreamRecvError and wrap in Event + let response_stream = response_stream.filter_map(|result| async move { + match result { + Ok(payment) => Some(cdk_common::payment::Event::PaymentReceived(payment)), + Err(err) => { + tracing::warn!("Error in broadcast stream: {}", err); + None + } + } + }); + + // Create a combined stream that also handles cancellation + let cancel_token = self.wait_invoice_cancel_token.clone(); + let is_active = self.wait_invoice_is_active.clone(); + + let stream = Box::pin(response_stream); + + // Set up a task to clean up when the stream is dropped + tokio::spawn(async move { + cancel_token.cancelled().await; + tracing::info!("wait_invoice stream cancelled"); + is_active.store(false, Ordering::SeqCst); + }); + + tracing::info!("wait_any_incoming_payment returning stream"); + Ok(stream) + } + + /// Is wait invoice active + fn is_wait_invoice_active(&self) -> bool { + self.wait_invoice_is_active.load(Ordering::SeqCst) + } + + /// Cancel wait invoice + fn cancel_wait_invoice(&self) { + self.wait_invoice_cancel_token.cancel() + } + + /// Check the status of an incoming payment + async fn check_incoming_payment_status( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let payment_id_str = match payment_identifier { + PaymentIdentifier::PaymentHash(hash) => hex::encode(hash), + PaymentIdentifier::CustomId(id) => id.clone(), + _ => return Err(Error::UnsupportedPaymentIdentifierType.into()), + }; + + let payment_id = PaymentId( + hex::decode(&payment_id_str)? + .try_into() + .map_err(|_| Error::InvalidPaymentIdLength)?, + ); + + let payment_details = self + .inner + .payment(&payment_id) + .ok_or(Error::PaymentNotFound)?; + + if payment_details.direction == PaymentDirection::Outbound { + return Err(Error::InvalidPaymentDirection.into()); + } + + let amount = if payment_details.status == PaymentStatus::Succeeded { + payment_details + .amount_msat + .ok_or(Error::CouldNotGetPaymentAmount)? + } else { + return Ok(vec![]); + }; + + let response = WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: amount.into(), + unit: CurrencyUnit::Msat, + payment_id: payment_id_str, + }; + + Ok(vec![response]) + } + + /// Check the status of an outgoing payment + async fn check_outgoing_payment( + &self, + request_lookup_id: &PaymentIdentifier, + ) -> Result { + let payment_details = match request_lookup_id { + PaymentIdentifier::PaymentHash(id_hash) => self + .inner + .list_payments_with_filter( + |p| matches!(&p.kind, PaymentKind::Bolt11 { hash, .. } if &hash.0 == id_hash), + ) + .first() + .cloned(), + PaymentIdentifier::PaymentId(id) => self.inner.payment(&PaymentId( + hex::decode(id)? + .try_into() + .map_err(|_| payment::Error::Custom("Invalid hex".to_string()))?, + )), + _ => { + return Ok(MakePaymentResponse { + payment_lookup_id: request_lookup_id.clone(), + status: MeltQuoteState::Unknown, + payment_proof: None, + total_spent: Amount::ZERO, + unit: CurrencyUnit::Msat, + }); + } + } + .ok_or(Error::PaymentNotFound)?; + + // This check seems reversed in the original code, so I'm fixing it here + if payment_details.direction != PaymentDirection::Outbound { + return Err(Error::InvalidPaymentDirection.into()); + } + + let status = match payment_details.status { + PaymentStatus::Pending => MeltQuoteState::Pending, + PaymentStatus::Succeeded => MeltQuoteState::Paid, + PaymentStatus::Failed => MeltQuoteState::Failed, + }; + + let payment_proof = match payment_details.kind { + PaymentKind::Bolt11 { + hash: _, + preimage, + secret: _, + } => preimage.map(|p| p.to_string()), + _ => return Err(Error::UnexpectedPaymentKind.into()), + }; + + let total_spent = payment_details + .amount_msat + .ok_or(Error::CouldNotGetAmountSpent)?; + + Ok(MakePaymentResponse { + payment_lookup_id: request_lookup_id.clone(), + payment_proof, + status, + total_spent: total_spent.into(), + unit: CurrencyUnit::Msat, + }) + } +} + +impl Drop for CdkLdkNode { + fn drop(&mut self) { + tracing::info!("Drop called on CdkLdkNode"); + self.wait_invoice_cancel_token.cancel(); + tracing::debug!("Cancelled wait_invoice token in drop"); + } +} diff --git a/crates/cdk-ldk-node/src/web/handlers/channels.rs b/crates/cdk-ldk-node/src/web/handlers/channels.rs new file mode 100644 index 000000000..654a29e1b --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/channels.rs @@ -0,0 +1,560 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, Response}; +use axum::Form; +use ldk_node::bitcoin::secp256k1::PublicKey; +use ldk_node::lightning::ln::msgs::SocketAddress; +use ldk_node::UserChannelId; +use maud::html; +use serde::Deserialize; + +use crate::web::handlers::utils::deserialize_optional_u64; +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, + success_message, +}; + +#[derive(Deserialize)] +pub struct OpenChannelForm { + node_id: String, + address: String, + port: u32, + amount_sats: u64, + #[serde(deserialize_with = "deserialize_optional_u64")] + push_btc: Option, +} + +#[derive(Deserialize)] +pub struct CloseChannelForm { + channel_id: String, + node_id: String, +} + +pub async fn channels_page(State(_state): State) -> Result { + // Redirect to the balance page since channels are now part of the Lightning section + Ok(Response::builder() + .status(StatusCode::FOUND) + .header("Location", "/balance") + .body(Body::empty()) + .unwrap()) +} + +pub async fn open_channel_page(State(state): State) -> Result, StatusCode> { + let content = form_card( + "Open New Channel", + html! { + form method="post" action="/channels/open" { + div class="form-group" { + label for="node_id" { "Node Public Key" } + input type="text" id="node_id" name="node_id" required placeholder="02..." {} + } + div class="form-group" { + label for="address" { "Node Address" } + input type="text" id="address" name="address" required placeholder="127.0.0.1" {} + } + div class="form-group" { + label for="port" { "Port" } + input type="number" id="port" name="port" required value="9735" {} + } + div class="form-group" { + label for="amount_btc" { "Channel Size" } + input type="number" id="amount_sats" name="amount_sats" required placeholder="₿0" step="1" {} + } + div class="form-group" { + label for="push_btc" { "Push Amount (optional)" } + input type="number" id="push_btc" name="push_btc" placeholder="₿0" step="1" {} + } + div class="form-actions" { + a href="/balance" { button type="button" class="button-secondary" { "Cancel" } } + button type="submit" class="button-primary" { "Open Channel" } + } + } + }, + ); + + let is_running = is_node_running(&state.node.inner); + Ok(Html( + layout_with_status("Open Channel", content, is_running).into_string(), + )) +} + +pub async fn post_open_channel( + State(state): State, + Form(form): Form, +) -> Result { + tracing::info!( + "Web interface: Attempting to open channel to node_id={}, address={}:{}, amount_sats={}, push_btc={:?}", + form.node_id, + form.address, + form.port, + form.amount_sats, + form.push_btc + ); + + let pubkey = match PublicKey::from_str(&form.node_id) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!("Web interface: Invalid node public key provided: {}", e); + let content = html! { + (error_message(&format!("Invalid node public key: {e}"))) + div class="card" { + a href="/channels/open" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Open Channel Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + let socket_addr = match SocketAddress::from_str(&format!("{}:{}", form.address, form.port)) { + Ok(addr) => addr, + Err(e) => { + tracing::warn!("Web interface: Invalid address:port combination: {}", e); + let content = html! { + (error_message(&format!("Invalid address:port combination: {e}"))) + div class="card" { + a href="/channels/open" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Open Channel Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + // First connect to the peer + tracing::info!( + "Web interface: Connecting to peer {} at {}", + pubkey, + socket_addr + ); + if let Err(e) = state.node.inner.connect(pubkey, socket_addr.clone(), true) { + tracing::error!("Web interface: Failed to connect to peer {}: {}", pubkey, e); + let content = html! { + (error_message(&format!("Failed to connect to peer: {e}"))) + div class="card" { + a href="/channels/open" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Open Channel Error", content, true).into_string(), + )) + .unwrap()); + } + + // Then open the channel + tracing::info!( + "Web interface: Opening announced channel to {} with amount {} sats and push amount {:?} msats", + pubkey, + form.amount_sats, + form.push_btc.map(|a| a * 1000) + ); + let channel_result = state.node.inner.open_announced_channel( + pubkey, + socket_addr, + form.amount_sats, + form.push_btc.map(|a| a * 1000), + None, + ); + + let content = match channel_result { + Ok(user_channel_id) => { + tracing::info!( + "Web interface: Successfully initiated channel opening with user_channel_id={} to {}", + user_channel_id.0, + pubkey + ); + html! { + (success_message("Channel opening initiated successfully!")) + (info_card( + "Channel Details", + vec![ + ("Temporary Channel ID", user_channel_id.0.to_string()), + ("Node ID", form.node_id), + ("Amount", format_sats_as_btc(form.amount_sats)), + ("Push Amount", form.push_btc.map(format_sats_as_btc).unwrap_or_else(|| "₿ 0".to_string())), + ] + )) + div class="card" { + p { "The channel is now being opened. It may take some time for the channel to become active." } + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + Err(e) => { + tracing::error!("Web interface: Failed to open channel to {}: {}", pubkey, e); + html! { + (error_message(&format!("Failed to open channel: {e}"))) + div class="card" { + a href="/channels/open" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Open Channel Result", content, true).into_string(), + )) + .unwrap()) +} + +pub async fn close_channel_page( + State(state): State, + query: Query>, +) -> Result, StatusCode> { + let channel_id = query.get("channel_id").unwrap_or(&"".to_string()).clone(); + let node_id = query.get("node_id").unwrap_or(&"".to_string()).clone(); + + if channel_id.is_empty() || node_id.is_empty() { + let content = html! { + (error_message("Missing channel ID or node ID")) + div class="card" { + a href="/balance" { button { "← Back to Lightning" } } + } + }; + return Ok(Html( + layout_with_status("Close Channel Error", content, true).into_string(), + )); + } + + // Get channel information for amount display + let channels = state.node.inner.list_channels(); + let channel = channels + .iter() + .find(|c| c.user_channel_id.0.to_string() == channel_id); + + let content = form_card( + "Close Channel", + html! { + p style="margin-bottom: 1.5rem;" { "Are you sure you want to close this channel?" } + + // Channel details in consistent format + div class="channel-details" { + div class="detail-row" { + span class="detail-label" { "User Channel ID" } + span class="detail-value-amount" { (channel_id) } + } + div class="detail-row" { + span class="detail-label" { "Node ID" } + span class="detail-value-amount" { (node_id) } + } + @if let Some(ch) = channel { + div class="detail-row" { + span class="detail-label" { "Channel Amount" } + span class="detail-value-amount" { (format_sats_as_btc(ch.channel_value_sats)) } + } + } + } + + form method="post" action="/channels/close" style="margin-top: 1rem; display: flex; justify-content: space-between; align-items: center;" { + input type="hidden" name="channel_id" value=(channel_id) {} + input type="hidden" name="node_id" value=(node_id) {} + a href="/balance" { button type="button" class="button-secondary" { "Cancel" } } + button type="submit" class="button-destructive" { "Close Channel" } + } + }, + ); + + let is_running = is_node_running(&state.node.inner); + Ok(Html( + layout_with_status("Close Channel", content, is_running).into_string(), + )) +} + +pub async fn force_close_channel_page( + State(state): State, + query: Query>, +) -> Result, StatusCode> { + let channel_id = query.get("channel_id").unwrap_or(&"".to_string()).clone(); + let node_id = query.get("node_id").unwrap_or(&"".to_string()).clone(); + + if channel_id.is_empty() || node_id.is_empty() { + let content = html! { + (error_message("Missing channel ID or node ID")) + div class="card" { + a href="/balance" { button { "← Back to Lightning" } } + } + }; + return Ok(Html( + layout_with_status("Force Close Channel Error", content, true).into_string(), + )); + } + + // Get channel information for amount display + let channels = state.node.inner.list_channels(); + let channel = channels + .iter() + .find(|c| c.user_channel_id.0.to_string() == channel_id); + + let content = form_card( + "Force Close Channel", + html! { + div style="border: 2px solid #f97316; background-color: rgba(249, 115, 22, 0.1); padding: 1rem; margin-bottom: 1rem; border-radius: 0.5rem;" { + h4 style="color: #f97316; margin: 0 0 0.5rem 0;" { "⚠️ Warning: Force Close" } + p style="color: #f97316; margin: 0; font-size: 0.9rem;" { + "Force close should NOT be used if normal close is preferred. " + "Force close will immediately broadcast the latest commitment transaction and may result in delayed fund recovery. " + "Only use this if the channel counterparty is unresponsive or there are other issues preventing normal closure." + } + } + p style="margin-bottom: 1.5rem;" { "Are you sure you want to force close this channel?" } + + // Channel details in consistent format + div class="channel-details" { + div class="detail-row" { + span class="detail-label" { "User Channel ID" } + span class="detail-value-amount" { (channel_id) } + } + div class="detail-row" { + span class="detail-label" { "Node ID" } + span class="detail-value-amount" { (node_id) } + } + @if let Some(ch) = channel { + div class="detail-row" { + span class="detail-label" { "Channel Amount" } + span class="detail-value-amount" { (format_sats_as_btc(ch.channel_value_sats)) } + } + } + } + + form method="post" action="/channels/force-close" style="margin-top: 1rem; display: flex; justify-content: space-between; align-items: center;" { + input type="hidden" name="channel_id" value=(channel_id) {} + input type="hidden" name="node_id" value=(node_id) {} + a href="/balance" { button type="button" class="button-secondary" { "Cancel" } } + button type="submit" class="button-destructive" { "Force Close Channel" } + } + }, + ); + + let is_running = is_node_running(&state.node.inner); + Ok(Html( + layout_with_status("Force Close Channel", content, is_running).into_string(), + )) +} + +pub async fn post_close_channel( + State(state): State, + Form(form): Form, +) -> Result { + tracing::info!( + "Web interface: Attempting to close channel_id={} with node_id={}", + form.channel_id, + form.node_id + ); + + let node_pubkey = match PublicKey::from_str(&form.node_id) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!( + "Web interface: Invalid node public key for channel close: {}", + e + ); + let content = html! { + (error_message(&format!("Invalid node public key: {e}"))) + div class="card" { + a href="/channels" { button { "← Back to Channels" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Close Channel Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + let channel_id: u128 = match form.channel_id.parse() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Web interface: Invalid channel ID for channel close: {}", e); + let content = html! { + (error_message(&format!("Invalid channel ID: {e}"))) + div class="card" { + a href="/channels" { button { "← Back to Channels" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Close Channel Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + let user_channel_id = UserChannelId(channel_id); + tracing::info!( + "Web interface: Initiating cooperative close for channel {} with {}", + channel_id, + node_pubkey + ); + let close_result = state + .node + .inner + .close_channel(&user_channel_id, node_pubkey); + + let content = match close_result { + Ok(()) => { + tracing::info!( + "Web interface: Successfully initiated cooperative close for channel {} with {}", + channel_id, + node_pubkey + ); + html! { + (success_message("Channel closing initiated successfully!")) + div class="card" { + p { "The channel is now being closed. It may take some time for the closing transaction to be confirmed." } + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + Err(e) => { + tracing::error!( + "Web interface: Failed to close channel {} with {}: {}", + channel_id, + node_pubkey, + e + ); + html! { + (error_message(&format!("Failed to close channel: {e}"))) + div class="card" { + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Close Channel Result", content, true).into_string(), + )) + .unwrap()) +} + +pub async fn post_force_close_channel( + State(state): State, + Form(form): Form, +) -> Result { + tracing::info!( + "Web interface: Attempting to FORCE CLOSE channel_id={} with node_id={}", + form.channel_id, + form.node_id + ); + + let node_pubkey = match PublicKey::from_str(&form.node_id) { + Ok(pk) => pk, + Err(e) => { + tracing::warn!( + "Web interface: Invalid node public key for force close: {}", + e + ); + let content = html! { + (error_message(&format!("Invalid node public key: {e}"))) + div class="card" { + a href="/channels" { button { "← Back to Channels" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Force Close Channel Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + let channel_id: u128 = match form.channel_id.parse() { + Ok(id) => id, + Err(e) => { + tracing::warn!("Web interface: Invalid channel ID for force close: {}", e); + let content = html! { + (error_message(&format!("Invalid channel ID: {e}"))) + div class="card" { + a href="/channels" { button { "← Back to Channels" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Force Close Channel Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + let user_channel_id = UserChannelId(channel_id); + tracing::warn!("Web interface: Initiating FORCE CLOSE for channel {} with {} - this will broadcast the latest commitment transaction", channel_id, node_pubkey); + let force_close_result = + state + .node + .inner + .force_close_channel(&user_channel_id, node_pubkey, None); + + let content = match force_close_result { + Ok(()) => { + tracing::info!( + "Web interface: Successfully initiated force close for channel {} with {}", + channel_id, + node_pubkey + ); + html! { + (success_message("Channel force close initiated successfully!")) + div class="card" style="border: 1px solid #d63384; background-color: rgba(214, 51, 132, 0.1);" { + h4 style="color: #d63384;" { "Force Close Complete" } + p { "The channel has been force closed. The latest commitment transaction has been broadcast to the network." } + p style="color: #d63384; font-size: 0.9rem;" { + "Note: Your funds may be subject to a time delay before they can be spent. " + "This delay depends on the channel configuration and may be several blocks." + } + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + Err(e) => { + tracing::error!( + "Web interface: Failed to force close channel {} with {}: {}", + channel_id, + node_pubkey, + e + ); + html! { + (error_message(&format!("Failed to force close channel: {e}"))) + div class="card" { + a href="/balance" { button { "← Back to Lightning" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Force Close Channel Result", content, true).into_string(), + )) + .unwrap()) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/dashboard.rs b/crates/cdk-ldk-node/src/web/handlers/dashboard.rs new file mode 100644 index 000000000..a24cab16d --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/dashboard.rs @@ -0,0 +1,302 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Html; +use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; +use maud::html; + +use crate::web::handlers::AppState; +use crate::web::templates::{format_sats_as_btc, is_node_running, layout_with_status}; + +#[derive(Debug)] +pub struct UsageMetrics { + pub lightning_inflow_24h: u64, + pub lightning_outflow_24h: u64, + pub lightning_inflow_all_time: u64, + pub lightning_outflow_all_time: u64, + pub onchain_inflow_24h: u64, + pub onchain_outflow_24h: u64, + pub onchain_inflow_all_time: u64, + pub onchain_outflow_all_time: u64, +} + +/// Calculate usage metrics from payment history +fn calculate_usage_metrics(payments: &[ldk_node::payment::PaymentDetails]) -> UsageMetrics { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let twenty_four_hours_ago = now.saturating_sub(24 * 60 * 60); + + let mut metrics = UsageMetrics { + lightning_inflow_24h: 0, + lightning_outflow_24h: 0, + lightning_inflow_all_time: 0, + lightning_outflow_all_time: 0, + onchain_inflow_24h: 0, + onchain_outflow_24h: 0, + onchain_inflow_all_time: 0, + onchain_outflow_all_time: 0, + }; + + for payment in payments { + if payment.status != PaymentStatus::Succeeded { + continue; + } + + let amount_sats = payment.amount_msat.unwrap_or(0) / 1000; + let is_recent = payment.latest_update_timestamp >= twenty_four_hours_ago; + + match &payment.kind { + PaymentKind::Bolt11 { .. } + | PaymentKind::Bolt12Offer { .. } + | PaymentKind::Bolt12Refund { .. } + | PaymentKind::Spontaneous { .. } + | PaymentKind::Bolt11Jit { .. } => match payment.direction { + PaymentDirection::Inbound => { + metrics.lightning_inflow_all_time += amount_sats; + if is_recent { + metrics.lightning_inflow_24h += amount_sats; + } + } + PaymentDirection::Outbound => { + metrics.lightning_outflow_all_time += amount_sats; + if is_recent { + metrics.lightning_outflow_24h += amount_sats; + } + } + }, + PaymentKind::Onchain { .. } => match payment.direction { + PaymentDirection::Inbound => { + metrics.onchain_inflow_all_time += amount_sats; + if is_recent { + metrics.onchain_inflow_24h += amount_sats; + } + } + PaymentDirection::Outbound => { + metrics.onchain_outflow_all_time += amount_sats; + if is_recent { + metrics.onchain_outflow_24h += amount_sats; + } + } + }, + } + } + + metrics +} + +pub async fn dashboard(State(state): State) -> Result, StatusCode> { + let node = &state.node.inner; + + let _node_id = node.node_id().to_string(); + let alias = node + .node_alias() + .map(|a| a.to_string()) + .unwrap_or_else(|| "No alias set".to_string()); + + let listening_addresses: Vec = state + .node + .inner + .announcement_addresses() + .as_ref() + .unwrap_or(&vec![]) + .iter() + .map(|a| a.to_string()) + .collect(); + + let (num_peers, num_connected_peers) = + node.list_peers() + .iter() + .fold((0, 0), |(mut peers, mut connected), p| { + if p.is_connected { + connected += 1; + } + peers += 1; + (peers, connected) + }); + + let (num_active_channels, num_inactive_channels) = + node.list_channels() + .iter() + .fold((0, 0), |(mut active, mut inactive), c| { + if c.is_usable { + active += 1; + } else { + inactive += 1; + } + (active, inactive) + }); + + let balances = node.list_balances(); + + // Calculate payment metrics for dashboard + let all_payments = node.list_payments_with_filter(|_| true); + let metrics = calculate_usage_metrics(&all_payments); + + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Dashboard" } + + // Balance Summary as metric cards + div class="card" { + 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" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) } + div class="metric-label" { "On-chain Balance" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } + div class="metric-label" { "Spendable Balance" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats + balances.total_onchain_balance_sats)) } + div class="metric-label" { "Combined Total" } + } + } + } + + // Node Information - new layout based on Figma design + section class="node-info-section" { + div class="node-info-main-container" { + // Left side - Node avatar and info + div class="node-info-left" { + div class="node-avatar" { + img src="/static/images/nut.png" alt="Node Avatar" class="avatar-image"; + } + div class="node-details" { + h2 class="node-name" { (alias.clone()) } + p class="node-address" { + "Listening Address: " + (listening_addresses.first().unwrap_or(&"127.0.0.1:8090".to_string())) + } + } + } + + // Middle - Gray container with spinning globe animation + div class="node-content-box" { + div class="globe-container" { + svg aria-hidden="true" style="position: absolute; width: 0; height: 0; overflow: hidden;" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" { + defs { + symbol id="icon-world" viewBox="0 0 216 100" { + title { "world" } + g fill-rule="nonzero" { + path d="M48 94l-3-4-2-14c0-3-1-5-3-8-4-5-6-9-4-11l1-4 1-3c2-1 9 0 11 1l3 2 2 3 1 2 8 2c1 1 2 2 0 7-1 5-2 7-4 7l-2 3-2 4-2 3-2 1c-2 2-2 9 0 10v1l-3-2zM188 90l3-2h1l-4 2zM176 87h2l-1 1-1-1zM195 86l3-2-2 2h-1zM175 83l-1-2-2-1-6 1c-5 1-5 1-5-2l1-4 2-2 4-3c5-4 9-5 9-3 0 3 3 3 4 1s1-2 1 0l3 4c2 4 1 6-2 10-4 3-7 4-8 1zM100 80c-2-4-4-11-3-14l-1-6c-1-1-2-3-1-4 0-2-4-3-9-3-4 0-5 0-7-3-1-2-2-4-1-7l3-6 3-3c1-2 10-4 11-2l6 3 5-1c3 1 4 0 5-1s-1-2-2-2l-4-1c0-1 3-3 6-2 3 0 3 0 2-2-2-2-6-2-7 0l-2 2-1 2-3-2-3-3c-1 0-1 1 1 2l1 2-2-1c-4-3-6-2-8 1-2 2-4 3-5 1-1-1 0-4 2-4l2-2 1-2 3-2 3-2 2 1c3 0 7-3 5-4l-1-3h-1l-1 3-2 2h-1l-2-1c-2-1-2-1 1-4 5-4 6-4 11-3 4 1 4 1 2 2v1l3-1 6-1c5 0 6-1 5-2l2 1c1 2 2 2 2 1-2-4 12-7 14-4l11 1 29 3 1 2-3 3c-2 0-2 0-1 1l1 3h-2c-1-1-2-3-1-4h-4l-6 2c-1 1-1 1 2 2 3 2 4 6 1 8v3c1 3 0 3-3 0s-4-1-2 3c3 4 3 7-2 8-5 2-4 1-2 5 2 3 0 5-3 4l-2-1-2-2-1-1-1-1-2-2c-1-2-1-2-4 0-2 1-3 4-3 5-1 3-1 3-3 1l-2-4c0-2-1-3-2-3l-1-1-4-2-6-1-4-2c-1 1 3 4 5 4h2c1 1 0 2-1 4-3 2-7 4-8 3l-7-10 5 10c2 2 3 3 5 2 3 0 2 1-2 7-4 4-4 5-4 8 1 3 1 4-1 6l-2 3c0 2-6 9-8 9l-3-2zm22-51l-2-3-1-1v-1c-2 0-2 2-1 4 2 3 4 4 4 1z" {} + path d="M117 75c-1-2 0-6 2-7h2l-2 5c0 2-1 3-2 1zM186 64h-3c-2 0-6-3-5-5 1-1 6 1 7 3l2 3-2-1zM160 62h2c1 1 0 1-1 1l-1-1zM154 57l-1-2c2 2 3 1 2-2l-2-3 2 2 1 4 1 3v2l-3-4zM161 59c-1-1-1-2 1-4 3-3 4-3 4 0 0 4-2 6-5 4zM167 59l1-1 1 1-1 1-1-1zM176 59l1-1v2l-1-1zM141 52l1-1v2l-1-1zM170 52l1-1v2l-1-1zM32 50c-1-2-4-3-6-4-4-1-5-3-7-6l-3-5-2-2c-1-3-1-6 2-9 1-1 2-3 1-5 0-4-3-5-8-4H4l2-2 1-1 1-1 2-1c1-2 7-2 23-1 12 1 12 1 12-1h1c1 1 2 2 3 1l1 1-3 1c-2 0-8 4-8 5l2 1 2 3 4-3c3-4 4-4 5-3l3 1 1 2 1 2c3 0-1 2-4 2-2 0-2 0-2 2 1 1 0 2-2 2-4 1-12 9-12 12 0 2 0 2-1 1 0-2-2-3-6-2-3 0-4 1-4 3-2 4 0 6 3 4 3-1 3-1 2 1s-1 2 1 2l1 2 1 3 1 1-3-2zm8-24l1-1c0-1-4-3-5-2l1 1v2c-1 1-1 1 0 0h3zM167 47v-3l1 2c1 2 0 3-1 1z" {} + path d="M41 43h2l-1 1-1-1zM37 42v-1l2 1h-2zM16 38l1-1v2l-1-1zM172 32l2-3h1c1 2 0 4-3 4v-1zM173 26h2l-1 1-1-1zM56 22h2l-2 1v-1zM87 19l1-2 1 3-1 1-1-2zM85 19l1-1v1l-1 1v-1zM64 12l1-3c2 0-1-4-3-4s-2 0 0-1V3l-6 2c-3 1-3 1-2-1 2-1 4-2 15-2h14c0 2-6 7-10 9l-5 2-2 1-2-2zM53 12l1-1c2 0-1-3-3-3-2-1-1-1 1-1l4 2c2 1 2 1 1 3-2 1-4 2-4 0zM80 12l1-1 1 1-1 1-1-1zM36 8h-2V7c1-1 7 0 7 1h-5zM116 7l1-1v1l-1 1V7zM50 5h2l-1 1-1-1zM97 5l2-1c0-1 1-1 0 0l-2 1z" {} + } + } + symbol id="icon-repeated-world" viewBox="0 0 432 100" { + use href="#icon-world" x="0" {} + use href="#icon-world" x="189" {} + } + } + } + span class="world" { + span class="images" { + svg { use href="#icon-repeated-world" {} } + } + } + } + } + } + + // Right side - Connections metrics + aside class="node-metrics" { + div class="card" { + 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" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}/{}", num_active_channels, num_active_channels + num_inactive_channels)) } + div class="metric-label" { "Active Channels" } + } + } + } + } + } + + // Activity Sections - Side by Side Layout + div class="card" { + 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" } + + 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)) } + } + } + } + + // 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)) } + } + } + } + } + } + }; + + let is_running = is_node_running(&state.node.inner); + Ok(Html( + layout_with_status("Dashboard", content, is_running).into_string(), + )) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/invoices.rs b/crates/cdk-ldk-node/src/web/handlers/invoices.rs new file mode 100644 index 000000000..931eb5c63 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/invoices.rs @@ -0,0 +1,330 @@ +use axum::body::Body; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{Html, Response}; +use axum::Form; +use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description}; +use maud::html; +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, format_sats_as_btc, invoice_display_card, is_node_running, layout_with_status, + success_message, +}; + +#[derive(Deserialize)] +pub struct CreateBolt11Form { + amount_btc: u64, + description: Option, + #[serde(deserialize_with = "deserialize_optional_u32")] + expiry_seconds: Option, +} + +#[derive(Deserialize)] +pub struct CreateBolt12Form { + #[serde(deserialize_with = "deserialize_optional_f64")] + amount_btc: Option, + description: Option, + #[serde(deserialize_with = "deserialize_optional_u32")] + expiry_seconds: Option, +} + +pub async fn invoices_page(State(state): State) -> Result, StatusCode> { + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Invoices" } + + 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" } + } + } + } + + // 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="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("s", content, is_running).into_string(), + )) +} + +pub async fn post_create_bolt11( + State(state): State, + Form(form): Form, +) -> Result { + tracing::info!( + "Web interface: Creating BOLT11 invoice for amount={} sats, description={:?}, expiry={}s", + form.amount_btc, + form.description, + form.expiry_seconds.unwrap_or(3600) + ); + + // Handle optional description + let description_text = form.description.clone().unwrap_or_else(|| "".to_string()); + let description = if description_text.is_empty() { + // Use empty description for empty or missing description + match Description::new("".to_string()) { + Ok(desc) => Bolt11InvoiceDescription::Direct(desc), + Err(_) => { + // Fallback to a minimal valid description + let desc = Description::new(" ".to_string()).unwrap(); + Bolt11InvoiceDescription::Direct(desc) + } + } + } else { + match Description::new(description_text.clone()) { + Ok(desc) => Bolt11InvoiceDescription::Direct(desc), + Err(e) => { + tracing::warn!( + "Web interface: Invalid description for BOLT11 invoice: {}", + e + ); + let content = html! { + (error_message(&format!("Invalid description: {e}"))) + div class="card" { + a href="/invoices" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status(" Error", content, true).into_string(), + )) + .unwrap()); + } + } + }; + + // Convert Bitcoin to millisatoshis + let amount_msats = form.amount_btc * 1_000; + + let expiry_seconds = form.expiry_seconds.unwrap_or(3600); + let invoice_result = + state + .node + .inner + .bolt11_payment() + .receive(amount_msats, &description, expiry_seconds); + + let content = match invoice_result { + Ok(invoice) => { + tracing::info!( + "Web interface: Successfully created BOLT11 invoice with payment_hash={}", + invoice.payment_hash() + ); + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let description_display = if description_text.is_empty() { + "None".to_string() + } else { + 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!")) + (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 : {e}"))) + div class="card" { + a href="/invoices" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("BOLT11 Invoice Created", content, true).into_string(), + )) + .unwrap()) +} + +pub async fn post_create_bolt12( + State(state): State, + Form(form): Form, +) -> Result { + let expiry_seconds = form.expiry_seconds.unwrap_or(3600); + let description_text = form.description.unwrap_or_else(|| "".to_string()); + + tracing::info!( + "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 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, + Some(expiry_seconds), + None, + ) + } else { + state + .node + .inner + .bolt12_payment() + .receive_variable_amount(&description_text, Some(expiry_seconds)) + }; + + let content = match offer_result { + Ok(offer) => { + tracing::info!( + "Web interface: Successfully created BOLT12 offer with offer_id={}", + offer.id() + ); + let current_time = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let amount_display = form + .amount_btc + .map(|a| format_sats_as_btc(a as u64)) + .unwrap_or_else(|| "Variable amount".to_string()); + + let description_display = if description_text.is_empty() { + "None".to_string() + } else { + 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!")) + (invoice_display_card(&offer.to_string(), &amount_display, offer_details, "/invoices")) + } + } + Err(e) => { + tracing::error!("Web interface: Failed to create BOLT12 offer: {}", e); + html! { + (error_message(&format!("Failed to create offer: {e}"))) + div class="card" { + a href="/invoices" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("BOLT12 Offer Created", content, true).into_string(), + )) + .unwrap()) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/lightning.rs b/crates/cdk-ldk-node/src/web/handlers/lightning.rs new file mode 100644 index 000000000..2782cb9d5 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/lightning.rs @@ -0,0 +1,206 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::Html; +use maud::html; + +use crate::web::handlers::utils::AppState; +use crate::web::templates::{format_sats_as_btc, is_node_running, layout_with_status}; + +pub async fn balance_page(State(state): State) -> Result, StatusCode> { + let balances = state.node.inner.list_balances(); + let channels = state.node.inner.list_channels(); + + let (num_active_channels, num_inactive_channels) = + channels + .iter() + .fold((0, 0), |(mut active, mut inactive), c| { + if c.is_usable { + active += 1; + } else { + inactive += 1; + } + (active, inactive) + }); + + let content = if channels.is_empty() { + html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" } + + // 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." + } + } + } + + // 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" 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" } + } + } + } + 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" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_active_channels + num_inactive_channels)) } + div class="metric-label" { "Total Channels" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_active_channels)) } + div class="metric-label" { "Active 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" } + } + } + } + } + + div class="card" { + p { "No channels found. Create your first channel to start using Lightning Network." } + } + } + } else { + html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" } + + // 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." + } + } + } + + // 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" 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" } + } + } + } + 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" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_active_channels + num_inactive_channels)) } + div class="metric-label" { "Total Channels" } + } + div class="metric-card" { + div class="metric-value" { (format!("{}", num_active_channels)) } + div class="metric-label" { "Active 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" 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() { + @let node_id = channel.counterparty_node_id.to_string(); + @let channel_number = index + 1; + + div class="channel-box" { + // 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 - 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" { (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" { (short_channel_id.to_string()) } + } + } + } + + // Balance information cards (keeping existing style) + div class="balance-info" { + div class="balance-item" { + div class="balance-amount" { (format_sats_as_btc(channel.outbound_capacity_msat / 1000)) } + div class="balance-label" { "Outbound" } + } + div class="balance-item" { + div class="balance-amount" { (format_sats_as_btc(channel.inbound_capacity_msat / 1000)) } + div class="balance-label" { "Inbound" } + } + div class="balance-item" { + div class="balance-amount" { (format_sats_as_btc(channel.channel_value_sats)) } + div class="balance-label" { "Total" } + } + } + + // Action buttons + @if channel.is_usable { + div class="channel-actions" { + a href=(format!("/channels/close?channel_id={}&node_id={}", channel.user_channel_id.0, channel.counterparty_node_id)) { + button class="button-secondary" { "Close Channel" } + } + a href=(format!("/channels/force-close?channel_id={}&node_id={}", channel.user_channel_id.0, channel.counterparty_node_id)) { + button class="button-destructive" title="Force close should not be used if normal close is preferred. Force close will broadcast the latest commitment transaction immediately." { "Force Close" } + } + } + } + } + } + } + }; + + let is_running = is_node_running(&state.node.inner); + Ok(Html( + layout_with_status("Lightning", content, is_running).into_string(), + )) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/mod.rs b/crates/cdk-ldk-node/src/web/handlers/mod.rs new file mode 100644 index 000000000..c7794800e --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/mod.rs @@ -0,0 +1,17 @@ +pub mod channels; +pub mod dashboard; +pub mod invoices; +pub mod lightning; +pub mod onchain; +pub mod payments; +pub mod utils; + +// Re-export commonly used items +// Re-export handler functions +pub use channels::*; +pub use dashboard::*; +pub use invoices::*; +pub use lightning::*; +pub use onchain::*; +pub use payments::*; +pub use utils::AppState; diff --git a/crates/cdk-ldk-node/src/web/handlers/onchain.rs b/crates/cdk-ldk-node/src/web/handlers/onchain.rs new file mode 100644 index 000000000..2282001e8 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/onchain.rs @@ -0,0 +1,480 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, Response}; +use axum::Form; +use ldk_node::bitcoin::Address; +use maud::html; +use serde::{Deserialize, Serialize}; + +use crate::web::handlers::utils::deserialize_optional_u64; +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, + success_message, +}; + +#[derive(Deserialize, Serialize)] +pub struct SendOnchainActionForm { + address: String, + #[serde(deserialize_with = "deserialize_optional_u64")] + amount_sat: Option, + send_action: String, +} + +#[derive(Deserialize)] +pub struct ConfirmOnchainForm { + address: String, + amount_sat: Option, + send_action: String, + confirmed: Option, +} + +pub async fn get_new_address(State(state): State) -> Result, StatusCode> { + let address_result = state.node.inner.onchain_payment().new_address(); + + let content = match address_result { + Ok(address) => { + html! { + div class="card" { + 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()) } + } + } + } + div class="card" { + div style="display: flex; justify-content: space-between; gap: 1rem;" { + a href="/onchain" { button class="button-secondary" { "Back" } } + form method="post" action="/onchain/new-address" style="display: inline;" { + button class="button-primary" type="submit" { "Generate Another Address" } + } + } + } + } + } + Err(e) => { + html! { + (error_message(&format!("Failed to generate address: {e}"))) + div class="card" { + a href="/onchain" { button class="button-primary" { "← Back to On-chain" } } + } + } + } + }; + + let is_running = is_node_running(&state.node.inner); + Ok(Html( + layout_with_status("New Address", content, is_running).into_string(), + )) +} + +pub async fn onchain_page( + State(state): State, + query: Query>, +) -> Result, StatusCode> { + let balances = state.node.inner.list_balances(); + let action = query + .get("action") + .map(|s| s.as_str()) + .unwrap_or("overview"); + + let mut content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" } + + // On-chain Balance 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;" { "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" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } + div class="metric-label" { "Spendable Balance" } + } + } + } + }; + + match action { + "send" => { + content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" } + + // Send form above balance + (form_card( + "Send On-chain Payment", + html! { + form method="post" action="/onchain/send" { + div class="form-group" { + label for="address" { "Recipient Address" } + input type="text" id="address" name="address" required placeholder="bc1..." {} + } + div class="form-group" { + label for="amount_sat" { "Amount (sats)" } + input type="number" id="amount_sat" name="amount_sat" placeholder="0" {} + } + input type="hidden" id="send_action" name="send_action" value="send" {} + div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem;" { + a href="/onchain" { button type="button" class="button-secondary" { "Cancel" } } + div style="display: flex; gap: 0.5rem;" { + button type="submit" onclick="document.getElementById('send_action').value='send'" { "Send Payment" } + button type="submit" onclick="document.getElementById('send_action').value='send_all'; document.getElementById('amount_sat').value=''" { "Send All" } + } + } + } + } + )) + + // On-chain Balance 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;" { "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" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } + div class="metric-label" { "Spendable Balance" } + } + } + } + }; + } + "receive" => { + content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" } + + // Generate address form above balance + (form_card( + "Generate New Address", + html! { + form method="post" action="/onchain/new-address" { + p style="margin-bottom: 2rem;" { "Click the button below to generate a new Bitcoin address for receiving on-chain payments." } + div style="display: flex; justify-content: space-between; gap: 1rem;" { + a href="/onchain" { button type="button" class="button-secondary" { "Cancel" } } + button class="button-primary" type="submit" { "Generate New Address" } + } + } + } + )) + + // On-chain Balance 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;" { "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" } + } + div class="metric-card" { + div class="metric-value" { (format_sats_as_btc(balances.spendable_onchain_balance_sats)) } + div class="metric-label" { "Spendable Balance" } + } + } + } + }; + } + _ => { + // Show overview with just the balance and quick actions at the top + } + } + + let is_running = is_node_running(&state.node.inner); + Ok(Html( + layout_with_status("On-chain", content, is_running).into_string(), + )) +} + +pub async fn post_send_onchain( + State(_state): State, + Form(form): Form, +) -> Result { + let encoded_form = + serde_urlencoded::to_string(&form).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Response::builder() + .status(StatusCode::FOUND) + .header("Location", format!("/onchain/confirm?{}", encoded_form)) + .body(Body::empty()) + .unwrap()) +} + +pub async fn onchain_confirm_page( + State(state): State, + query: Query, +) -> Result { + let form = query.0; + + // If user confirmed, execute the transaction + if form.confirmed.as_deref() == Some("true") { + return execute_onchain_transaction(State(state), form).await; + } + + // Validate address + let _address = match Address::from_str(&form.address) { + Ok(addr) => addr, + Err(e) => { + let content = html! { + (error_message(&format!("Invalid address: {e}"))) + div class="card" { + a href="/onchain?action=send" { button { "← Back" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Send On-chain Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + let balances = state.node.inner.list_balances(); + let spendable_balance = balances.spendable_onchain_balance_sats; + + // Calculate transaction details + let (amount_to_send, is_send_all) = if form.send_action == "send_all" { + (spendable_balance, true) + } else { + let amount = form.amount_sat.unwrap_or(0); + if amount > spendable_balance { + let content = html! { + (error_message(&format!("Insufficient funds. Requested: {}, Available: {}", + format_sats_as_btc(amount), format_sats_as_btc(spendable_balance)))) + div class="card" { + a href="/onchain?action=send" { button { "← Back" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Send On-chain Error", content, true).into_string(), + )) + .unwrap()); + } + (amount, false) + }; + + let confirmation_url = if form.send_action == "send_all" { + format!( + "/onchain/confirm?address={}&send_action={}&confirmed=true", + urlencoding::encode(&form.address), + form.send_action + ) + } else { + format!( + "/onchain/confirm?address={}&amount_sat={}&send_action={}&confirmed=true", + urlencoding::encode(&form.address), + form.amount_sat.unwrap_or(0), + form.send_action + ) + }; + + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Confirm On-chain Transaction" } + + @if is_send_all { + div class="card send-all-notice" { + h3 { "Send All Notice" } + p { + "This transaction will send all available funds to the recipient address. Network fees will be deducted from the total amount automatically." + } + } + } + + // Transaction Details Card + div class="card" { + 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()) } + } + div class="detail-row" { + span class="detail-label" { "Amount to Send:" } + span class="detail-value-amount" { + (if is_send_all { + format!("{} (All available funds)", format_sats_as_btc(amount_to_send)) + } else { + format_sats_as_btc(amount_to_send) + }) + } + } + div class="detail-row" { + span class="detail-label" { "Current Spendable Balance:" } + span class="detail-value-amount" { (format_sats_as_btc(spendable_balance)) } + } + } + + div style="display: flex; justify-content: space-between; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid hsl(var(--border));" { + a href="/onchain?action=send" { + button type="button" class="button-secondary" { "Cancel" } + } + a href=(confirmation_url) { + button class="button-primary" { + "Confirm" + } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Confirm Transaction", content, true).into_string(), + )) + .unwrap()) +} + +async fn execute_onchain_transaction( + State(state): State, + form: ConfirmOnchainForm, +) -> Result { + tracing::info!( + "Web interface: Executing on-chain transaction to address={}, send_action={}, amount_sat={:?}", + form.address, + form.send_action, + form.amount_sat + ); + + let address = match Address::from_str(&form.address) { + Ok(addr) => addr, + Err(e) => { + tracing::warn!( + "Web interface: Invalid address for on-chain transaction: {}", + e + ); + let content = html! { + (error_message(&format!("Invalid address: {e}"))) + div class="card" { + a href="/onchain" { button { "← Back" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Send On-chain Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + // Handle send all action + let txid_result = if form.send_action == "send_all" { + tracing::info!( + "Web interface: Sending all available funds to {}", + form.address + ); + state.node.inner.onchain_payment().send_all_to_address( + address.assume_checked_ref(), + false, + None, + ) + } else { + let amount_sats = form.amount_sat.ok_or(StatusCode::BAD_REQUEST)?; + tracing::info!( + "Web interface: Sending {} sats to {}", + amount_sats, + form.address + ); + state.node.inner.onchain_payment().send_to_address( + address.assume_checked_ref(), + amount_sats, + None, + ) + }; + + let content = match txid_result { + Ok(txid) => { + if form.send_action == "send_all" { + tracing::info!( + "Web interface: Successfully sent all available funds, txid={}", + txid + ); + } else { + tracing::info!( + "Web interface: Successfully sent {} sats, txid={}", + form.amount_sat.unwrap_or(0), + txid + ); + } + let amount = form.amount_sat; + html! { + (success_message("Transaction sent successfully!")) + (info_card( + "Transaction Details", + vec![ + ("Transaction ID", txid.to_string()), + ("Amount", if form.send_action == "send_all" { + format!("{} (All available funds)", format_sats_as_btc(amount.unwrap_or(0))) + } else { + format_sats_as_btc(form.amount_sat.unwrap_or(0)) + }), + ("Recipient", form.address), + ] + )) + div class="card" { + a href="/onchain" { button { "← Back to On-chain" } } + } + } + } + Err(e) => { + tracing::error!("Web interface: Failed to send on-chain transaction: {}", e); + html! { + (error_message(&format!("Failed to send payment: {e}"))) + div class="card" { + a href="/onchain" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Send On-chain Result", content, true).into_string(), + )) + .unwrap()) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/payments.rs b/crates/cdk-ldk-node/src/web/handlers/payments.rs new file mode 100644 index 000000000..46a838837 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/payments.rs @@ -0,0 +1,697 @@ +use std::str::FromStr; + +use axum::body::Body; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::{Html, Response}; +use axum::Form; +use cdk_common::util::hex; +use ldk_node::lightning::offers::offer::Offer; +use ldk_node::lightning_invoice::Bolt11Invoice; +use ldk_node::payment::{PaymentDirection, PaymentKind, PaymentStatus}; +use maud::html; +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, format_msats_as_btc, format_sats_as_btc, info_card, is_node_running, + layout_with_status, payment_list_item, success_message, +}; + +#[derive(Deserialize)] +pub struct PaymentsQuery { + filter: Option, + page: Option, + per_page: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PayBolt11Form { + invoice: String, + #[serde(deserialize_with = "deserialize_optional_u64")] + amount_btc: Option, +} + +#[derive(Deserialize)] +pub struct PayBolt12Form { + offer: String, + #[serde(deserialize_with = "deserialize_optional_u64")] + amount_btc: Option, +} + +pub async fn payments_page( + State(state): State, + query: Query, +) -> Result, StatusCode> { + let filter = query.filter.as_deref().unwrap_or("all"); + let page = query.page.unwrap_or(1).max(1); + let per_page = query.per_page.unwrap_or(25).clamp(10, 100); // Limit between 10-100 items per page + + // Use efficient pagination function + let (current_page_payments, total_count) = get_paginated_payments_streaming( + &state.node.inner, + filter, + ((page - 1) * per_page) as usize, + per_page as usize, + ); + + // Calculate pagination + let total_pages = ((total_count as f64) / (per_page as f64)).ceil() as u32; + let start_index = ((page - 1) * per_page) as usize; + let end_index = (start_index + per_page as usize).min(total_count); + + // Helper function to build URL with pagination params + let build_url = |new_page: u32, new_filter: &str, new_per_page: u32| -> String { + let mut params = vec![]; + if new_filter != "all" { + params.push(format!("filter={}", new_filter)); + } + if new_page != 1 { + params.push(format!("page={}", new_page)); + } + if new_per_page != 25 { + params.push(format!("per_page={}", new_per_page)); + } + + if params.is_empty() { + "/payments".to_string() + } else { + format!("/payments?{}", params.join("&")) + } + }; + + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Payments" } + div class="card" { + div class="payment-list-header" { + div { + 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" + } + } + } + div class="payment-filter-tabs" { + a href=(build_url(1, "all", per_page)) class=(if filter == "all" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "All" } + a href=(build_url(1, "incoming", per_page)) class=(if filter == "incoming" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "Incoming" } + a href=(build_url(1, "outgoing", per_page)) class=(if filter == "outgoing" { "payment-filter-tab active" } else { "payment-filter-tab" }) { "Outgoing" } + } + } + + // Payment list (no metrics here) + @if current_page_payments.is_empty() { + @if total_count == 0 { + p { "No payments found." } + } @else { + p { "No payments found on this page. " + a href=(build_url(1, filter, per_page)) { "Go to first page" } + } + } + } @else { + @for payment in ¤t_page_payments { + @let direction_str = match payment.direction { + PaymentDirection::Inbound => "Inbound", + PaymentDirection::Outbound => "Outbound", + }; + + @let (payment_hash, description, payment_type, preimage) = match &payment.kind { + PaymentKind::Bolt11 { hash, preimage, .. } => { + (Some(hash.to_string()), None::, "BOLT11", preimage.map(|p| p.to_string())) + }, + PaymentKind::Bolt12Offer { hash, offer_id, preimage, .. } => { + // For BOLT12, we can use either the payment hash or offer ID + let identifier = hash.map(|h| h.to_string()).unwrap_or_else(|| offer_id.to_string()); + (Some(identifier), None::, "BOLT12", preimage.map(|p| p.to_string())) + }, + PaymentKind::Bolt12Refund { hash, preimage, .. } => { + (hash.map(|h| h.to_string()), None::, "BOLT12", preimage.map(|p| p.to_string())) + }, + PaymentKind::Spontaneous { hash, preimage, .. } => { + (Some(hash.to_string()), None::, "Spontaneous", preimage.map(|p| p.to_string())) + }, + PaymentKind::Onchain { txid, .. } => { + (Some(txid.to_string()), None::, "On-chain", None) + }, + PaymentKind::Bolt11Jit { hash, .. } => { + (Some(hash.to_string()), None::, "BOLT11 JIT", None) + }, + }; + + @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 = { + match (payment.amount_msat, payment.fee_paid_msat) { + (Some(amount), Some(fee)) => format_msats_as_btc(amount + fee), + (Some(amount), None) => format_msats_as_btc(amount), + _ => "Unknown".to_string() + } + }; + + (payment_list_item( + &payment.id.to_string(), + direction_str, + status_str, + &amount_str, + payment_hash.as_deref(), + description.as_deref(), + Some(payment.latest_update_timestamp), // Use the actual timestamp + payment_type, + preimage.as_deref(), + )) + } + } + + // Pagination controls (bottom) + @if total_pages > 1 { + div class="pagination-controls" style="margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #eee;" { + div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;" { + // Previous page + @if page > 1 { + a href=(build_url(page - 1, filter, per_page)) class="pagination-btn" { "← Previous" } + } @else { + span class="pagination-btn disabled" { "← Previous" } + } + + // Page numbers + @let start_page = (page.saturating_sub(2)).max(1); + @let end_page = (page + 2).min(total_pages); + + @if start_page > 1 { + a href=(build_url(1, filter, per_page)) class="pagination-number" { "1" } + @if start_page > 2 { + span class="pagination-ellipsis" { "..." } + } + } + + @for p in start_page..=end_page { + @if p == page { + span class="pagination-number active" { (p) } + } @else { + a href=(build_url(p, filter, per_page)) class="pagination-number" { (p) } + } + } + + @if end_page < total_pages { + @if end_page < total_pages - 1 { + span class="pagination-ellipsis" { "..." } + } + a href=(build_url(total_pages, filter, per_page)) class="pagination-number" { (total_pages) } + } + + // Next page + @if page < total_pages { + a href=(build_url(page + 1, filter, per_page)) class="pagination-btn" { "Next →" } + } @else { + span class="pagination-btn disabled" { "Next →" } + } + } + } + } + + // Compact per-page selector integrated with pagination + @if total_count > 0 { + div class="per-page-selector" { + label for="per-page" { "Show:" } + select id="per-page" onchange="changePage()" { + option value="10" selected[per_page == 10] { "10" } + option value="25" selected[per_page == 25] { "25" } + option value="50" selected[per_page == 50] { "50" } + option value="100" selected[per_page == 100] { "100" } + } + span { "per page" } + } + } + } + + // JavaScript for per-page selector + script { + "function changePage() { + const perPageSelect = document.getElementById('per-page'); + const newPerPage = perPageSelect.value; + const currentUrl = new URL(window.location); + currentUrl.searchParams.set('per_page', newPerPage); + currentUrl.searchParams.set('page', '1'); // Reset to first page when changing per_page + window.location.href = currentUrl.toString(); + }" + } + }; + + let is_running = is_node_running(&state.node.inner); + Ok(Html( + layout_with_status("Payment History", content, is_running).into_string(), + )) +} + +pub async fn send_payments_page(State(state): State) -> Result, StatusCode> { + let content = html! { + h2 style="text-align: center; margin-bottom: 3rem;" { "Send Payment" } + + 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" + } + } + div class="form-actions" { + a href="/balance" { button type="button" class="button-secondary" { "Cancel" } } + button type="submit" class="button-primary" { "Pay Invoice" } + } + } + } + + // 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" + } + } + 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); + Ok(Html( + layout_with_status("Send Payments", content, is_running).into_string(), + )) +} + +pub async fn post_pay_bolt11( + State(state): State, + Form(form): Form, +) -> Result { + let invoice = match Bolt11Invoice::from_str(form.invoice.trim()) { + Ok(inv) => inv, + Err(e) => { + tracing::warn!("Web interface: Invalid BOLT11 invoice provided: {}", e); + let content = html! { + (error_message(&format!("Invalid BOLT11 invoice: {e}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Payment Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + tracing::info!( + "Web interface: Attempting to pay BOLT11 invoice payment_hash={}, amount_override={:?}", + invoice.payment_hash(), + form.amount_btc + ); + + let payment_id = if let Some(amount_btc) = form.amount_btc { + // Convert Bitcoin to millisatoshis + let amount_msats = amount_btc * 1000; + state + .node + .inner + .bolt11_payment() + .send_using_amount(&invoice, amount_msats, None) + } else { + state.node.inner.bolt11_payment().send(&invoice, None) + }; + + let payment_id = match payment_id { + Ok(id) => { + tracing::info!( + "Web interface: BOLT11 payment initiated with payment_id={}", + hex::encode(id.0) + ); + id + } + Err(e) => { + tracing::error!("Web interface: Failed to initiate BOLT11 payment: {}", e); + let content = html! { + (error_message(&format!("Failed to initiate payment: {e}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Payment Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + // Wait for payment to complete (max 10 seconds) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + let payment_result = loop { + if let Some(details) = state.node.inner.payment(&payment_id) { + match details.status { + PaymentStatus::Succeeded => { + tracing::info!( + "Web interface: BOLT11 payment succeeded for payment_hash={}", + invoice.payment_hash() + ); + break Ok(details); + } + PaymentStatus::Failed => { + tracing::error!( + "Web interface: BOLT11 payment failed for payment_hash={}", + invoice.payment_hash() + ); + break Err("Payment failed".to_string()); + } + PaymentStatus::Pending => { + if start.elapsed() > timeout { + tracing::warn!( + "Web interface: BOLT11 payment timeout for payment_hash={}", + invoice.payment_hash() + ); + break Err("Payment is still pending after timeout".to_string()); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + } + } else { + break Err("Payment not found".to_string()); + } + }; + + let content = match payment_result { + Ok(details) => { + let (preimage, fee_msats) = match details.kind { + PaymentKind::Bolt11 { + hash: _, + preimage, + secret: _, + } => ( + preimage.map(|p| p.to_string()).unwrap_or_default(), + details.fee_paid_msat.unwrap_or(0), + ), + _ => (String::new(), 0), + }; + + html! { + (success_message("Payment succeeded!")) + (info_card( + "Payment Details", + vec![ + ("Payment Hash", invoice.payment_hash().to_string()), + ("Payment Preimage", preimage), + ("Fee Paid", format_msats_as_btc(fee_msats)), + ("Amount", form.amount_btc.map(|_a| format_sats_as_btc(details.amount_msat.unwrap_or(1000) / 1000)).unwrap_or_default()), + ] + )) + div class="card" { + a href="/payments" { button { "← Make Another Payment" } } + } + } + } + Err(error) => { + html! { + (error_message(&format!("Payment failed: {error}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Payment Result", content, true).into_string(), + )) + .unwrap()) +} + +pub async fn post_pay_bolt12( + State(state): State, + Form(form): Form, +) -> Result { + let offer = match Offer::from_str(form.offer.trim()) { + Ok(offer) => offer, + Err(e) => { + tracing::warn!("Web interface: Invalid BOLT12 offer provided: {:?}", e); + let content = html! { + (error_message(&format!("Invalid BOLT12 offer: {e:?}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Payment Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + tracing::info!( + "Web interface: Attempting to pay BOLT12 offer offer_id={}, amount_override={:?}", + offer.id(), + form.amount_btc + ); + + // Determine payment method based on offer type and user input + let payment_id = match offer.amount() { + Some(_) => { + // Fixed amount offer - use send() method, ignore user input amount + state.node.inner.bolt12_payment().send(&offer, None, None) + } + None => { + // Variable amount offer - requires user to specify amount via send_using_amount() + let amount_btc = match form.amount_btc { + Some(amount) => amount, + None => { + tracing::warn!("Web interface: Amount required for variable amount BOLT12 offer but not provided"); + let content = html! { + (error_message("Amount is required for variable amount offers. This offer does not have a fixed amount, so you must specify how much you want to pay.")) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::BAD_REQUEST) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Payment Error", content, true).into_string(), + )) + .unwrap()); + } + }; + let amount_msats = amount_btc * 1_000; + state + .node + .inner + .bolt12_payment() + .send_using_amount(&offer, amount_msats, None, None) + } + }; + + let payment_id = match payment_id { + Ok(id) => { + tracing::info!( + "Web interface: BOLT12 payment initiated with payment_id={}", + hex::encode(id.0) + ); + id + } + Err(e) => { + tracing::error!("Web interface: Failed to initiate BOLT12 payment: {}", e); + let content = html! { + (error_message(&format!("Failed to initiate payment: {e}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + }; + return Ok(Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Payment Error", content, true).into_string(), + )) + .unwrap()); + } + }; + + // Wait for payment to complete (max 10 seconds) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(10); + + let payment_result = loop { + if let Some(details) = state.node.inner.payment(&payment_id) { + match details.status { + PaymentStatus::Succeeded => { + tracing::info!( + "Web interface: BOLT12 payment succeeded for offer_id={}", + offer.id() + ); + break Ok(details); + } + PaymentStatus::Failed => { + tracing::error!( + "Web interface: BOLT12 payment failed for offer_id={}", + offer.id() + ); + break Err("Payment failed".to_string()); + } + PaymentStatus::Pending => { + if start.elapsed() > timeout { + tracing::warn!( + "Web interface: BOLT12 payment timeout for offer_id={}", + offer.id() + ); + break Err("Payment is still pending after timeout".to_string()); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + continue; + } + } + } else { + break Err("Payment not found".to_string()); + } + }; + + let content = match payment_result { + Ok(details) => { + let (payment_hash, preimage, fee_msats) = match details.kind { + PaymentKind::Bolt12Offer { + hash, + preimage, + secret: _, + offer_id: _, + payer_note: _, + quantity: _, + } => ( + hash.map(|h| h.to_string()).unwrap_or_default(), + preimage.map(|p| p.to_string()).unwrap_or_default(), + details.fee_paid_msat.unwrap_or(0), + ), + _ => (String::new(), String::new(), 0), + }; + + html! { + (success_message("Payment succeeded!")) + (info_card( + "Payment Details", + vec![ + ("Payment Hash", payment_hash), + ("Payment Preimage", preimage), + ("Fee Paid", format_msats_as_btc(fee_msats)), + ("Amount Paid", form.amount_btc.map(format_sats_as_btc).unwrap_or_else(|| { + // If no amount was specified in the form, show the actual amount from the payment details + details.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string()) + })), + ] + )) + div class="card" { + a href="/payments" { button { "← Make Another Payment" } } + } + } + } + Err(error) => { + html! { + (error_message(&format!("Payment failed: {error}"))) + div class="card" { + a href="/payments" { button { "← Try Again" } } + } + } + } + }; + + Ok(Response::builder() + .header("content-type", "text/html") + .body(Body::from( + layout_with_status("Payment Result", content, true).into_string(), + )) + .unwrap()) +} diff --git a/crates/cdk-ldk-node/src/web/handlers/utils.rs b/crates/cdk-ldk-node/src/web/handlers/utils.rs new file mode 100644 index 000000000..c4f2dbed2 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/handlers/utils.rs @@ -0,0 +1,91 @@ +use std::sync::Arc; + +use ldk_node::payment::PaymentDirection; +use serde::Deserialize; + +use crate::CdkLdkNode; + +#[derive(Clone)] +pub struct AppState { + pub node: Arc, +} + +// Custom deserializer for optional u32 that handles empty strings +pub fn deserialize_optional_u32<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt.as_deref() { + None | Some("") => Ok(None), + Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), + } +} + +// Custom deserializer for optional u64 that handles empty strings +pub fn deserialize_optional_u64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt.as_deref() { + None | Some("") => Ok(None), + Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), + } +} + +// Custom deserializer for optional f64 that handles empty strings +pub fn deserialize_optional_f64<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(deserializer)?; + match opt.as_deref() { + None | Some("") => Ok(None), + Some(s) => s.parse::().map(Some).map_err(serde::de::Error::custom), + } +} + +/// Get paginated payments with efficient filtering and sorting +pub fn get_paginated_payments_streaming( + node: &ldk_node::Node, + filter: &str, + skip: usize, + take: usize, +) -> (Vec, usize) { + // Create filter predicate - note LDK expects &&PaymentDetails + let filter_fn = match filter { + "incoming" => { + |p: &&ldk_node::payment::PaymentDetails| p.direction == PaymentDirection::Inbound + } + "outgoing" => { + |p: &&ldk_node::payment::PaymentDetails| p.direction == PaymentDirection::Outbound + } + _ => |_: &&ldk_node::payment::PaymentDetails| true, + }; + + // Get filtered payments from LDK + let filtered_payments = node.list_payments_with_filter(filter_fn); + + // Create sorted index to avoid cloning payments during sort + let mut time_indexed: Vec<_> = filtered_payments + .iter() + .enumerate() + .map(|(idx, payment)| (payment.latest_update_timestamp, idx)) + .collect(); + + // Sort by timestamp (newest first) + time_indexed.sort_unstable_by(|a, b| b.0.cmp(&a.0)); + + let total_count = time_indexed.len(); + + // Extract only the payments we need for this page + let page_payments: Vec<_> = time_indexed + .into_iter() + .skip(skip) + .take(take) + .map(|(_, idx)| filtered_payments[idx].clone()) + .collect(); + + (page_payments, total_count) +} diff --git a/crates/cdk-ldk-node/src/web/mod.rs b/crates/cdk-ldk-node/src/web/mod.rs new file mode 100644 index 000000000..f182a9a9f --- /dev/null +++ b/crates/cdk-ldk-node/src/web/mod.rs @@ -0,0 +1,6 @@ +pub mod handlers; +pub mod server; +pub mod static_files; +pub mod templates; + +pub use server::WebServer; diff --git a/crates/cdk-ldk-node/src/web/server.rs b/crates/cdk-ldk-node/src/web/server.rs new file mode 100644 index 000000000..300db0d4e --- /dev/null +++ b/crates/cdk-ldk-node/src/web/server.rs @@ -0,0 +1,78 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::routing::{get, post}; +use axum::Router; +use tower::ServiceBuilder; +use tower_http::cors::CorsLayer; + +use crate::web::handlers::{ + balance_page, channels_page, close_channel_page, dashboard, force_close_channel_page, + get_new_address, invoices_page, onchain_confirm_page, onchain_page, open_channel_page, + payments_page, post_close_channel, post_create_bolt11, post_create_bolt12, + post_force_close_channel, post_open_channel, post_pay_bolt11, post_pay_bolt12, + post_send_onchain, send_payments_page, AppState, +}; +use crate::web::static_files::static_handler; +use crate::CdkLdkNode; + +pub struct WebServer { + pub node: Arc, +} + +impl WebServer { + pub fn new(node: Arc) -> Self { + Self { node } + } + + pub fn create_router(&self) -> Router { + let state = AppState { + node: self.node.clone(), + }; + + tracing::debug!("Serving static files from embedded assets"); + + Router::new() + // Dashboard + .route("/", get(dashboard)) + // Balance and onchain operations + .route("/balance", get(balance_page)) + .route("/onchain", get(onchain_page)) + .route("/onchain/send", post(post_send_onchain)) + .route("/onchain/confirm", get(onchain_confirm_page)) + .route("/onchain/new-address", post(get_new_address)) + // Channel management + .route("/channels", get(channels_page)) + .route("/channels/open", get(open_channel_page)) + .route("/channels/open", post(post_open_channel)) + .route("/channels/close", get(close_channel_page)) + .route("/channels/close", post(post_close_channel)) + .route("/channels/force-close", get(force_close_channel_page)) + .route("/channels/force-close", post(post_force_close_channel)) + // Invoice creation + .route("/invoices", get(invoices_page)) + .route("/invoices/bolt11", post(post_create_bolt11)) + .route("/invoices/bolt12", post(post_create_bolt12)) + // Payment sending and history + .route("/payments", get(payments_page)) + .route("/payments/send", get(send_payments_page)) + .route("/payments/bolt11", post(post_pay_bolt11)) + .route("/payments/bolt12", post(post_pay_bolt12)) + // Static files - now embedded + .route("/static/{*file}", get(static_handler)) + .layer(ServiceBuilder::new().layer(CorsLayer::permissive())) + .with_state(state) + } + + pub async fn serve(&self, addr: SocketAddr) -> Result<(), Box> { + let app = self.create_router(); + + tracing::info!("Starting web server on {}", addr); + let listener = tokio::net::TcpListener::bind(addr).await?; + + tracing::info!("Web interface available at: http://{}", addr); + axum::serve(listener, app).await?; + + Ok(()) + } +} diff --git a/crates/cdk-ldk-node/src/web/static_files.rs b/crates/cdk-ldk-node/src/web/static_files.rs new file mode 100644 index 000000000..666c4089c --- /dev/null +++ b/crates/cdk-ldk-node/src/web/static_files.rs @@ -0,0 +1,47 @@ +use axum::extract::Path; +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "static/"] +pub struct Assets; + +fn get_content_type(path: &str) -> &'static str { + if let Some(extension) = path.rsplit('.').next() { + match extension.to_lowercase().as_str() { + "css" => "text/css", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + _ => "application/octet-stream", + } + } else { + "application/octet-stream" + } +} + +pub async fn static_handler(Path(path): Path) -> impl IntoResponse { + let cleaned_path = path.trim_start_matches('/'); + + match Assets::get(cleaned_path) { + Some(content) => { + let content_type = get_content_type(cleaned_path); + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, content_type.parse().unwrap()); + + // Add cache headers for static assets + headers.insert( + header::CACHE_CONTROL, + "public, max-age=31536000".parse().unwrap(), + ); + + (headers, content.data).into_response() + } + None => { + tracing::warn!("Static file not found: {}", cleaned_path); + (StatusCode::NOT_FOUND, "404 Not Found").into_response() + } + } +} diff --git a/crates/cdk-ldk-node/src/web/templates/components.rs b/crates/cdk-ldk-node/src/web/templates/components.rs new file mode 100644 index 000000000..6df325e65 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/components.rs @@ -0,0 +1,87 @@ +use maud::{html, Markup}; + +pub fn info_card(title: &str, items: Vec<(&str, String)>) -> Markup { + html! { + div class="card" { + 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) } + } + } + } + } + } +} + +pub fn form_card(title: &str, form_content: Markup) -> Markup { + html! { + div class="card" { + 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) + } + } + } +} + +pub fn success_message(message: &str) -> Markup { + html! { + div class="success" { (message) } + } +} + +pub fn error_message(message: &str) -> Markup { + html! { + 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" } + } + } + } + } +} diff --git a/crates/cdk-ldk-node/src/web/templates/formatters.rs b/crates/cdk-ldk-node/src/web/templates/formatters.rs new file mode 100644 index 000000000..97630a132 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/formatters.rs @@ -0,0 +1,150 @@ +/// Format satoshis as a whole number with Bitcoin symbol (BIP177) +pub fn format_sats_as_btc(sats: u64) -> String { + let sats_str = sats.to_string(); + let formatted_sats = if sats_str.len() > 3 { + let mut result = String::new(); + let chars: Vec = sats_str.chars().collect(); + let len = chars.len(); + + for (i, ch) in chars.iter().enumerate() { + // Add comma before every group of 3 digits from right to left + if i > 0 && (len - i) % 3 == 0 { + result.push(','); + } + result.push(*ch); + } + result + } else { + sats_str + }; + + format!("₿{formatted_sats}") +} + +/// Format millisatoshis as satoshis (whole number) with Bitcoin symbol (BIP177) +pub fn format_msats_as_btc(msats: u64) -> String { + let sats = msats / 1000; + let sats_str = sats.to_string(); + let formatted_sats = if sats_str.len() > 3 { + let mut result = String::new(); + let chars: Vec = sats_str.chars().collect(); + let len = chars.len(); + + for (i, ch) in chars.iter().enumerate() { + // Add comma before every group of 3 digits from right to left + if i > 0 && (len - i) % 3 == 0 { + result.push(','); + } + result.push(*ch); + } + result + } else { + sats_str + }; + + format!("₿{formatted_sats}") +} + +/// Format a Unix timestamp as a human-readable date and time +pub fn format_timestamp(timestamp: u64) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let diff = now.saturating_sub(timestamp); + + match diff { + 0..=60 => "Just now".to_string(), + 61..=3600 => format!("{} min ago", diff / 60), + _ => { + // For timestamps older than 1 hour, show UTC time + // Convert to a simple UTC format + let total_seconds = timestamp; + let seconds = total_seconds % 60; + let total_minutes = total_seconds / 60; + let minutes = total_minutes % 60; + let total_hours = total_minutes / 60; + let hours = total_hours % 24; + let days = total_hours / 24; + + // Calculate year, month, day from days since epoch (1970-01-01) + let mut year = 1970; + let mut remaining_days = days; + + // Simple year calculation + loop { + let is_leap_year = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + let days_in_year = if is_leap_year { 366 } else { 365 }; + + if remaining_days >= days_in_year { + remaining_days -= days_in_year; + year += 1; + } else { + break; + } + } + + // Calculate month and day + let is_leap_year = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + let days_in_months = if is_leap_year { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + + let mut month = 1; + let mut day = remaining_days + 1; + + for &days_in_month in &days_in_months { + if day > days_in_month { + day -= days_in_month; + month += 1; + } else { + break; + } + } + + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", + year, month, day, hours, minutes, seconds + ) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_timestamp() { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Test "Just now" (30 seconds ago) + let recent = now - 30; + assert_eq!(format_timestamp(recent), "Just now"); + + // Test minutes ago (30 minutes ago) + let minutes_ago = now - (30 * 60); + assert_eq!(format_timestamp(minutes_ago), "30 min ago"); + + // Test UTC format for older timestamps (2 hours ago) + let hours_ago = now - (2 * 60 * 60); + let result = format_timestamp(hours_ago); + assert!(result.ends_with(" UTC")); + assert!(result.contains("-")); + assert!(result.contains(":")); + + // Test known timestamp: January 1, 2020 00:00:00 UTC + let timestamp_2020 = 1577836800; // 2020-01-01 00:00:00 UTC + let result = format_timestamp(timestamp_2020); + assert_eq!(result, "2020-01-01 00:00:00 UTC"); + } +} diff --git a/crates/cdk-ldk-node/src/web/templates/layout.rs b/crates/cdk-ldk-node/src/web/templates/layout.rs new file mode 100644 index 000000000..9e191a4bf --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/layout.rs @@ -0,0 +1,2733 @@ +use ldk_node::Node; +use maud::{html, Markup, DOCTYPE}; + +/// Helper function to check if the node is running +pub fn is_node_running(node: &Node) -> bool { + node.status().is_running +} + +pub fn layout_with_status(title: &str, content: Markup, is_running: bool) -> Markup { + html! { + (DOCTYPE) + html lang="en" { + head { + meta charset="utf-8"; + meta name="viewport" content="width=device-width, initial-scale=1"; + link rel="icon" type="image/svg+xml" href="/static/favicon.svg"; + link rel="stylesheet" type="text/css" href="/static/css/globe.css"; + title { (title) " - CDK LDK Node" } + style { + " + :root { + /* Light mode (default) */ + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96%; + --secondary-foreground: 222.2 84% 4.9%; + --muted: 210 40% 96%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96%; + --accent-foreground: 222.2 84% 4.9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0; + + /* Typography scale */ + --fs-title: 1.25rem; + --fs-label: 0.8125rem; + --fs-value: 1.625rem; + + /* Line heights */ + --lh-tight: 1.15; + --lh-normal: 1.4; + + /* Font weights */ + --fw-medium: 500; + --fw-semibold: 600; + --fw-bold: 700; + + /* Colors */ + --fg-primary: #0f172a; + --fg-muted: #6b7280; + + /* Header text colors for light mode */ + --header-title: #000000; + --header-subtitle: #333333; + } + + /* Dark mode using system preference */ + @media (prefers-color-scheme: dark) { + body { + background: linear-gradient(rgb(23, 25, 29), rgb(18, 19, 21)); + } + + :root { + --background: 0 0% 0%; + --foreground: 0 0% 100%; + --card: 0 0% 0%; + --card-foreground: 0 0% 100%; + --popover: 0 0% 0%; + --popover-foreground: 0 0% 100%; + --primary: 0 0% 100%; + --primary-foreground: 0 0% 0%; + --secondary: 0 0% 20%; + --secondary-foreground: 0 0% 100%; + --muted: 0 0% 20%; + --muted-foreground: 0 0% 70%; + --accent: 0 0% 20%; + --accent-foreground: 0 0% 100%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 100%; + --border: 0 0% 20%; + --input: 0 0% 20%; + --ring: 0 0% 83.9%; + + /* Dark mode text hierarchy colors */ + --text-primary: #ffffff; + --text-secondary: #e6e6e6; + --text-tertiary: #cccccc; + --text-quaternary: #b3b3b3; + --text-muted: #999999; + --text-muted-2: #888888; + --text-muted-3: #666666; + --text-muted-4: #333333; + --text-subtle: #1a1a1a; + + /* Header text colors for dark mode */ + --header-title: #ffffff; + --header-subtitle: #e6e6e6; + } + + /* Dark mode box styling - no borders, subtle background */ + .card { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .channel-box { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .metric-card { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .balance-item { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .node-info-main-container { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .node-avatar { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + /* Text hierarchy colors */ + .section-header { + color: var(--text-primary) !important; + } + + .channel-alias { + color: var(--text-primary) !important; + } + + .detail-label { + color: hsl(var(--foreground)) !important; + opacity: 0.5 !important; + } + + .detail-value, .detail-value-amount { + color: hsl(var(--foreground)) !important; + } + + .info-value { + color: var(--text-primary) !important; + } + + .metric-label, .balance-label { + color: var(--text-muted) !important; + } + + .metric-value, .balance-amount { + color: var(--text-primary) !important; + } + + /* Page headers and section titles */ + h1, h2, h3, h4, h5, h6 { + color: var(--text-primary) !important; + } + + /* Form card titles */ + .form-card h2, .form-card h3 { + color: var(--text-primary) !important; + } + + /* Quick action cards styling */ + .quick-action-card { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + border-radius: 0 !important; + padding: 1.5rem !important; + } + + /* Dark mode outline button styling */ + .button-outline { + background-color: transparent !important; + color: var(--text-primary) !important; + border: 1px solid var(--text-muted) !important; + } + + .button-outline:hover { + background-color: rgba(255, 255, 255, 0.2) !important; + } + + /* Navigation dark mode styling */ + nav { + background-color: transparent !important; + border-top: none !important; + border-bottom: none !important; + } + + } + + /* New Header Layout Styles */ + .header-content { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + } + + .header-left { + display: flex; + align-items: center; + gap: 1rem; + } + + .header-avatar { + flex-shrink: 0; + background-color: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + } + + .header-avatar-image { + width: 48px; + height: 48px; + border-radius: 0; + object-fit: cover; + display: block; + } + + .node-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-top: 0; + margin-top: 0; + } + + .node-status { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .status-indicator { + width: 0.75rem; + height: 0.75rem; + border-radius: 50%; + background-color: #10b981; + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); + } + + .status-indicator.status-inactive { + background-color: #ef4444; + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); + } + + .status-text { + font-size: 0.875rem; + font-weight: 500; + color: #10b981; + } + + .status-text.status-inactive { + color: #ef4444; + } + + .node-title { + font-size: 1.875rem; + font-weight: 600; + color: var(--header-title); + margin: 0; + line-height: 1.1; + } + + .node-subtitle { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .header-right { + display: flex; + align-items: center; + } + + + + /* Responsive header */ + @media (max-width: 768px) { + header { + height: 180px; /* Slightly taller for better mobile layout */ + padding: 1rem 0; + } + + header .container { + padding: 0 1rem; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + .header-content { + flex-direction: column; + gap: 1rem; + text-align: center; + width: 100%; + justify-content: center; + } + + .header-left { + flex-direction: column; + text-align: center; + align-items: center; + gap: 0.75rem; + } + + .header-avatar { + width: 64px; + height: 64px; + padding: 0.5rem; + } + + .header-avatar-image { + width: 40px; + height: 40px; + } + + .node-title { + font-size: 1.5rem; + } + + .node-subtitle { + font-size: 0.6875rem; + text-align: center; + } + + .node-status { + justify-content: center; + } + } + + @media (max-width: 480px) { + header { + height: 160px; + } + + .header-avatar { + width: 56px; + height: 56px; + padding: 0.375rem; + } + + .header-avatar-image { + width: 36px; + height: 36px; + } + + .node-title { + font-size: 1.25rem; + } + + .node-subtitle { + font-size: 0.75rem; + } + } + + /* Dark mode navigation styles */ + @media (prefers-color-scheme: dark) { + nav a { + color: var(--text-muted) !important; + } + + nav a:hover { + color: var(--text-secondary) !important; + background-color: rgba(255, 255, 255, 0.08) !important; + transform: translateY(-1px) !important; + } + + nav a.active { + color: var(--text-primary) !important; + background-color: rgba(255, 255, 255, 0.1) !important; + } + + nav a.active:hover { + background-color: rgba(255, 255, 255, 0.12) !important; + transform: translateY(-1px) !important; + } + } + + * { + box-sizing: border-box; + margin: 0; + padding: 0; + } + + html { + font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11'; + font-variation-settings: normal; + } + + body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + font-size: 14px; + line-height: 1.5; + color: hsl(var(--foreground)); + background-color: hsl(var(--background)); + font-feature-settings: 'rlig' 1, 'calt' 1; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: geometricPrecision; + min-height: 100vh; + } + + .container { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; + } + + @media (min-width: 640px) { + .container { + padding: 0 2rem; + } + } + + /* Hero section styling */ + header { + position: relative; + background-color: hsl(var(--background)); + background-image: + linear-gradient(hsl(var(--border)) 1px, transparent 1px), + linear-gradient(90deg, hsl(var(--border)) 1px, transparent 1px); + background-size: 40px 40px; + background-position: -1px -1px; + border-bottom: 1px solid hsl(var(--border)); + margin-bottom: 2rem; + text-align: left; + width: 100%; + height: 200px; /* Reduced height for more compact header */ + display: flex; + align-items: center; + justify-content: flex-start; + } + + /* Subtle diamond gradient fade on edges */ + header::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + linear-gradient(90deg, hsl(var(--background)) 0%, transparent 15%, transparent 85%, hsl(var(--background)) 100%), + linear-gradient(180deg, hsl(var(--background)) 0%, transparent 15%, transparent 85%, hsl(var(--background)) 100%); + pointer-events: none; + z-index: 1; + } + + /* Dark mode header background - subtle grid with darker theme */ + @media (prefers-color-scheme: dark) { + header { + background-color: rgb(18, 19, 21); + background-image: + linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px); + } + + header::before { + background: + linear-gradient(90deg, rgb(18, 19, 21) 0%, transparent 15%, transparent 85%, rgb(18, 19, 21) 100%), + linear-gradient(180deg, rgb(18, 19, 21) 0%, transparent 15%, transparent 85%, rgb(18, 19, 21) 100%); + } + } + + /* Ensure text is positioned properly */ + header .container { + position: relative; + top: auto; + left: auto; + transform: none; + z-index: 2; + width: 100%; + max-width: 1200px; + padding: 0 2rem; + display: flex; + align-items: center; + justify-content: flex-start; + } + + h1 { + font-size: 3rem; + font-weight: 700; + line-height: 1.1; + letter-spacing: -0.02em; + color: var(--header-title); + margin-bottom: 1rem; + } + + .subtitle { + font-size: 1.25rem; + color: var(--header-subtitle); + font-weight: 400; + max-width: 600px; + margin: 0 auto; + line-height: 1.6; + } + + + /* Card fade-in animation */ + @keyframes fade-in { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } + } + + .card { + animation: fade-in 0.3s ease-out; + } + + /* Corner embellishments for angular design */ + .card::before, + .card::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 1px solid hsl(var(--border)); + } + + .card::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .card::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + @media (prefers-color-scheme: dark) { + .card::before, + .card::after { + border-color: rgba(255, 255, 255, 0.2); + } + } + + /* Modern Navigation Bar Styling */ + nav { + background-color: hsl(var(--card)); + border-top: 1px solid hsl(var(--border)); + border-bottom: 1px solid hsl(var(--border)); + border-left: none; + border-right: none; + border-radius: 0; + padding: 0.75rem; + margin-bottom: 2rem; + } + + nav .container { + padding: 0; + display: flex; + justify-content: center; + } + + nav ul { + list-style: none; + display: flex; + gap: 0.5rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin: 0; + padding: 0; + justify-content: center; + } + + nav li { + flex-shrink: 0; + } + + nav a { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + text-decoration: none; + font-size: 1rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + padding: 1rem 1.5rem; + border-radius: 0; + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + min-height: 3rem; + } + + nav a:hover { + color: hsl(var(--foreground)); + background-color: hsl(var(--muted)); + } + + /* Light mode navigation hover states */ + @media (prefers-color-scheme: light) { + nav a:hover { + color: hsl(var(--foreground)); + background-color: hsl(var(--muted) / 0.8); + transform: translateY(-1px); + } + } + + nav a.active { + color: hsl(var(--primary-foreground)); + background-color: hsl(var(--primary)); + font-weight: 700; + } + + nav a.active:hover { + background-color: hsl(var(--primary) / 0.9); + } + + .card { + position: relative; + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 1.5rem; + margin-bottom: 1.5rem; + box-shadow: none; + } + + /* Metric cards styling - matching balance-item style */ + .metrics-container { + display: flex; + gap: 1rem; + margin: 1rem 0; + flex-wrap: wrap; + } + + .metric-card { + position: relative; + flex: 1; + min-width: 200px; + text-align: center; + padding: 1rem; + background-color: hsl(var(--muted) / 0.3); + border-radius: 0; + border: 1px solid hsl(var(--border)); + } + + .metric-card::before, + .metric-card::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border: 1px solid hsl(var(--border)); + } + + .metric-card::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .metric-card::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + @media (prefers-color-scheme: dark) { + .metric-card::before, + .metric-card::after { + border-color: rgba(255, 255, 255, 0.2); + } + } + + .metric-value { + font-size: 1.5rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin-bottom: 0.5rem; + line-height: 1.2; + } + + .metric-label { + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); + font-weight: 400; + } + + .card h2, + .section-title, + h2 { + font-size: var(--fs-title); + line-height: var(--lh-tight); + font-weight: var(--fw-semibold); + color: var(--fg-primary); + text-transform: none; + margin: 0 0 12px; + } + + h3 { + font-size: var(--fs-title); + line-height: var(--lh-tight); + font-weight: var(--fw-semibold); + color: var(--fg-primary); + text-transform: none; + margin: 0 0 12px; + } + + .form-group { + margin-bottom: 1.5rem; + } + + label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--foreground)); + margin-bottom: 0.5rem; + } + + input, textarea, select { + flex: 1; + background-color: hsl(var(--background)); + border: 1px solid hsl(var(--input)); + border-radius: 0; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + line-height: 1.25; + color: hsl(var(--foreground)); + transition: border-color 150ms ease-in-out, box-shadow 150ms ease-in-out; + width: 100%; + } + + /* Dark mode input field improvements */ + @media (prefers-color-scheme: dark) { + input, textarea, select { + background-color: hsl(0 0% 8%); + border: 1px solid hsl(0 0% 20%); + color: hsl(var(--foreground)); + } + + input:focus, textarea:focus, select:focus { + background-color: hsl(0 0% 10%); + border-color: hsl(var(--ring)); + } + + textarea { + color: var(--text-primary) !important; + } + } + + input:focus, textarea:focus, select:focus { + outline: 2px solid transparent; + outline-offset: 2px; + border-color: hsl(var(--ring)); + box-shadow: 0 0 0 2px hsl(var(--ring)); + } + + input:disabled, textarea:disabled, select:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + /* Subtle pagination dropdown styling */ + .per-page-selector { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 1rem 0 0 0; + padding: 0; + background-color: transparent; + border: none; + border-radius: 0; + font-size: 0.875rem; + } + + .per-page-selector label { + color: hsl(var(--muted-foreground)); + font-weight: 500; + } + + .per-page-selector select { + background-color: transparent; + border: 1px solid hsl(var(--muted)); + border-radius: 0; + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + color: hsl(var(--muted-foreground)); + min-width: 50px; + cursor: pointer; + transition: all 0.2s ease; + flex: none; + width: auto; + } + + .per-page-selector select:hover { + border-color: hsl(var(--ring)); + background-color: hsl(var(--muted) / 0.5); + } + + .per-page-selector select:focus { + outline: 2px solid transparent; + outline-offset: 2px; + border-color: hsl(var(--ring)); + box-shadow: 0 0 0 2px hsl(var(--ring) / 0.2); + } + + .per-page-selector span { + color: hsl(var(--muted-foreground)); + font-weight: 500; + } + + /* Form actions layout */ + .form-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid hsl(var(--border)); + } + + .form-actions .button-secondary { + order: 1; + } + + .form-actions .button-primary { + order: 2; + } + + button { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + border-radius: 0; + font-size: 0.875rem; + font-weight: 600; + transition: all 150ms ease-in-out; + border: 1px solid transparent; + cursor: pointer; + padding: 0.5rem 1rem; + height: 2.25rem; + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + } + + button:hover { + background-color: hsl(var(--primary) / 0.9); + } + + button:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; + } + + button:disabled { + pointer-events: none; + opacity: 0.5; + } + + .button-secondary { + background-color: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + border: 1px solid hsl(var(--input)); + } + + .button-secondary:hover { + background-color: hsl(var(--secondary) / 0.8); + } + + .button-outline { + border: 1px solid hsl(var(--input)); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + } + + .button-outline:hover { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + } + + .button-destructive { + background-color: transparent !important; + color: #DC2626 !important; + border: 1px solid #DC2626 !important; + } + + .button-destructive:hover { + background-color: rgba(220, 38, 38, 0.2) !important; + } + + + + .button-sm { + height: 2rem; + border-radius: 0; + padding: 0 0.75rem; + font-size: 0.75rem; + } + + .button-lg { + height: 2.75rem; + border-radius: 0; + padding: 0 2rem; + font-size: 1rem; + } + + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 1.5rem; + } + + @media (max-width: 640px) { + .grid { + grid-template-columns: 1fr; + } + } + + + + .info-label, + .sub-label, + label { + font-size: var(--fs-label); + line-height: var(--lh-normal); + font-weight: var(--fw-medium); + color: var(--fg-muted); + text-transform: none; + letter-spacing: 0.02em; + flex-shrink: 0; + } + + .info-value { + font-size: 0.875rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + color: var(--fg-primary); + text-align: right; + word-break: break-all; + overflow-wrap: break-word; + hyphens: auto; + min-width: 0; + } + + .info-item { + display: flex; + gap: 0.5rem; + align-items: baseline; + margin: 8px 0; + padding: 1rem 0; + border-bottom: 1px solid hsl(var(--border)); + min-height: 3rem; + justify-content: space-between; + } + + .info-item:last-child { + border-bottom: none; + } + + /* Card flex spacing improvements */ + .card-flex { + display: flex; + gap: 1rem; + align-items: center; + } + + .card-flex-content { + flex: 1 1 auto; + } + + .card-flex-button { + flex: 0 0 auto; + } + + .card-flex-content p { + margin: 0 0 12px; + line-height: var(--lh-normal); + } + + .card-flex-content p + .card-flex-button, + .card-flex-content p + a, + .card-flex-content p + button { + margin-top: 12px; + } + + .card-flex-content .body + .card-flex-button, + .card-flex-content .body + a, + .card-flex-content .body + button { + margin-top: 12px; + } + + .truncate-value { + font-size: 0.875rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + color: hsl(var(--foreground)); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: inline-block; + max-width: 200px; + } + + .copy-button { + background-color: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 0.25rem 0.5rem; + cursor: pointer; + font-size: 0.75rem; + font-weight: 600; + margin-left: 0.5rem; + transition: all 150ms ease-in-out; + height: auto; + min-height: auto; + flex-shrink: 0; + } + + .copy-button:hover { + background-color: hsl(var(--secondary) / 0.8); + border-color: hsl(var(--border)); + } + + /* Invoice details section */ + .invoice-details-section { + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid hsl(var(--border)); + } + + /* Invoice amount section - prominent display */ + .invoice-amount-section { + text-align: center; + margin-bottom: 2rem; + padding: 1.5rem; + background-color: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + position: relative; + } + + .invoice-amount-section::before, + .invoice-amount-section::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 1px solid hsl(var(--border)); + } + + .invoice-amount-section::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .invoice-amount-section::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + .invoice-amount-label { + font-size: 0.875rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .invoice-amount-value { + font-size: 2rem; + font-weight: 700; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + color: hsl(var(--foreground)); + line-height: 1.2; + } + + /* Invoice display section */ + .invoice-display-section { + margin-top: 1rem; + } + + .invoice-label { + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.75rem; + } + + .invoice-display-container { + background-color: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 1rem; + position: relative; + } + + .invoice-display-container::before, + .invoice-display-container::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border: 1px solid hsl(var(--border)); + } + + .invoice-display-container::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .invoice-display-container::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + .invoice-textarea { + width: 100%; + background-color: transparent !important; + border: none !important; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace !important; + font-size: 0.875rem !important; + color: var(--fg-primary) !important; + padding: 0 !important; + margin: 0 !important; + outline: none !important; + word-break: break-all; + overflow-wrap: break-word; + hyphens: auto; + line-height: 1.5; + text-align: left; + resize: none; + min-height: 100px; + height: auto; + overflow: visible; + } + + .invoice-textarea:focus { + box-shadow: none !important; + border: none !important; + } + + /* Dark mode invoice display styling */ + @media (prefers-color-scheme: dark) { + .invoice-amount-section { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .invoice-amount-section::before, + .invoice-amount-section::after { + border-color: rgba(255, 255, 255, 0.2); + } + + .invoice-amount-label { + color: var(--text-muted) !important; + } + + .invoice-amount-value { + color: var(--text-primary) !important; + } + + .invoice-label { + color: var(--text-primary) !important; + } + + .invoice-display-container { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .invoice-display-container::before, + .invoice-display-container::after { + border-color: rgba(255, 255, 255, 0.2); + } + + .invoice-textarea { + color: var(--text-primary) !important; + } + } + + /* Responsive invoice display */ + @media (max-width: 640px) { + .invoice-amount-value { + font-size: 1.5rem; + } + + .invoice-textarea { + font-size: 0.75rem !important; + line-height: 1.4; + min-height: 80px; + } + } + + .balance-item, + .balance-item-container { + padding: 1.25rem 0; + border-bottom: 1px solid hsl(var(--border)); + margin-bottom: 10px; + } + + .balance-item:last-child, + .balance-item-container:last-child { + border-bottom: none; + } + + .balance-item .balance-label, + .balance-item-container .balance-label, + .balance-title, + .balance-label { + display: block; + margin-bottom: 6px; + font-size: var(--fs-label); + line-height: var(--lh-normal); + font-weight: var(--fw-medium); + color: var(--fg-muted); + letter-spacing: 0.02em; + text-transform: none; + } + + .balance-item .balance-amount, + .balance-item-container .balance-value, + .balance-amount, + .balance-amount-value, + .balance-value { + display: block; + font-size: var(--fs-value); + line-height: var(--lh-tight); + font-weight: var(--fw-bold); + color: var(--fg-primary); + white-space: nowrap; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + } + + .balance-item .info-label + .info-value, + .balance-item .label + .amount, + .balance-item-container .info-label + .info-value, + .balance-item-container .label + .amount { + margin-top: 6px; + } + + .alert { + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 1rem; + margin-bottom: 1rem; + } + + .alert-success { + border-color: hsl(142.1 76.2% 36.3%); + background-color: hsl(142.1 70.6% 45.3% / 0.1); + color: hsl(142.1 76.2% 36.3%); + } + + .alert-destructive { + border-color: hsl(var(--destructive)); + background-color: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); + } + + .alert-warning { + border-color: hsl(32.6 75.4% 55.1%); + background-color: hsl(32.6 75.4% 55.1% / 0.1); + color: hsl(32.6 75.4% 55.1%); + } + + /* Legacy classes for backward compatibility */ + .success { + border-color: hsl(142.1 76.2% 36.3%); + background-color: hsl(142.1 70.6% 45.3% / 0.1); + color: hsl(142.1 76.2% 36.3%); + border: 1px solid hsl(142.1 76.2% 36.3%); + border-radius: 0; + padding: 1rem; + margin-bottom: 1rem; + } + + .error { + border-color: hsl(var(--destructive)); + background-color: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); + border: 1px solid hsl(var(--destructive)); + border-radius: 0; + padding: 1rem; + margin-bottom: 1rem; + } + + .badge { + display: inline-flex; + align-items: center; + border-radius: 9999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + transition: all 150ms ease-in-out; + border: 1px solid transparent; + } + + .badge-default { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + } + + .badge-secondary { + background-color: hsl(var(--secondary)); + color: hsl(var(--secondary-foreground)); + } + + .badge-success { + background-color: hsl(142.1 70.6% 45.3%); + color: hsl(355.7 78% 98.4%); + } + + .badge-destructive { + background-color: hsl(var(--destructive)); + color: hsl(var(--destructive-foreground)); + } + + .badge-outline { + background-color: transparent; + color: hsl(var(--foreground)); + border: 1px solid hsl(var(--border)); + } + + /* Status badge classes - consistent with payment type badges */ + .status-badge { + display: inline-flex; + align-items: center; + border-radius: 9999px; + padding: 0.25rem 0.625rem; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + } + + .status-active { + background-color: hsl(142.1 70.6% 45.3% / 0.1); + color: hsl(142.1 70.6% 45.3%); + border: 1px solid hsl(142.1 70.6% 45.3% / 0.2); + } + + .status-inactive { + background-color: hsl(0 84.2% 60.2% / 0.1); + color: hsl(0 84.2% 60.2%); + border: 1px solid hsl(0 84.2% 60.2% / 0.2); + } + + .status-pending { + background-color: hsl(215.4 16.3% 46.9% / 0.1); + color: hsl(215.4 16.3% 46.9%); + border: 1px solid hsl(215.4 16.3% 46.9% / 0.2); + } + + .channel-box { + position: relative; + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 1.5rem; + margin-bottom: 1.5rem; + } + + .channel-box::before, + .channel-box::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 1px solid hsl(var(--border)); + } + + .channel-box::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .channel-box::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + @media (prefers-color-scheme: dark) { + .channel-box::before, + .channel-box::after { + border-color: rgba(255, 255, 255, 0.2); + } + } + + .section-header { + font-size: 1.25rem; + font-weight: 700; + color: hsl(var(--foreground)); + margin-bottom: 1.5rem; + line-height: 1.2; + } + + .channel-alias { + font-size: 1.25rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin-bottom: 1rem; + line-height: 1.2; + } + + .channel-details { + margin-bottom: 1.5rem; + } + + .detail-row { + display: flex; + align-items: center; + margin-bottom: 1rem; + gap: 1.5rem; + padding: 0.75rem 0; + } + + .detail-row:last-child { + margin-bottom: 0; + } + + .detail-label { + font-weight: 500; + color: hsl(var(--foreground)); + opacity: 0.5; + font-size: 0.8125rem; + min-width: 140px; + flex-shrink: 0; + letter-spacing: 0.025em; + text-transform: uppercase; + text-align: right; + } + + .detail-value { + color: hsl(var(--foreground)); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + font-size: 0.875rem; + font-weight: 400; + word-break: break-all; + flex: 1; + min-width: 0; + letter-spacing: -0.01em; + line-height: 1.5; + } + + .detail-value-amount { + color: hsl(var(--foreground)); + font-size: 0.9375rem; + font-weight: 500; + word-break: break-all; + flex: 1; + min-width: 0; + letter-spacing: 0; + } + + .channel-actions { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 1rem; + gap: 1rem; + } + + @media (max-width: 640px) { + .channel-actions { + flex-direction: column; + align-items: stretch; + } + + .detail-row { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + + .detail-label { + min-width: auto; + } + } + + .balance-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 1rem; + margin-top: 1rem; + } + + @media (max-width: 640px) { + .balance-info { + grid-template-columns: 1fr; + } + } + + .balance-item { + position: relative; + text-align: center; + padding: 1rem; + background-color: hsl(var(--muted) / 0.3); + border-radius: 0; + border: 1px solid hsl(var(--border)); + } + + .balance-item::before, + .balance-item::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border: 1px solid hsl(var(--border)); + } + + .balance-item::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .balance-item::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + @media (prefers-color-scheme: dark) { + .balance-item::before, + .balance-item::after { + border-color: rgba(255, 255, 255, 0.2); + } + } + + .balance-amount { + font-weight: 600; + font-size: 1.125rem; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + color: hsl(var(--foreground)); + line-height: 1.2; + } + + + + .payment-item { + position: relative; + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 1.5rem; + margin-bottom: 1.5rem; + } + + .payment-item::before, + .payment-item::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 1px solid hsl(var(--border)); + } + + .payment-item::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .payment-item::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + @media (prefers-color-scheme: dark) { + .payment-item::before, + .payment-item::after { + border-color: rgba(255, 255, 255, 0.2); + } + } + + /* Dark mode payment card improvements - match other cards */ + @media (prefers-color-scheme: dark) { + .payment-item { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + } + + .payment-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; + gap: 1rem; + } + + @media (max-width: 640px) { + .payment-header { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + } + + .payment-direction { + display: flex; + align-items: center; + gap: 0.5rem; + font-weight: 600; + color: hsl(var(--foreground)); + flex: 1; + min-width: 0; + } + + .direction-icon { + font-size: 1.125rem; + font-weight: bold; + color: hsl(var(--muted-foreground)); + } + + .payment-details { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .payment-amount { + font-size: 1.25rem; + font-weight: 600; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + color: hsl(var(--foreground)); + line-height: 1.2; + } + + .payment-info { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + } + + @media (max-width: 640px) { + .payment-info { + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + } + } + + .payment-label { + font-weight: 400; + color: var(--text-muted); + font-size: 0.75rem; + flex-shrink: 0; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .payment-value { + color: var(--text-tertiary); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + font-size: 0.8125rem; + font-weight: 300; + word-break: break-all; + min-width: 0; + letter-spacing: -0.02em; + line-height: 1.7; + } + + .payment-list-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid hsl(var(--border)); + } + + @media (max-width: 640px) { + .payment-list-header { + flex-direction: column; + align-items: stretch; + gap: 1rem; + } + } + + .payment-filter-tabs { + display: flex; + gap: 0.25rem; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .payment-filter-tab { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + padding: 0.5rem 1rem; + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + border-radius: 0; + text-decoration: none; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; + font-weight: 600; + transition: all 150ms ease-in-out; + height: 2.25rem; + } + + .payment-filter-tab:hover { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + text-decoration: none; + } + + .payment-filter-tab.active { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); + } + + /* Dark mode specific styling for payment filter tabs */ + @media (prefers-color-scheme: dark) { + .payment-filter-tab { + background-color: rgba(255, 255, 255, 0.03) !important; + border-color: var(--text-muted) !important; + color: var(--text-muted) !important; + } + + .payment-filter-tab:hover { + background-color: rgba(255, 255, 255, 0.08) !important; + color: var(--text-secondary) !important; + } + + .payment-filter-tab.active { + background-color: rgba(255, 255, 255, 0.12) !important; + color: var(--text-primary) !important; + border-color: var(--text-secondary) !important; + } + + .payment-filter-tab.active:hover { + background-color: rgba(255, 255, 255, 0.15) !important; + } + } + + .payment-type-badge { + display: inline-flex; + align-items: center; + border-radius: 9999px; + padding: 0.125rem 0.5rem; + font-size: 0.625rem; + font-weight: 600; + line-height: 1; + margin-left: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .payment-type-bolt11 { + background-color: hsl(217 91% 60% / 0.1); + color: hsl(217 91% 60%); + border: 1px solid hsl(217 91% 60% / 0.2); + } + + .payment-type-bolt12 { + background-color: hsl(262 83% 58% / 0.1); + color: hsl(262 83% 58%); + border: 1px solid hsl(262 83% 58% / 0.2); + } + + .payment-type-onchain { + background-color: hsl(32 95% 44% / 0.1); + color: hsl(32 95% 44%); + border: 1px solid hsl(32 95% 44% / 0.2); + } + + /* Dark mode payment type badge improvements */ + @media (prefers-color-scheme: dark) { + .payment-type-onchain { + background-color: hsl(32 95% 60% / 0.15); + color: hsl(32 95% 70%); + border: 1px solid hsl(32 95% 60% / 0.3); + } + + .payment-type-bolt11 { + background-color: hsl(217 91% 70% / 0.15); + color: hsl(217 91% 80%); + border: 1px solid hsl(217 91% 70% / 0.3); + } + + .payment-type-bolt12 { + background-color: hsl(262 83% 70% / 0.15); + color: hsl(262 83% 80%); + border: 1px solid hsl(262 83% 70% / 0.3); + } + + .payment-type-spontaneous { + background-color: hsl(142.1 70.6% 60% / 0.15); + color: hsl(142.1 70.6% 75%); + border: 1px solid hsl(142.1 70.6% 60% / 0.3); + } + + .payment-type-bolt11-jit { + background-color: hsl(199 89% 65% / 0.15); + color: hsl(199 89% 80%); + border: 1px solid hsl(199 89% 65% / 0.3); + } + } + + .payment-type-spontaneous { + background-color: hsl(142.1 70.6% 45.3% / 0.1); + color: hsl(142.1 70.6% 45.3%); + border: 1px solid hsl(142.1 70.6% 45.3% / 0.2); + } + + .payment-type-bolt11-jit { + background-color: hsl(199 89% 48% / 0.1); + color: hsl(199 89% 48%); + border: 1px solid hsl(199 89% 48% / 0.2); + } + + .payment-type-unknown { + background-color: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + border: 1px solid hsl(var(--border)); + } + + /* Pagination */ + .pagination-controls { + display: flex; + justify-content: center; + align-items: center; + margin: 2rem 0; + } + + .pagination { + display: flex; + align-items: center; + gap: 0.25rem; + list-style: none; + } + + .pagination-btn, .pagination-number { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + border-radius: 0; + font-size: 0.875rem; + font-weight: 600; + transition: all 150ms ease-in-out; + border: 1px solid hsl(var(--border)); + background-color: hsl(var(--background)); + color: hsl(var(--foreground)); + text-decoration: none; + cursor: pointer; + height: 2.25rem; + min-width: 2.25rem; + padding: 0 0.5rem; + } + + .pagination-btn:hover, .pagination-number:hover { + background-color: hsl(var(--accent)); + color: hsl(var(--accent-foreground)); + text-decoration: none; + } + + .pagination-number.active { + background-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); + } + + .pagination-btn.disabled { + background-color: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + cursor: not-allowed; + opacity: 0.5; + pointer-events: none; + } + + .pagination-ellipsis { + display: flex; + align-items: center; + justify-content: center; + height: 2.25rem; + width: 2.25rem; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; + } + + /* Responsive adjustments */ + @media (max-width: 640px) { + .container { + padding: 0 1rem; + } + + header { + padding: 1rem 0; + margin-bottom: 1rem; + } + + h1 { + font-size: 1.5rem; + } + + nav ul { + flex-wrap: wrap; + } + + .card { + padding: 1rem; + margin-bottom: 1rem; + } + + .info-item { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem 0; + min-height: auto; + } + + .info-value, .truncate-value { + text-align: left; + max-width: 100%; + } + + .copy-button { + margin-left: 0; + margin-top: 0.25rem; + align-self: flex-start; + } + + .balance-amount-value { + font-size: 1.25rem; + } + + .pagination { + flex-wrap: wrap; + justify-content: center; + gap: 0.125rem; + } + + .pagination-btn, .pagination-number { + height: 2rem; + min-width: 2rem; + font-size: 0.75rem; + } + } + + /* Node Information Section Styling */ + .node-info-section { + display: flex; + gap: 1.5rem; + margin-bottom: 1.5rem; + align-items: stretch; + } + + .node-info-main-container { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + gap: 1rem; + background-color: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 1.5rem; + box-shadow: none; + height: 100%; + } + + .node-info-main-container::before, + .node-info-main-container::after { + content: ''; + position: absolute; + width: 16px; + height: 16px; + border: 1px solid hsl(var(--border)); + } + + .node-info-main-container::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .node-info-main-container::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + @media (prefers-color-scheme: dark) { + .node-info-main-container::before, + .node-info-main-container::after { + border-color: rgba(255, 255, 255, 0.2); + } + } + + .node-info-left { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + } + + .node-avatar { + flex-shrink: 0; + background-color: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 0.75rem; + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + } + + .avatar-image { + width: 48px; + height: 48px; + border-radius: 0; + object-fit: cover; + display: block; + } + + .node-details { + flex: 1; + min-width: 0; + } + + .node-name { + font-size: var(--fs-title); + font-weight: var(--fw-semibold); + color: var(--fg-primary); + margin: 0 0 0.25rem 0; + line-height: var(--lh-tight); + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + } + + .node-address { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; + letter-spacing: 0.05em; + text-transform: uppercase; + margin: 0; + line-height: var(--lh-normal); + } + + .node-content-box { + background-color: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0; + min-height: 200px; + padding: 1rem; + display: flex; + align-items: center; + justify-content: center; + color: hsl(var(--muted-foreground)); + overflow: hidden; + } + + .node-metrics { + flex-shrink: 0; + width: 280px; + display: flex; + flex-direction: column; + align-self: stretch; + } + + .node-metrics .card { + margin-bottom: 0; + flex: 1; + display: flex; + flex-direction: column; + align-self: stretch; + } + + .node-metrics .metrics-container { + flex-direction: column; + margin: 1rem 0 0 0; + flex: 1; + display: flex; + justify-content: flex-start; + gap: 1rem; + align-items: stretch; + } + + .node-metrics .metric-card { + min-width: auto; + padding: 1rem; + height: fit-content; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + } + + /* Mobile responsive design for node info */ + @media (max-width: 768px) { + .node-info-section { + flex-direction: column; + gap: 1rem; + } + + .node-info-left { + flex-direction: column; + align-items: flex-start; + text-align: center; + gap: 0.75rem; + } + + .node-avatar { + align-self: center; + } + + .node-details { + text-align: center; + width: 100%; + } + + .node-content-box { + min-height: 150px; + padding: 1rem; + } + + .node-metrics { + width: 100%; + } + + .node-metrics .metrics-container { + flex-direction: row; + flex-wrap: wrap; + } + + .node-metrics .metric-card { + flex: 1; + min-width: 120px; + } + } + + @media (max-width: 480px) { + .node-info-left { + gap: 0.5rem; + } + + .node-avatar { + width: 64px; + height: 64px; + padding: 0.5rem; + } + + .avatar-image { + width: 40px; + height: 40px; + } + + .node-name { + font-size: 1rem; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + } + + .node-address { + font-size: 0.8125rem; + } + + .node-content-box { + min-height: 120px; + padding: 0.75rem; + } + + .node-metrics .metrics-container { + flex-direction: column; + gap: 0.75rem; + } + } + + /* Activity Grid Layout - Side by Side */ + .activity-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0; + margin-top: 1.5rem; + } + + .activity-section { + padding: 2rem 1.5rem; + border-right: 1px solid hsl(var(--border)); + border-top: 1px solid hsl(var(--border)); + } + + .activity-section:last-child { + border-right: none; + } + + .activity-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 2rem; + padding-bottom: 0; + border-bottom: none; + } + + .activity-icon-box { + flex-shrink: 0; + background-color: hsl(var(--muted) / 0.3); + border: 1px solid hsl(var(--border)); + border-radius: 0; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + } + + .activity-icon-box svg { + color: hsl(var(--foreground)); + } + + .activity-title { + font-size: 1rem; + font-weight: 400; + color: hsl(var(--foreground)); + margin: 0; + text-transform: none; + letter-spacing: normal; + } + + .activity-metrics { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .activity-metric-card { + position: relative; + text-align: left; + padding: 1rem; + background-color: hsl(var(--muted) / 0.3); + border-radius: 0; + border: 1px solid hsl(var(--border)); + } + + .activity-metric-card::before, + .activity-metric-card::after { + content: ''; + position: absolute; + width: 12px; + height: 12px; + border: 1px solid hsl(var(--border)); + } + + .activity-metric-card::before { + top: -1px; + left: -1px; + border-right: none; + border-bottom: none; + } + + .activity-metric-card::after { + bottom: -1px; + right: -1px; + border-left: none; + border-top: none; + } + + .activity-metric-label { + display: block; + margin-bottom: 0.5rem; + font-size: 0.875rem; + font-weight: 400; + color: hsl(var(--muted-foreground)); + text-transform: none; + letter-spacing: normal; + } + + .activity-metric-value { + display: block; + font-size: 1.5rem; + font-weight: 600; + color: hsl(var(--foreground)); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + line-height: 1.2; + } + + /* Dark mode activity styling */ + @media (prefers-color-scheme: dark) { + .activity-icon-box { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .activity-icon-box svg { + color: var(--text-primary); + } + + .activity-title { + color: var(--text-primary); + } + + .activity-metric-card { + background-color: rgba(255, 255, 255, 0.03) !important; + border: none !important; + } + + .activity-metric-card::before, + .activity-metric-card::after { + border-color: rgba(255, 255, 255, 0.2); + } + + .activity-metric-label { + color: var(--text-muted) !important; + } + + .activity-metric-value { + color: var(--text-primary) !important; + } + } + + /* Responsive activity grid */ + @media (max-width: 768px) { + .activity-grid { + grid-template-columns: 1fr; + } + + .activity-section { + border-right: none; + border-bottom: 1px solid hsl(var(--border)); + padding: 1.5rem 1rem; + } + + .activity-section:last-child { + border-bottom: none; + } + } + + /* Responsive typography adjustments */ + @media (max-width: 640px) { + :root { + --fs-value: 1.45rem; + } + + .node-name { + font-size: 0.875rem; + } + + .activity-metric-value { + font-size: 1.25rem; + } + } + + /* Payment tabs styling */ + .payment-tabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid hsl(var(--border)); + } + + .payment-tab { + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + padding: 0.75rem 1.5rem; + border: none; + border-bottom: 2px solid transparent; + background-color: transparent; + border-radius: 0; + text-decoration: none; + color: hsl(var(--muted-foreground)); + font-size: 0.9375rem; + font-weight: 600; + transition: all 200ms ease; + cursor: pointer; + position: relative; + margin-bottom: -1px; + } + + .payment-tab:hover { + color: hsl(var(--foreground)); + background-color: hsl(var(--muted) / 0.5); + } + + .payment-tab.active { + color: hsl(var(--foreground)); + border-bottom-color: hsl(var(--foreground)); + background-color: transparent; + } + + /* Dark mode tab styling */ + @media (prefers-color-scheme: dark) { + .payment-tab { + color: var(--text-muted); + } + + .payment-tab:hover { + color: var(--text-secondary); + background-color: rgba(255, 255, 255, 0.05); + } + + .payment-tab.active { + color: var(--text-primary); + border-bottom-color: var(--text-primary); + } + } + + /* Tab content */ + .tab-content { + display: none; + animation: fade-in 0.2s ease-out; + } + + .tab-content.active { + display: block; + } + + @keyframes fade-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + + @media (max-width: 480px) { + .node-name { + font-size: 0.8125rem; + } + } + + /* Dark mode adjustments for globe animation */ + @media (prefers-color-scheme: dark) { + .node-content-box .world { + border-color: rgba(156, 163, 175, 0.4); + fill: rgba(156, 163, 175, 0.2); + } + } + + /* Address display styling */ + .address-display { + margin: 1.5rem 0; + } + + .address-container { + padding: 1rem 0; + } + + .address-text { + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + font-size: 1.25rem; + font-weight: 500; + color: hsl(var(--foreground)); + word-break: break-all; + overflow-wrap: break-word; + hyphens: auto; + flex: 1; + min-width: 0; + line-height: 1.4; + background-color: transparent; + border: none; + padding: 0; + } + + + /* Dark mode address styling */ + @media (prefers-color-scheme: dark) { + .address-text { + color: var(--text-primary) !important; + } + } + + /* Responsive address display */ + @media (max-width: 640px) { + .address-text { + font-size: 1.125rem; + text-align: center; + } + } + + /* Transaction confirmation styling */ + .transaction-details { + margin-top: 1rem; + } + + .transaction-details .detail-row { + display: flex; + align-items: baseline; + margin-bottom: 1rem; + gap: 1rem; + padding: 0.75rem 0; + border-bottom: 1px solid hsl(var(--border)); + } + + .transaction-details .detail-row:last-child { + border-bottom: none; + margin-bottom: 0; + } + + .transaction-details .detail-label { + font-weight: 400; + color: var(--text-muted); + font-size: 0.75rem; + min-width: 180px; + flex-shrink: 0; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .transaction-details .detail-value { + color: var(--text-tertiary); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + font-size: 0.8125rem; + font-weight: 300; + word-break: break-all; + flex: 1; + min-width: 0; + letter-spacing: -0.02em; + line-height: 1.7; + } + + .transaction-details .detail-value-amount { + color: var(--text-secondary); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'Courier New', monospace; + font-size: 1rem; + font-weight: 600; + letter-spacing: 0; + flex: 1; + min-width: 0; + } + + .send-all-notice { + border: 1px solid hsl(32.6 75.4% 55.1%); + background-color: hsl(32.6 75.4% 55.1% / 0.1); + } + + .send-all-notice h3 { + color: hsl(32.6 75.4% 55.1%); + font-size: 1rem; + font-weight: 600; + margin-bottom: 0.5rem; + } + + .send-all-notice p { + color: hsl(32.6 75.4% 55.1%); + font-size: 0.875rem; + line-height: 1.4; + margin: 0; + } + + /* Dark mode transaction styling */ + @media (prefers-color-scheme: dark) { + .transaction-details .detail-label { + color: var(--text-muted) !important; + } + + .transaction-details .detail-value, + .transaction-details .detail-value-amount { + color: var(--text-primary) !important; + } + + .send-all-notice { + background-color: hsl(32.6 75.4% 55.1% / 0.15) !important; + border-color: hsl(32.6 75.4% 55.1% / 0.3) !important; + } + } + + /* Responsive transaction details */ + @media (max-width: 640px) { + .transaction-details .detail-row { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .transaction-details .detail-label { + min-width: auto; + font-size: 0.8125rem; + } + + .transaction-details .detail-value, + .transaction-details .detail-value-amount { + font-size: 0.875rem; + } + } + " + } + } + body { + header { + div class="container" { + div class="header-content" { + div class="header-left" { + div class="header-avatar" { + img src="/static/images/nut.png" alt="CDK LDK Node Icon" class="header-avatar-image"; + } + div class="node-info" { + div class="node-status" { + @if is_running { + span class="status-indicator" {} + span class="status-text" { "Running" } + } @else { + span class="status-indicator status-inactive" {} + span class="status-text status-inactive" { "Inactive" } + } + } + h1 class="node-title" { "CDK LDK Node" } + span class="node-subtitle" { "Cashu Mint & Lightning Network Node Management" } + } + } + div class="header-right" { + // Right side content can be added here later if needed + } + } + } + } + + nav { + div class="container" { + ul { + li { + a href="/" { + 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" style="margin-right: 0.5rem;" { + path d="M15.6 2.7a10 10 0 1 0 5.7 5.7" {} + circle cx="12" cy="12" r="2" {} + path d="M13.4 10.6 19 5" {} + } + "Dashboard" + } + } + li { + a href="/balance" { + 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" style="margin-right: 0.5rem;" { + 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" {} + } + "Lightning" + } + } + li { + a href="/onchain" { + 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" style="margin-right: 0.5rem;" { + 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" {} + } + "On-chain" + } + } + li { + a href="/payments" { + 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" style="margin-right: 0.5rem;" { + path d="M12 18H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5" {} + path d="m16 19 3 3 3-3" {} + path d="M18 12h.01" {} + path d="M19 16v6" {} + path d="M6 12h.01" {} + circle cx="12" cy="12" r="2" {} + } + "All Payments" + } + } + } + } + } + + main class="container" { + (content) + } + + + + } + } + } +} diff --git a/crates/cdk-ldk-node/src/web/templates/mod.rs b/crates/cdk-ldk-node/src/web/templates/mod.rs new file mode 100644 index 000000000..d01660856 --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/mod.rs @@ -0,0 +1,10 @@ +pub mod components; +pub mod formatters; +pub mod layout; +pub mod payments; + +// Re-export commonly used functions +pub use components::*; +pub use formatters::*; +pub use layout::*; +pub use payments::*; diff --git a/crates/cdk-ldk-node/src/web/templates/payments.rs b/crates/cdk-ldk-node/src/web/templates/payments.rs new file mode 100644 index 000000000..50c518c4a --- /dev/null +++ b/crates/cdk-ldk-node/src/web/templates/payments.rs @@ -0,0 +1,106 @@ +use maud::{html, Markup}; + +use crate::web::templates::formatters::format_timestamp; + +#[allow(clippy::too_many_arguments)] +pub fn payment_list_item( + _payment_id: &str, + direction: &str, + status: &str, + amount: &str, + payment_hash: Option<&str>, + description: Option<&str>, + timestamp: Option, + payment_type: &str, + preimage: Option<&str>, +) -> Markup { + let status_class = match status { + "Succeeded" => "status-active", + "Failed" => "status-inactive", + "Pending" => "status-pending", + "Unpaid" => "status-pending", // Use pending styling for unpaid + _ => "status-badge", + }; + + let direction_icon = match direction { + "Inbound" => "↓", + "Outbound" => "↑", + _ => "•", + }; + + let type_class = match payment_type { + "BOLT11" => "payment-type-bolt11", + "BOLT12" => "payment-type-bolt12", + "On-chain" => "payment-type-onchain", + "Spontaneous" => "payment-type-spontaneous", + "BOLT11 JIT" => "payment-type-bolt11-jit", + _ => "payment-type-unknown", + }; + + html! { + div class="payment-item" { + div class="payment-header" { + div class="payment-direction" { + span class="direction-icon" { (direction_icon) } + span { (direction) " Payment" } + span class=(format!("payment-type-badge {}", type_class)) { (payment_type) } + } + span class=(format!("status-badge {}", status_class)) { (status) } + } + + div class="payment-details" { + div class="payment-amount" { (amount) } + + @if let Some(hash) = payment_hash { + div class="payment-info" { + span class="payment-label" { + @if payment_type == "BOLT11" || payment_type == "BOLT12" || payment_type == "Spontaneous" || payment_type == "BOLT11 JIT" { "Payment Hash:" } + @else { "Transaction ID:" } + } + span class="payment-value" title=(hash) { + (&hash[..std::cmp::min(16, hash.len())]) "..." + } + button class="copy-button" data-copy=(hash) + onclick="navigator.clipboard.writeText(this.getAttribute('data-copy')).then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000); })" { + "Copy" + } + } + } + + // Show preimage for successful outgoing BOLT11 or BOLT12 payments + @if let Some(preimage_str) = preimage { + @if !preimage_str.is_empty() && direction == "Outbound" && status == "Succeeded" && (payment_type == "BOLT11" || payment_type == "BOLT12") { + div class="payment-info" { + span class="payment-label" { "Preimage:" } + span class="payment-value" title=(preimage_str) { + (&preimage_str[..std::cmp::min(16, preimage_str.len())]) "..." + } + button class="copy-button" data-copy=(preimage_str) + onclick="navigator.clipboard.writeText(this.getAttribute('data-copy')).then(() => { this.textContent = 'Copied!'; setTimeout(() => this.textContent = 'Copy', 2000); })" { + "Copy" + } + } + } + } + + @if let Some(desc) = description { + @if !desc.is_empty() { + div class="payment-info" { + span class="payment-label" { "Description:" } + span class="payment-value" { (desc) } + } + } + } + + @if let Some(ts) = timestamp { + div class="payment-info" { + span class="payment-label" { "Last Update:" } + span class="payment-value" { + (format_timestamp(ts)) + } + } + } + } + } + } +} diff --git a/crates/cdk-ldk-node/static/css/globe.css b/crates/cdk-ldk-node/static/css/globe.css new file mode 100644 index 000000000..6cdca043c --- /dev/null +++ b/crates/cdk-ldk-node/static/css/globe.css @@ -0,0 +1,75 @@ +/* Spinning Globe Animation CSS - Replaces radar */ +:root { + --globe-hue: 220deg; + --globe-base-bg-sat: 20%; + --globe-base-bg-lum: 12%; + --globe-base-bg: hsl(var(--globe-hue), var(--globe-base-bg-sat), var(--globe-base-bg-lum)); + --globe-base-fg-sat: 50%; + --globe-base-fg-lum: 80%; + --globe-base-fg: hsl(var(--globe-hue), var(--globe-base-fg-sat), var(--globe-base-fg-lum)); + --globe-filter-fg: saturate(100%) brightness(100%); + --globe-module-bg-sat: 18%; + --globe-module-bg-lum: 27%; + --globe-module-bg: hsl(var(--globe-hue), var(--globe-module-bg-sat), var(--globe-module-bg-lum)); +} + +/* Dark mode adjustments for globe */ +@media (prefers-color-scheme: dark) { + :root { + --globe-hue: 220deg; + --globe-base-bg-sat: 25%; + --globe-base-bg-lum: 15%; + --globe-base-fg-sat: 60%; + --globe-base-fg-lum: 85%; + --globe-filter-fg: saturate(120%) brightness(110%); + --globe-module-bg-sat: 22%; + --globe-module-bg-lum: 30%; + } +} + +/* Globe Container - fits inside the gray content box */ +.globe-container { + display: block; + width: 100%; + height: 200px; + position: relative; + overflow: hidden; +} + +.world { + fill: rgba(107, 114, 128, 0.1); /* Gray color with reduced opacity */ + width: 40em; + height: 40em; + position: absolute; + left: 50%; + top: 0%; + transform: translateX(-50%); + border-radius: 50%; + overflow: hidden; + white-space: nowrap; + border: 2px solid rgba(156, 163, 175, 0.2); /* Light gray border with reduced opacity */ + box-sizing: border-box; + background-image: url(#icon-world); + filter: var(--globe-filter-fg); +} + +/* Dark mode globe styling */ +@media (prefers-color-scheme: dark) { + .world { + fill: rgba(156, 163, 175, 0.2); + border-color: rgba(156, 163, 175, 0.4); + } +} + +.world svg { + width: 160em; + height: 40em; + margin-top: calc(-2px + -0.05em); + display: inline; + animation: world-scroll 8s linear infinite; +} + +@keyframes world-scroll { + from { margin-left: -110em; } + to { margin-left: -40em; } +} diff --git a/crates/cdk-ldk-node/static/favicon.ico b/crates/cdk-ldk-node/static/favicon.ico new file mode 100644 index 000000000..cb9704767 Binary files /dev/null and b/crates/cdk-ldk-node/static/favicon.ico differ diff --git a/crates/cdk-ldk-node/static/favicon.svg b/crates/cdk-ldk-node/static/favicon.svg new file mode 100644 index 000000000..be884b033 --- /dev/null +++ b/crates/cdk-ldk-node/static/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/crates/cdk-ldk-node/static/images/bg-dark.jpg b/crates/cdk-ldk-node/static/images/bg-dark.jpg new file mode 100644 index 000000000..6fa6745ac Binary files /dev/null and b/crates/cdk-ldk-node/static/images/bg-dark.jpg differ diff --git a/crates/cdk-ldk-node/static/images/bg.jpg b/crates/cdk-ldk-node/static/images/bg.jpg new file mode 100644 index 000000000..e8e5a502a Binary files /dev/null and b/crates/cdk-ldk-node/static/images/bg.jpg differ diff --git a/crates/cdk-ldk-node/static/images/nut.png b/crates/cdk-ldk-node/static/images/nut.png new file mode 100644 index 000000000..70c32d3e6 Binary files /dev/null and b/crates/cdk-ldk-node/static/images/nut.png differ diff --git a/crates/cdk-lnbits/Cargo.toml b/crates/cdk-lnbits/Cargo.toml index bbb0e7863..9b0050bcf 100644 --- a/crates/cdk-lnbits/Cargo.toml +++ b/crates/cdk-lnbits/Cargo.toml @@ -13,7 +13,6 @@ readme = "README.md" [dependencies] async-trait.workspace = true anyhow.workspace = true -axum.workspace = true bitcoin.workspace = true cdk-common = { workspace = true, features = ["mint"] } futures.workspace = true @@ -21,5 +20,6 @@ tokio.workspace = true tokio-util.workspace = true tracing.workspace = true thiserror.workspace = true -lnbits-rs = "0.6.0" +lnbits-rs = "0.9.1" serde_json.workspace = true +rustls.workspace = true diff --git a/crates/cdk-lnbits/README.md b/crates/cdk-lnbits/README.md index 2e74d8d70..b573a8532 100644 --- a/crates/cdk-lnbits/README.md +++ b/crates/cdk-lnbits/README.md @@ -8,6 +8,8 @@ LNBits backend implementation for the Cashu Development Kit (CDK). This provides integration with [LNBits](https://lnbits.com/) for Lightning Network functionality. +**Note: Only LNBits v1 API is supported.** This backend uses the websocket-based v1 API for real-time payment notifications. + ## Installation Add this to your `Cargo.toml`: @@ -17,6 +19,51 @@ Add this to your `Cargo.toml`: cdk-lnbits = "*" ``` +## Configuration for cdk-mintd + +### Config File + +```toml +[ln] +ln_backend = "lnbits" + +[lnbits] +admin_api_key = "your-admin-api-key" +invoice_api_key = "your-invoice-api-key" +lnbits_api = "https://your-lnbits-instance.com/api/v1" +fee_percent = 0.02 # Optional, defaults to 2% +reserve_fee_min = 2 # Optional, defaults to 2 sats +``` + +### Environment Variables + +All configuration can be set via environment variables: + +| Variable | Description | Required | +|----------|-------------|----------| +| `CDK_MINTD_LN_BACKEND` | Set to `lnbits` | Yes | +| `CDK_MINTD_LNBITS_ADMIN_API_KEY` | LNBits admin API key | Yes | +| `CDK_MINTD_LNBITS_INVOICE_API_KEY` | LNBits invoice API key | Yes | +| `CDK_MINTD_LNBITS_LNBITS_API` | LNBits API URL | Yes | +| `CDK_MINTD_LNBITS_FEE_PERCENT` | Fee percentage (default: `0.02`) | No | +| `CDK_MINTD_LNBITS_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No | + +### Example + +```bash +export CDK_MINTD_LN_BACKEND=lnbits +export CDK_MINTD_LNBITS_ADMIN_API_KEY=your-admin-api-key +export CDK_MINTD_LNBITS_INVOICE_API_KEY=your-invoice-api-key +export CDK_MINTD_LNBITS_LNBITS_API=https://your-lnbits-instance.com/api/v1 +cdk-mintd +``` + +### Getting API Keys + +1. Log in to your LNBits instance +2. Go to your wallet +3. Click on "API Info" to find your admin and invoice API keys + ## License This project is licensed under the [MIT License](../../LICENSE). \ No newline at end of file diff --git a/crates/cdk-lnbits/src/error.rs b/crates/cdk-lnbits/src/error.rs index d22d69dcd..83cc6336b 100644 --- a/crates/cdk-lnbits/src/error.rs +++ b/crates/cdk-lnbits/src/error.rs @@ -14,6 +14,9 @@ pub enum Error { /// Amount overflow #[error("Amount overflow")] AmountOverflow, + /// Invalid payment hash + #[error("Invalid payment hash")] + InvalidPaymentHash, /// Anyhow error #[error(transparent)] Anyhow(#[from] anyhow::Error), diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs index 6a14395f9..ec5a9c60e 100644 --- a/crates/cdk-lnbits/src/lib.rs +++ b/crates/cdk-lnbits/src/lib.rs @@ -6,22 +6,21 @@ use std::cmp::max; use std::pin::Pin; -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use anyhow::anyhow; use async_trait::async_trait; -use axum::Router; use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT}; use cdk_common::common::FeeReserve; -use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState}; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ - self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment, - PaymentQuoteResponse, + self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, }; -use cdk_common::util::unix_time; -use cdk_common::{mint, Bolt11Invoice}; +use cdk_common::util::{hex, unix_time}; +use cdk_common::Bolt11Invoice; use error::Error; use futures::Stream; use lnbits_rs::api::invoice::CreateInvoiceRequest; @@ -36,7 +35,6 @@ pub mod error; pub struct LNbits { lnbits_api: LNBitsClient, fee_reserve: FeeReserve, - webhook_url: Option, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, settings: Bolt11Settings, @@ -50,14 +48,12 @@ impl LNbits { invoice_api_key: String, api_url: String, fee_reserve: FeeReserve, - webhook_url: Option, ) -> Result { let lnbits_api = LNBitsClient::new("", &admin_api_key, &invoice_api_key, &api_url, None)?; Ok(Self { lnbits_api, fee_reserve, - webhook_url, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), settings: Bolt11Settings { @@ -65,12 +61,16 @@ impl LNbits { unit: CurrencyUnit::Sat, invoice_description: true, amountless: false, + bolt12: false, }, }) } /// Subscribe to lnbits ws pub async fn subscribe_ws(&self) -> Result<(), Error> { + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } self.lnbits_api .subscribe_to_websocket() .await @@ -79,6 +79,64 @@ impl LNbits { Error::Anyhow(err) }) } + + /// Process an incoming message from the websocket receiver + async fn process_message( + msg_option: Option, + api: &LNBitsClient, + _is_active: &Arc, + ) -> Option { + let msg = msg_option?; + + let payment = match api.get_payment_info(&msg).await { + Ok(payment) => payment, + Err(_) => return None, + }; + + if !payment.paid { + tracing::warn!( + "Received payment notification but payment not paid for {}", + msg + ); + return None; + } + + Self::create_payment_response(&msg, &payment).unwrap_or_else(|e| { + tracing::error!("Failed to create payment response: {}", e); + None + }) + } + + /// Create a payment response from payment info + fn create_payment_response( + msg: &str, + payment: &lnbits_rs::api::payment::Payment, + ) -> Result, Error> { + let amount = payment.details.amount; + + if amount == i64::MIN { + return Ok(None); + } + + let hash = Self::decode_payment_hash(msg)?; + + Ok(Some(WaitPaymentResponse { + payment_identifier: PaymentIdentifier::PaymentHash(hash), + payment_amount: Amount::from(amount.unsigned_abs()), + unit: CurrencyUnit::Msat, + payment_id: msg.to_string(), + })) + } + + /// Decode a hex payment hash string into a byte array + fn decode_payment_hash(hash_str: &str) -> Result<[u8; 32], Error> { + let decoded = hex::decode(hash_str) + .map_err(|e| Error::Anyhow(anyhow!("Failed to decode payment hash: {}", e)))?; + + decoded + .try_into() + .map_err(|_| Error::Anyhow(anyhow!("Invalid payment hash length"))) + } } #[async_trait] @@ -97,48 +155,64 @@ impl MintPayment for LNbits { self.wait_invoice_cancel_token.cancel() } - async fn wait_any_incoming_payment( + async fn wait_payment_event( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let api = self.lnbits_api.clone(); let cancel_token = self.wait_invoice_cancel_token.clone(); 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() => { - // Stream is cancelled - is_active.store(false, Ordering::SeqCst); - tracing::info!("Waiting for lnbits invoice ending"); - None - } - msg_option = receiver.recv() => { - match msg_option { - Some(msg) => { - let check = api.is_invoice_paid(&msg).await; - - match check { - Ok(state) => { - if state { - Some((msg, (api, cancel_token, is_active))) - } else { - Some(("".to_string(), (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"); } - _ => Some(("".to_string(), (api, cancel_token, is_active))), + + retry_count += 1; + // Continue the loop to try again + continue; } } - None => { - is_active.store(false, Ordering::SeqCst); - None - }, } } } @@ -148,151 +222,168 @@ impl MintPayment for LNbits { async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result { - if unit != &CurrencyUnit::Sat { - return Err(Self::Err::Anyhow(anyhow!("Unsupported unit"))); - } - - let bolt11 = Bolt11Invoice::from_str(request)?; - - let amount_msat = match options { - Some(amount) => { - if matches!(amount, MeltOptions::Mpp { mpp: _ }) { - return Err(payment::Error::UnsupportedPaymentOption); - } - amount.amount_msat() + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let amount_msat = match bolt11_options.melt_options { + Some(amount) => { + if matches!(amount, MeltOptions::Mpp { mpp: _ }) { + return Err(payment::Error::UnsupportedPaymentOption); + } + amount.amount_msat() + } + None => bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + .into(), + }; + + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount_msat) as f32) as u64; + + let absolute_fee_reserve: u64 = + u64::from(self.fee_reserve.min_fee_reserve) * MSAT_IN_SAT; + + let fee = max(relative_fee_reserve, absolute_fee_reserve); + + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::PaymentHash( + *bolt11_options.bolt11.payment_hash().as_ref(), + )), + amount: to_unit(amount_msat, &CurrencyUnit::Msat, unit)?, + fee: to_unit(fee, &CurrencyUnit::Msat, unit)?, + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) } - None => bolt11 - .amount_milli_satoshis() - .ok_or(Error::UnknownInvoiceAmount)? - .into(), - }; - - let amount = amount_msat / MSAT_IN_SAT.into(); - - let relative_fee_reserve = - (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; - - let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); - - let fee = max(relative_fee_reserve, absolute_fee_reserve); - - Ok(PaymentQuoteResponse { - request_lookup_id: bolt11.payment_hash().to_string(), - amount, - unit: unit.clone(), - fee: fee.into(), - state: MeltQuoteState::Unpaid, - }) + OutgoingPaymentOptions::Bolt12(_bolt12_options) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits"))) + } + } } async fn make_payment( &self, - melt_quote: mint::MeltQuote, - _partial_msats: Option, - _max_fee_msats: Option, + _unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result { - let pay_response = self - .lnbits_api - .pay_invoice(&melt_quote.request, None) - .await - .map_err(|err| { - tracing::error!("Could not pay invoice"); - tracing::error!("{}", err.to_string()); - Self::Err::Anyhow(anyhow!("Could not pay invoice")) - })?; - - let invoice_info = self - .lnbits_api - .get_payment_info(&pay_response.payment_hash) - .await - .map_err(|err| { - tracing::error!("Could not find invoice"); - tracing::error!("{}", err.to_string()); - Self::Err::Anyhow(anyhow!("Could not find invoice")) - })?; - - let status = match invoice_info.paid { - true => MeltQuoteState::Paid, - false => MeltQuoteState::Unpaid, - }; - - let total_spent = Amount::from( - (invoice_info - .details - .amount - .checked_add(invoice_info.details.fee) - .ok_or(Error::AmountOverflow)?) - .unsigned_abs(), - ); - - Ok(MakePaymentResponse { - payment_lookup_id: pay_response.payment_hash, - payment_proof: invoice_info.details.preimage, - status, - total_spent, - unit: CurrencyUnit::Sat, - }) + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let pay_response = self + .lnbits_api + .pay_invoice(&bolt11_options.bolt11.to_string(), None) + .await + .map_err(|err| { + tracing::error!("Could not pay invoice"); + tracing::error!("{}", err.to_string()); + Self::Err::Anyhow(anyhow!("Could not pay invoice")) + })?; + + let invoice_info = self + .lnbits_api + .get_payment_info(&pay_response.payment_hash) + .await + .map_err(|err| { + tracing::error!("Could not find invoice"); + tracing::error!("{}", err.to_string()); + Self::Err::Anyhow(anyhow!("Could not find invoice")) + })?; + + let status = if invoice_info.paid { + MeltQuoteState::Paid + } else { + MeltQuoteState::Unpaid + }; + + let total_spent = Amount::from( + (invoice_info + .details + .amount + .checked_add(invoice_info.details.fee) + .ok_or(Error::AmountOverflow)?) + .unsigned_abs(), + ); + + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentHash( + hex::decode(pay_response.payment_hash) + .map_err(|_| Error::InvalidPaymentHash)? + .try_into() + .map_err(|_| Error::InvalidPaymentHash)?, + ), + payment_proof: Some(invoice_info.details.payment_hash), + status, + total_spent, + unit: CurrencyUnit::Msat, + }) + } + OutgoingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits"))) + } + } } async fn create_incoming_payment_request( &self, - amount: Amount, unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + options: IncomingPaymentOptions, ) -> Result { - if unit != &CurrencyUnit::Sat { - return Err(Self::Err::Anyhow(anyhow!("Unsupported unit"))); + match options { + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let description = bolt11_options.description.unwrap_or_default(); + let amount = bolt11_options.amount; + let unix_expiry = bolt11_options.unix_expiry; + + let time_now = unix_time(); + let expiry = unix_expiry.map(|t| t - time_now); + + let invoice_request = CreateInvoiceRequest { + amount: to_unit(amount, unit, &CurrencyUnit::Sat)?.into(), + memo: Some(description), + unit: unit.to_string(), + expiry, + internal: None, + out: false, + }; + + let create_invoice_response = self + .lnbits_api + .create_invoice(&invoice_request) + .await + .map_err(|err| { + tracing::error!("Could not create invoice"); + tracing::error!("{}", err.to_string()); + Self::Err::Anyhow(anyhow!("Could not create invoice")) + })?; + + let request: Bolt11Invoice = create_invoice_response.bolt11().parse()?; + + let expiry = request.expires_at().map(|t| t.as_secs()); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::PaymentHash( + *request.payment_hash().as_ref(), + ), + request: request.to_string(), + expiry, + }) + } + IncomingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits"))) + } } - - let time_now = unix_time(); - - let expiry = unix_expiry.map(|t| t - time_now); - - let invoice_request = CreateInvoiceRequest { - amount: to_unit(amount, unit, &CurrencyUnit::Sat)?.into(), - memo: Some(description), - unit: unit.to_string(), - expiry, - webhook: self.webhook_url.clone(), - internal: None, - out: false, - }; - - let create_invoice_response = self - .lnbits_api - .create_invoice(&invoice_request) - .await - .map_err(|err| { - tracing::error!("Could not create invoice"); - tracing::error!("{}", err.to_string()); - Self::Err::Anyhow(anyhow!("Could not create invoice")) - })?; - - let request: Bolt11Invoice = create_invoice_response - .bolt11() - .ok_or_else(|| Self::Err::Anyhow(anyhow!("Missing bolt11 invoice")))? - .parse()?; - let expiry = request.expires_at().map(|t| t.as_secs()); - - Ok(CreateIncomingPaymentResponse { - request_lookup_id: create_invoice_response.payment_hash().to_string(), - request: request.to_string(), - expiry, - }) } async fn check_incoming_payment_status( &self, - payment_hash: &str, - ) -> Result { - let paid = self + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let payment = self .lnbits_api - .is_invoice_paid(payment_hash) + .get_payment_info(&payment_identifier.to_string()) .await .map_err(|err| { tracing::error!("Could not check invoice status"); @@ -300,21 +391,30 @@ impl MintPayment for LNbits { Self::Err::Anyhow(anyhow!("Could not check invoice status")) })?; - let state = match paid { - true => MintQuoteState::Paid, - false => MintQuoteState::Unpaid, - }; + let amount = payment.details.amount; + + if amount == i64::MIN { + return Err(Error::AmountOverflow.into()); + } - Ok(state) + match payment.paid { + true => Ok(vec![WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: Amount::from(amount.unsigned_abs()), + unit: CurrencyUnit::Msat, + payment_id: payment.details.payment_hash, + }]), + false => Ok(vec![]), + } } async fn check_outgoing_payment( &self, - payment_hash: &str, + payment_identifier: &PaymentIdentifier, ) -> Result { let payment = self .lnbits_api - .get_payment_info(payment_hash) + .get_payment_info(&payment_identifier.to_string()) .await .map_err(|err| { tracing::error!("Could not check invoice status"); @@ -323,25 +423,20 @@ impl MintPayment for LNbits { })?; let pay_response = MakePaymentResponse { - payment_lookup_id: payment.details.payment_hash, + payment_lookup_id: payment_identifier.clone(), payment_proof: payment.preimage, - status: lnbits_to_melt_status(&payment.details.status, payment.details.pending), + status: lnbits_to_melt_status(&payment.details.status), total_spent: Amount::from( - payment.details.amount.unsigned_abs() - + payment.details.fee.unsigned_abs() / MSAT_IN_SAT, + payment.details.amount.unsigned_abs() + payment.details.fee.unsigned_abs(), ), - unit: self.settings.unit.clone(), + unit: CurrencyUnit::Msat, }; Ok(pay_response) } } -fn lnbits_to_melt_status(status: &str, pending: Option) -> MeltQuoteState { - if pending.unwrap_or_default() { - return MeltQuoteState::Pending; - } - +fn lnbits_to_melt_status(status: &str) -> MeltQuoteState { match status { "success" => MeltQuoteState::Paid, "failed" => MeltQuoteState::Unpaid, @@ -349,15 +444,3 @@ fn lnbits_to_melt_status(status: &str, pending: Option) -> MeltQuoteState _ => MeltQuoteState::Unknown, } } - -impl LNbits { - /// Create invoice webhook - pub async fn create_invoice_webhook_router( - &self, - webhook_endpoint: &str, - ) -> anyhow::Result { - self.lnbits_api - .create_invoice_webhook_router(webhook_endpoint) - .await - } -} diff --git a/crates/cdk-lnd/README.md b/crates/cdk-lnd/README.md index bdc2fb60e..562bb9ea8 100644 --- a/crates/cdk-lnd/README.md +++ b/crates/cdk-lnd/README.md @@ -17,6 +17,44 @@ Add this to your `Cargo.toml`: cdk-lnd = "*" ``` +## Configuration for cdk-mintd + +### Config File + +```toml +[ln] +ln_backend = "lnd" + +[lnd] +address = "https://localhost:10009" +cert_file = "/path/to/.lnd/tls.cert" +macaroon_file = "/path/to/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" +fee_percent = 0.02 # Optional, defaults to 2% +reserve_fee_min = 2 # Optional, defaults to 2 sats +``` + +### Environment Variables + +All configuration can be set via environment variables: + +| Variable | Description | Required | +|----------|-------------|----------| +| `CDK_MINTD_LN_BACKEND` | Set to `lnd` | Yes | +| `CDK_MINTD_LND_ADDRESS` | LND gRPC address (e.g., `https://localhost:10009`) | Yes | +| `CDK_MINTD_LND_CERT_FILE` | Path to LND TLS certificate | Yes | +| `CDK_MINTD_LND_MACAROON_FILE` | Path to LND macaroon file | Yes | +| `CDK_MINTD_LND_FEE_PERCENT` | Fee percentage (default: `0.02`) | No | +| `CDK_MINTD_LND_RESERVE_FEE_MIN` | Minimum fee in sats (default: `2`) | No | + +### Example + +```bash +export CDK_MINTD_LN_BACKEND=lnd +export CDK_MINTD_LND_ADDRESS=https://127.0.0.1:10009 +export CDK_MINTD_LND_CERT_FILE=/home/user/.lnd/tls.cert +export CDK_MINTD_LND_MACAROON_FILE=/home/user/.lnd/data/chain/bitcoin/mainnet/admin.macaroon +cdk-mintd +``` ## Minimum Supported Rust Version (MSRV) diff --git a/crates/cdk-lnd/src/error.rs b/crates/cdk-lnd/src/error.rs index 5d1b10f0d..ba546f536 100644 --- a/crates/cdk-lnd/src/error.rs +++ b/crates/cdk-lnd/src/error.rs @@ -39,6 +39,9 @@ pub enum Error { /// Could not read file #[error("Could not read file")] ReadFile, + /// Database Error + #[error("Database error: {0}")] + Database(String), } impl From for cdk_common::payment::Error { diff --git a/crates/cdk-lnd/src/lib.rs b/crates/cdk-lnd/src/lib.rs index c923d5543..4066508d0 100644 --- a/crates/cdk-lnd/src/lib.rs +++ b/crates/cdk-lnd/src/lib.rs @@ -18,13 +18,15 @@ use async_trait::async_trait; use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT}; use cdk_common::bitcoin::hashes::Hash; use cdk_common::common::FeeReserve; -use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState}; +use cdk_common::database::mint::DynMintKVStore; +use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ - self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment, - PaymentQuoteResponse, + self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, }; use cdk_common::util::hex; -use cdk_common::{mint, Bolt11Invoice}; +use cdk_common::Bolt11Invoice; use error::Error; use futures::{Stream, StreamExt}; use lnrpc::fee_limit::Limit; @@ -39,6 +41,14 @@ pub mod error; mod proto; pub(crate) use proto::{lnrpc, routerrpc}; +use crate::lnrpc::invoice::InvoiceState; + +/// LND KV Store constants +const LND_KV_PRIMARY_NAMESPACE: &str = "cdk_lnd_lightning_backend"; +const LND_KV_SECONDARY_NAMESPACE: &str = "payment_indices"; +const LAST_ADD_INDEX_KV_KEY: &str = "last_add_index"; +const LAST_SETTLE_INDEX_KV_KEY: &str = "last_settle_index"; + /// Lnd mint backend #[derive(Clone)] pub struct Lnd { @@ -47,6 +57,7 @@ pub struct Lnd { _macaroon_file: PathBuf, lnd_client: client::Client, fee_reserve: FeeReserve, + kv_store: DynMintKVStore, wait_invoice_cancel_token: CancellationToken, wait_invoice_is_active: Arc, settings: Bolt11Settings, @@ -62,6 +73,7 @@ impl Lnd { cert_file: PathBuf, macaroon_file: PathBuf, fee_reserve: FeeReserve, + kv_store: DynMintKVStore, ) -> Result { // Validate address is not empty if address.is_empty() { @@ -101,6 +113,7 @@ impl Lnd { _macaroon_file: macaroon_file, lnd_client, fee_reserve, + kv_store, wait_invoice_cancel_token: CancellationToken::new(), wait_invoice_is_active: Arc::new(AtomicBool::new(false)), settings: Bolt11Settings { @@ -108,9 +121,59 @@ impl Lnd { unit: CurrencyUnit::Msat, invoice_description: true, amountless: true, + bolt12: false, }, }) } + + /// Get last add and settle indices from KV store + #[instrument(skip_all)] + async fn get_last_indices(&self) -> Result<(Option, Option), Error> { + let add_index = if let Some(stored_index) = self + .kv_store + .kv_read( + LND_KV_PRIMARY_NAMESPACE, + LND_KV_SECONDARY_NAMESPACE, + LAST_ADD_INDEX_KV_KEY, + ) + .await + .map_err(|e| Error::Database(e.to_string()))? + { + if let Ok(index_str) = std::str::from_utf8(stored_index.as_slice()) { + index_str.parse::().ok() + } else { + None + } + } else { + None + }; + + let settle_index = if let Some(stored_index) = self + .kv_store + .kv_read( + LND_KV_PRIMARY_NAMESPACE, + LND_KV_SECONDARY_NAMESPACE, + LAST_SETTLE_INDEX_KV_KEY, + ) + .await + .map_err(|e| Error::Database(e.to_string()))? + { + if let Ok(index_str) = std::str::from_utf8(stored_index.as_slice()) { + index_str.parse::().ok() + } else { + None + } + } else { + None + }; + + tracing::debug!( + "LND: Retrieved last indices from KV store - add_index: {:?}, settle_index: {:?}", + add_index, + settle_index + ); + Ok((add_index, settle_index)) + } } #[async_trait] @@ -133,16 +196,26 @@ impl MintPayment for Lnd { } #[instrument(skip_all)] - async fn wait_any_incoming_payment( + async fn wait_payment_event( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { let mut lnd_client = self.lnd_client.clone(); + // Get last indices from KV store + let (last_add_index, last_settle_index) = + self.get_last_indices().await.unwrap_or((None, None)); + let stream_req = lnrpc::InvoiceSubscription { - add_index: 0, - settle_index: 0, + add_index: last_add_index.unwrap_or(0), + settle_index: last_settle_index.unwrap_or(0), }; + tracing::debug!( + "LND: Starting invoice subscription with add_index: {}, settle_index: {}", + stream_req.add_index, + stream_req.settle_index + ); + let stream = lnd_client .lightning() .subscribe_invoices(stream_req) @@ -154,312 +227,416 @@ impl MintPayment for Lnd { .into_inner(); let cancel_token = self.wait_invoice_cancel_token.clone(); + let kv_store = self.kv_store.clone(); - Ok(futures::stream::unfold( + let event_stream = futures::stream::unfold( ( stream, cancel_token, Arc::clone(&self.wait_invoice_is_active), + kv_store, + last_add_index.unwrap_or(0), + last_settle_index.unwrap_or(0), ), - |(mut stream, cancel_token, is_active)| async move { + |( + mut stream, + cancel_token, + is_active, + kv_store, + mut current_add_index, + mut current_settle_index, + )| async move { is_active.store(true, Ordering::SeqCst); - tokio::select! { - _ = cancel_token.cancelled() => { - // Stream is cancelled - is_active.store(false, Ordering::SeqCst); - tracing::info!("Waiting for lnd invoice ending"); - None - - } - msg = stream.message() => { - - match msg { - Ok(Some(msg)) => { - if msg.state == 1 { - Some((hex::encode(msg.r_hash), (stream, cancel_token, is_active))) - } else { - None + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + // Stream is cancelled + is_active.store(false, Ordering::SeqCst); + tracing::info!("Waiting for lnd invoice ending"); + return None; + } + msg = stream.message() => { + match msg { + Ok(Some(msg)) => { + // Update indices based on the message + current_add_index = current_add_index.max(msg.add_index); + current_settle_index = current_settle_index.max(msg.settle_index); + + // Store the updated indices in KV store regardless of settlement status + let add_index_str = current_add_index.to_string(); + let settle_index_str = current_settle_index.to_string(); + + if let Ok(mut tx) = kv_store.begin_transaction().await { + let mut has_error = false; + + if let Err(e) = tx.kv_write(LND_KV_PRIMARY_NAMESPACE, LND_KV_SECONDARY_NAMESPACE, LAST_ADD_INDEX_KV_KEY, add_index_str.as_bytes()).await { + tracing::warn!("LND: Failed to write add_index {} to KV store: {}", current_add_index, e); + has_error = true; + } + + if let Err(e) = tx.kv_write(LND_KV_PRIMARY_NAMESPACE, LND_KV_SECONDARY_NAMESPACE, LAST_SETTLE_INDEX_KV_KEY, settle_index_str.as_bytes()).await { + tracing::warn!("LND: Failed to write settle_index {} to KV store: {}", current_settle_index, e); + has_error = true; + } + + if !has_error { + if let Err(e) = tx.commit().await { + tracing::warn!("LND: Failed to commit indices to KV store: {}", e); + } else { + tracing::debug!("LND: Stored updated indices - add_index: {}, settle_index: {}", current_add_index, current_settle_index); + } + } + } else { + tracing::warn!("LND: Failed to begin KV transaction for storing indices"); + } + + // Only emit event for settled invoices + if msg.state() == InvoiceState::Settled { + let hash_slice: Result<[u8;32], _> = msg.r_hash.try_into(); + + if let Ok(hash_slice) = hash_slice { + let hash = hex::encode(hash_slice); + + tracing::info!("LND: Payment for {} with amount {} msat", hash, msg.amt_paid_msat); + + let wait_response = WaitPaymentResponse { + payment_identifier: PaymentIdentifier::PaymentHash(hash_slice), + payment_amount: Amount::from(msg.amt_paid_msat as u64), + unit: CurrencyUnit::Msat, + payment_id: hash, + }; + let event = Event::PaymentReceived(wait_response); + return Some((event, (stream, cancel_token, is_active, kv_store, current_add_index, current_settle_index))); + } else { + // Invalid hash, skip this message but continue streaming + tracing::error!("LND returned invalid payment hash"); + // Continue the loop without yielding + continue; + } + } else { + // Not a settled invoice, continue but don't emit event + tracing::debug!("LND: Received non-settled invoice, continuing to wait for settled invoices"); + // Continue the loop without yielding + continue; + } + } + Ok(None) => { + is_active.store(false, Ordering::SeqCst); + tracing::info!("LND invoice stream ended."); + return None; + } + Err(err) => { + is_active.store(false, Ordering::SeqCst); + tracing::warn!("Encountered error in LND invoice stream. Stream ending"); + tracing::error!("{:?}", err); + return None; + } + } } - } - Ok(None) => { - is_active.store(false, Ordering::SeqCst); - tracing::info!("LND invoice stream ended."); - None - }, // End of stream - Err(err) => { - is_active.store(false, Ordering::SeqCst); - tracing::warn!("Encountered error in LND invoice stream. Stream ending"); - tracing::error!("{:?}", err); - None - - }, // Handle errors gracefully, ends the stream on error - } } } }, - ) - .boxed()) + ); + + Ok(Box::pin(event_stream)) } #[instrument(skip_all)] async fn get_payment_quote( &self, - request: &str, unit: &CurrencyUnit, - options: Option, + options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = Bolt11Invoice::from_str(request)?; - - let amount_msat = match options { - Some(amount) => amount.amount_msat(), - None => bolt11 - .amount_milli_satoshis() - .ok_or(Error::UnknownInvoiceAmount)? - .into(), - }; + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let amount_msat = match bolt11_options.melt_options { + Some(amount) => amount.amount_msat(), + None => bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)? + .into(), + }; - let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; + let amount = to_unit(amount_msat, &CurrencyUnit::Msat, unit)?; - let relative_fee_reserve = - (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; + let relative_fee_reserve = + (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; - let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); + let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); - let fee = max(relative_fee_reserve, absolute_fee_reserve); + let fee = max(relative_fee_reserve, absolute_fee_reserve); - Ok(PaymentQuoteResponse { - request_lookup_id: bolt11.payment_hash().to_string(), - amount, - unit: unit.clone(), - fee: fee.into(), - state: MeltQuoteState::Unpaid, - }) + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::PaymentHash( + *bolt11_options.bolt11.payment_hash().as_ref(), + )), + amount, + fee: fee.into(), + state: MeltQuoteState::Unpaid, + unit: unit.clone(), + }) + } + OutgoingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND"))) + } + } } #[instrument(skip_all)] async fn make_payment( &self, - melt_quote: mint::MeltQuote, - partial_amount: Option, - max_fee: Option, + _unit: &CurrencyUnit, + options: OutgoingPaymentOptions, ) -> Result { - let payment_request = melt_quote.request; - let bolt11 = Bolt11Invoice::from_str(&payment_request)?; - - let pay_state = self - .check_outgoing_payment(&bolt11.payment_hash().to_string()) - .await?; - - match pay_state.status { - MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => (), - MeltQuoteState::Paid => { - tracing::debug!("Melt attempted on invoice already paid"); - return Err(Self::Err::InvoiceAlreadyPaid); - } - MeltQuoteState::Pending => { - tracing::debug!("Melt attempted on invoice already pending"); - return Err(Self::Err::InvoicePaymentPending); - } - } - - let bolt11 = Bolt11Invoice::from_str(&payment_request)?; - let amount_msat: u64 = match bolt11.amount_milli_satoshis() { - Some(amount_msat) => amount_msat, - None => melt_quote - .msat_to_pay - .ok_or(Error::UnknownInvoiceAmount)? - .into(), - }; - - // Detect partial payments - match partial_amount { - Some(part_amt) => { - let partial_amount_msat = to_unit(part_amt, &melt_quote.unit, &CurrencyUnit::Msat)?; - let invoice = Bolt11Invoice::from_str(&payment_request)?; - - // Extract information from invoice - let pub_key = invoice.get_payee_pub_key(); - let payer_addr = invoice.payment_secret().0.to_vec(); - let payment_hash = invoice.payment_hash(); - - let mut lnd_client = self.lnd_client.clone(); - - for attempt in 0..Self::MAX_ROUTE_RETRIES { - // Create a request for the routes - let route_req = lnrpc::QueryRoutesRequest { - pub_key: hex::encode(pub_key.serialize()), - amt_msat: u64::from(partial_amount_msat) as i64, - fee_limit: max_fee.map(|f| { - let limit = Limit::Fixed(u64::from(f) as i64); - FeeLimit { limit: Some(limit) } - }), - use_mission_control: true, - ..Default::default() - }; - - // Query the routes - let mut routes_response = lnd_client - .lightning() - .query_routes(route_req) - .await - .map_err(Error::LndError)? - .into_inner(); - - // update its MPP record, - // attempt it and check the result - let last_hop: &mut Hop = routes_response.routes[0] - .hops - .last_mut() - .ok_or(Error::MissingLastHop)?; - let mpp_record = MppRecord { - payment_addr: payer_addr.clone(), - total_amt_msat: amount_msat as i64, - }; - last_hop.mpp_record = Some(mpp_record); + match options { + OutgoingPaymentOptions::Bolt11(bolt11_options) => { + let bolt11 = bolt11_options.bolt11; + + let pay_state = self + .check_outgoing_payment(&PaymentIdentifier::PaymentHash( + *bolt11.payment_hash().as_ref(), + )) + .await?; + + match pay_state.status { + MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => (), + MeltQuoteState::Paid => { + tracing::debug!("Melt attempted on invoice already paid"); + return Err(Self::Err::InvoiceAlreadyPaid); + } + MeltQuoteState::Pending => { + tracing::debug!("Melt attempted on invoice already pending"); + return Err(Self::Err::InvoicePaymentPending); + } + } - let payment_response = lnd_client - .router() - .send_to_route_v2(routerrpc::SendToRouteRequest { - payment_hash: payment_hash.to_byte_array().to_vec(), - route: Some(routes_response.routes[0].clone()), - ..Default::default() - }) - .await - .map_err(Error::LndError)? - .into_inner(); - - if let Some(failure) = payment_response.failure { - if failure.code == 15 { - tracing::debug!( - "Attempt number {}: route has failed. Re-querying...", - attempt + 1 - ); - continue; + // Detect partial payments + match bolt11_options.melt_options { + Some(MeltOptions::Mpp { mpp }) => { + let amount_msat: u64 = bolt11 + .amount_milli_satoshis() + .ok_or(Error::UnknownInvoiceAmount)?; + { + let partial_amount_msat = mpp.amount; + let invoice = bolt11; + let max_fee: Option = bolt11_options.max_fee_amount; + + // Extract information from invoice + let pub_key = invoice.get_payee_pub_key(); + let payer_addr = invoice.payment_secret().0.to_vec(); + let payment_hash = invoice.payment_hash(); + + let mut lnd_client = self.lnd_client.clone(); + + for attempt in 0..Self::MAX_ROUTE_RETRIES { + // Create a request for the routes + let route_req = lnrpc::QueryRoutesRequest { + pub_key: hex::encode(pub_key.serialize()), + amt_msat: u64::from(partial_amount_msat) as i64, + fee_limit: max_fee.map(|f| { + let limit = Limit::Fixed(u64::from(f) as i64); + FeeLimit { limit: Some(limit) } + }), + use_mission_control: true, + ..Default::default() + }; + + // Query the routes + let mut routes_response = lnd_client + .lightning() + .query_routes(route_req) + .await + .map_err(Error::LndError)? + .into_inner(); + + // update its MPP record, + // attempt it and check the result + let last_hop: &mut Hop = routes_response.routes[0] + .hops + .last_mut() + .ok_or(Error::MissingLastHop)?; + let mpp_record = MppRecord { + payment_addr: payer_addr.clone(), + total_amt_msat: amount_msat as i64, + }; + last_hop.mpp_record = Some(mpp_record); + + let payment_response = lnd_client + .router() + .send_to_route_v2(routerrpc::SendToRouteRequest { + payment_hash: payment_hash.to_byte_array().to_vec(), + route: Some(routes_response.routes[0].clone()), + ..Default::default() + }) + .await + .map_err(Error::LndError)? + .into_inner(); + + if let Some(failure) = payment_response.failure { + if failure.code == 15 { + tracing::debug!( + "Attempt number {}: route has failed. Re-querying...", + attempt + 1 + ); + continue; + } + } + + // Get status and maybe the preimage + let (status, payment_preimage) = match payment_response.status { + 0 => (MeltQuoteState::Pending, None), + 1 => ( + MeltQuoteState::Paid, + Some(hex::encode(payment_response.preimage)), + ), + 2 => (MeltQuoteState::Unpaid, None), + _ => (MeltQuoteState::Unknown, None), + }; + + // Get the actual amount paid in sats + let mut total_amt: u64 = 0; + if let Some(route) = payment_response.route { + total_amt = (route.total_amt_msat / 1000) as u64; + } + + return Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentHash( + payment_hash.to_byte_array(), + ), + payment_proof: payment_preimage, + status, + total_spent: total_amt.into(), + unit: CurrencyUnit::Sat, + }); + } + + // "We have exhausted all tactical options" -- STEM, Upgrade (2018) + // The payment was not possible within 50 retries. + tracing::error!("Limit of retries reached, payment couldn't succeed."); + Err(Error::PaymentFailed.into()) } } + _ => { + let mut lnd_client = self.lnd_client.clone(); + + let max_fee: Option = bolt11_options.max_fee_amount; + + let amount_msat = u64::from( + bolt11_options + .melt_options + .map(|a| a.amount_msat()) + .unwrap_or_default(), + ); + + let pay_req = lnrpc::SendRequest { + payment_request: bolt11.to_string(), + fee_limit: max_fee.map(|f| { + let limit = Limit::Fixed(u64::from(f) as i64); + FeeLimit { limit: Some(limit) } + }), + amt_msat: amount_msat as i64, + ..Default::default() + }; + + let payment_response = lnd_client + .lightning() + .send_payment_sync(tonic::Request::new(pay_req)) + .await + .map_err(|err| { + tracing::warn!("Lightning payment failed: {}", err); + Error::PaymentFailed + })? + .into_inner(); + + let total_amount = payment_response + .payment_route + .map_or(0, |route| route.total_amt_msat / MSAT_IN_SAT as i64) + as u64; + + let (status, payment_preimage) = match total_amount == 0 { + true => (MeltQuoteState::Unpaid, None), + false => ( + MeltQuoteState::Paid, + Some(hex::encode(payment_response.payment_preimage)), + ), + }; - // Get status and maybe the preimage - let (status, payment_preimage) = match payment_response.status { - 0 => (MeltQuoteState::Pending, None), - 1 => ( - MeltQuoteState::Paid, - Some(hex::encode(payment_response.preimage)), - ), - 2 => (MeltQuoteState::Unpaid, None), - _ => (MeltQuoteState::Unknown, None), - }; + let payment_identifier = + PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref()); - // Get the actual amount paid in sats - let mut total_amt: u64 = 0; - if let Some(route) = payment_response.route { - total_amt = (route.total_amt_msat / 1000) as u64; + Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier, + payment_proof: payment_preimage, + status, + total_spent: total_amount.into(), + unit: CurrencyUnit::Sat, + }) } - - return Ok(MakePaymentResponse { - payment_lookup_id: hex::encode(payment_hash), - payment_proof: payment_preimage, - status, - total_spent: total_amt.into(), - unit: CurrencyUnit::Sat, - }); } - - // "We have exhausted all tactical options" -- STEM, Upgrade (2018) - // The payment was not possible within 50 retries. - tracing::error!("Limit of retries reached, payment couldn't succeed."); - Err(Error::PaymentFailed.into()) } - None => { - let mut lnd_client = self.lnd_client.clone(); - - let pay_req = lnrpc::SendRequest { - payment_request, - fee_limit: max_fee.map(|f| { - let limit = Limit::Fixed(u64::from(f) as i64); - FeeLimit { limit: Some(limit) } - }), - amt_msat: amount_msat as i64, - ..Default::default() - }; - - let payment_response = lnd_client - .lightning() - .send_payment_sync(tonic::Request::new(pay_req)) - .await - .map_err(|err| { - tracing::warn!("Lightning payment failed: {}", err); - Error::PaymentFailed - })? - .into_inner(); - - let total_amount = payment_response - .payment_route - .map_or(0, |route| route.total_amt_msat / MSAT_IN_SAT as i64) - as u64; - - let (status, payment_preimage) = match total_amount == 0 { - true => (MeltQuoteState::Unpaid, None), - false => ( - MeltQuoteState::Paid, - Some(hex::encode(payment_response.payment_preimage)), - ), - }; - - Ok(MakePaymentResponse { - payment_lookup_id: hex::encode(payment_response.payment_hash), - payment_proof: payment_preimage, - status, - total_spent: total_amount.into(), - unit: CurrencyUnit::Sat, - }) + OutgoingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND"))) } } } - #[instrument(skip(self, description))] + #[instrument(skip(self, options))] async fn create_incoming_payment_request( &self, - amount: Amount, unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + options: IncomingPaymentOptions, ) -> Result { - let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; + match options { + IncomingPaymentOptions::Bolt11(bolt11_options) => { + let description = bolt11_options.description.unwrap_or_default(); + let amount = bolt11_options.amount; + let unix_expiry = bolt11_options.unix_expiry; - let invoice_request = lnrpc::Invoice { - value_msat: u64::from(amount) as i64, - memo: description, - ..Default::default() - }; + let amount_msat = to_unit(amount, unit, &CurrencyUnit::Msat)?; - let mut lnd_client = self.lnd_client.clone(); + let invoice_request = lnrpc::Invoice { + value_msat: u64::from(amount_msat) as i64, + memo: description, + ..Default::default() + }; - let invoice = lnd_client - .lightning() - .add_invoice(tonic::Request::new(invoice_request)) - .await - .map_err(|e| payment::Error::Anyhow(anyhow!(e)))? - .into_inner(); + let mut lnd_client = self.lnd_client.clone(); - let bolt11 = Bolt11Invoice::from_str(&invoice.payment_request)?; + let invoice = lnd_client + .lightning() + .add_invoice(tonic::Request::new(invoice_request)) + .await + .map_err(|e| payment::Error::Anyhow(anyhow!(e)))? + .into_inner(); - Ok(CreateIncomingPaymentResponse { - request_lookup_id: bolt11.payment_hash().to_string(), - request: bolt11.to_string(), - expiry: unix_expiry, - }) + let bolt11 = Bolt11Invoice::from_str(&invoice.payment_request)?; + + let payment_identifier = + PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref()); + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: payment_identifier, + request: bolt11.to_string(), + expiry: unix_expiry, + }) + } + IncomingPaymentOptions::Bolt12(_) => { + Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND"))) + } + } } #[instrument(skip(self))] async fn check_incoming_payment_status( &self, - request_lookup_id: &str, - ) -> Result { + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { let mut lnd_client = self.lnd_client.clone(); let invoice_request = lnrpc::PaymentHash { - r_hash: hex::decode(request_lookup_id).unwrap(), + r_hash: hex::decode(payment_identifier.to_string()).unwrap(), ..Default::default() }; @@ -470,26 +647,27 @@ impl MintPayment for Lnd { .map_err(|e| payment::Error::Anyhow(anyhow!(e)))? .into_inner(); - match invoice.state { - // Open - 0 => Ok(MintQuoteState::Unpaid), - // Settled - 1 => Ok(MintQuoteState::Paid), - // Canceled - 2 => Ok(MintQuoteState::Unpaid), - // Accepted - 3 => Ok(MintQuoteState::Unpaid), - _ => Err(Self::Err::Anyhow(anyhow!("Invalid status"))), + if invoice.state() == InvoiceState::Settled { + Ok(vec![WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: Amount::from(invoice.amt_paid_msat as u64), + unit: CurrencyUnit::Msat, + payment_id: hex::encode(invoice.r_hash), + }]) + } else { + Ok(vec![]) } } #[instrument(skip(self))] async fn check_outgoing_payment( &self, - payment_hash: &str, + payment_identifier: &PaymentIdentifier, ) -> Result { let mut lnd_client = self.lnd_client.clone(); + let payment_hash = &payment_identifier.to_string(); + let track_request = routerrpc::TrackPaymentRequest { payment_hash: hex::decode(payment_hash).map_err(|_| Error::InvalidHash)?, no_inflight_updates: true, @@ -503,7 +681,7 @@ impl MintPayment for Lnd { let err_code = err.code(); if err_code == tonic::Code::NotFound { return Ok(MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: None, status: MeltQuoteState::Unknown, total_spent: Amount::ZERO, @@ -522,7 +700,7 @@ impl MintPayment for Lnd { let response = match status { PaymentStatus::Unknown => MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: Some(update.payment_preimage), status: MeltQuoteState::Unknown, total_spent: Amount::ZERO, @@ -533,7 +711,7 @@ impl MintPayment for Lnd { continue; } PaymentStatus::Succeeded => MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: Some(update.payment_preimage), status: MeltQuoteState::Paid, total_spent: Amount::from( @@ -546,7 +724,7 @@ impl MintPayment for Lnd { unit: CurrencyUnit::Sat, }, PaymentStatus::Failed => MakePaymentResponse { - payment_lookup_id: payment_hash.to_string(), + payment_lookup_id: payment_identifier.clone(), payment_proof: Some(update.payment_preimage), status: MeltQuoteState::Failed, total_spent: Amount::ZERO, diff --git a/crates/cdk-mint-rpc/Cargo.toml b/crates/cdk-mint-rpc/Cargo.toml index 5b86d9f91..9f3ed0893 100644 --- a/crates/cdk-mint-rpc/Cargo.toml +++ b/crates/cdk-mint-rpc/Cargo.toml @@ -23,6 +23,7 @@ anyhow.workspace = true cdk = { workspace = true, features = [ "mint", ] } +cdk-common.workspace = true clap.workspace = true tonic = { workspace = true, features = ["transport"] } tracing.workspace = true diff --git a/crates/cdk-mint-rpc/README.md b/crates/cdk-mint-rpc/README.md index be20d9cf2..54dbac5db 100644 --- a/crates/cdk-mint-rpc/README.md +++ b/crates/cdk-mint-rpc/README.md @@ -19,7 +19,7 @@ This crate includes: From crates.io: ```bash -cargo install cdk-mint-cli +cargo install cdk-mint-rpc ``` As a library: @@ -48,4 +48,4 @@ cdk-mint-cli keysets list ## License -This project is licensed under the [MIT License](../../LICENSE). \ No newline at end of file +This project is licensed under the [MIT License](../../LICENSE). diff --git a/crates/cdk-mint-rpc/src/bin/mint_rpc_cli.rs b/crates/cdk-mint-rpc/src/bin/mint_rpc_cli.rs index f7a619429..293ae6343 100644 --- a/crates/cdk-mint-rpc/src/bin/mint_rpc_cli.rs +++ b/crates/cdk-mint-rpc/src/bin/mint_rpc_cli.rs @@ -7,22 +7,55 @@ use cdk_mint_rpc::GetInfoRequest; use clap::{Parser, Subcommand}; use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity}; use tonic::Request; -use tracing::Level; use tracing_subscriber::EnvFilter; +/// Common CLI arguments for CDK binaries +#[derive(Parser, Debug)] +pub struct CommonArgs { + /// Enable logging (default is false) + #[arg(long, default_value_t = false)] + pub enable_logging: bool, + + /// Logging level when enabled (default is debug) + #[arg(long, default_value = "debug")] + pub log_level: tracing::Level, +} + +/// Initialize logging based on CLI arguments +pub fn init_logging(enable_logging: bool, log_level: tracing::Level) { + if enable_logging { + let default_filter = log_level.to_string(); + + // Common filters to reduce noise + let sqlx_filter = "sqlx=warn"; + let hyper_filter = "hyper=warn"; + let h2_filter = "h2=warn"; + let rustls_filter = "rustls=warn"; + let reqwest_filter = "reqwest=warn"; + + let env_filter = EnvFilter::new(format!( + "{default_filter},{sqlx_filter},{hyper_filter},{h2_filter},{rustls_filter},{reqwest_filter}" + )); + + // Ok if successful, Err if already initialized + let _ = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .try_init(); + } +} + const DEFAULT_WORK_DIR: &str = ".cdk-mint-rpc-cli"; #[derive(Parser)] #[command(version, about, long_about = None)] struct Cli { + #[command(flatten)] + common: CommonArgs, + /// Address of RPC server #[arg(short, long, default_value = "https://127.0.0.1:8086")] addr: String, - /// Logging level - #[arg(short, long, default_value = "debug")] - log_level: Level, - /// Path to working dir #[arg(short, long)] work_dir: Option, @@ -70,14 +103,9 @@ enum Commands { #[tokio::main] async fn main() -> Result<()> { let args: Cli = Cli::parse(); - let default_filter = args.log_level; - - let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn"; - - let env_filter = EnvFilter::new(format!("{default_filter},{sqlx_filter}")); - // Parse input - tracing_subscriber::fmt().with_env_filter(env_filter).init(); + // Initialize logging based on CLI arguments + init_logging(args.common.enable_logging, args.common.log_level); let cli = Cli::parse(); diff --git a/crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs b/crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs index 7e4137c6d..76661413c 100644 --- a/crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs +++ b/crates/cdk-mint-rpc/src/mint_rpc_cli/subcommands/rotate_next_keyset.rs @@ -16,9 +16,9 @@ pub struct RotateNextKeysetCommand { #[arg(short, long)] #[arg(default_value = "sat")] unit: String, - /// The maximum order (power of 2) for tokens that can be minted with this keyset + /// The amounts that can be minted with this keyset (e.g., "1,2,4,8,16") #[arg(short, long)] - max_order: Option, + amounts: Option, /// The input fee in parts per thousand to apply when minting with this keyset #[arg(short, long)] input_fee_ppk: Option, @@ -36,10 +36,19 @@ pub async fn rotate_next_keyset( client: &mut CdkMintClient, sub_command_args: &RotateNextKeysetCommand, ) -> Result<()> { + let amounts = if let Some(amounts_str) = &sub_command_args.amounts { + amounts_str + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()? + } else { + vec![] + }; + let response = client .rotate_next_keyset(Request::new(RotateNextKeysetRequest { unit: sub_command_args.unit.clone(), - max_order: sub_command_args.max_order.map(|m| m.into()), + amounts, input_fee_ppk: sub_command_args.input_fee_ppk, })) .await?; @@ -47,8 +56,8 @@ pub async fn rotate_next_keyset( let response = response.into_inner(); println!( - "Rotated to new keyset {} for unit {} with a max order of {} and fee of {}", - response.id, response.unit, response.max_order, response.input_fee_ppk + "Rotated to new keyset {} for unit {} with amounts {:?} and fee of {}", + response.id, response.unit, response.amounts, response.input_fee_ppk ); Ok(()) diff --git a/crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto b/crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto index 21b1e3f5c..32990637c 100644 --- a/crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto +++ b/crates/cdk-mint-rpc/src/proto/cdk-mint-rpc.proto @@ -122,7 +122,7 @@ message UpdateNut04QuoteRequest { message RotateNextKeysetRequest { string unit = 1; - optional uint32 max_order = 2; + repeated uint64 amounts = 2; optional uint64 input_fee_ppk = 3; } @@ -130,6 +130,6 @@ message RotateNextKeysetRequest { message RotateNextKeysetResponse { string id = 1; string unit = 2; - uint32 max_order = 3; + repeated uint64 amounts = 3; uint64 input_fee_ppk = 4; } diff --git a/crates/cdk-mint-rpc/src/proto/server.rs b/crates/cdk-mint-rpc/src/proto/server.rs index dbba9479e..fed764fac 100644 --- a/crates/cdk-mint-rpc/src/proto/server.rs +++ b/crates/cdk-mint-rpc/src/proto/server.rs @@ -3,12 +3,13 @@ use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -use cdk::mint::Mint; +use cdk::mint::{Mint, MintQuote}; use cdk::nuts::nut04::MintMethodSettings; use cdk::nuts::nut05::MeltMethodSettings; use cdk::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod}; use cdk::types::QuoteTTL; use cdk::Amount; +use cdk_common::payment::WaitPaymentResponse; use thiserror::Error; use tokio::sync::Notify; use tokio::task::JoinHandle; @@ -76,6 +77,11 @@ impl MintRPCServer { pub async fn start(&mut self, tls_dir: Option) -> Result<(), Error> { tracing::info!("Starting RPC server {}", self.socket_addr); + #[cfg(not(target_arch = "wasm32"))] + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + let server = match tls_dir { Some(tls_dir) => { tracing::info!("TLS configuration found, starting secure server"); @@ -637,7 +643,7 @@ impl CdkMint for MintRPCServer { let mint_quote = self .mint - .localstore + .localstore() .get_mint_quote("e_id) .await .map_err(|_| Status::invalid_argument("Could not find quote".to_string()))? @@ -645,23 +651,53 @@ impl CdkMint for MintRPCServer { match state { MintQuoteState::Paid => { + // Create a dummy payment response + let response = WaitPaymentResponse { + payment_id: String::new(), + payment_amount: mint_quote.amount_paid(), + unit: mint_quote.unit.clone(), + payment_identifier: mint_quote.request_lookup_id.clone(), + }; + + let localstore = self.mint.localstore(); + let mut tx = localstore + .begin_transaction() + .await + .map_err(|_| Status::internal("Could not start db transaction".to_string()))?; + self.mint - .pay_mint_quote(&mint_quote) + .pay_mint_quote(&mut tx, &mint_quote, response) .await - .map_err(|_| Status::internal("Could not find quote".to_string()))?; + .map_err(|_| Status::internal("Could not process payment".to_string()))?; + + tx.commit() + .await + .map_err(|_| Status::internal("Could not commit db transaction".to_string()))?; } _ => { - let mut mint_quote = mint_quote; - - mint_quote.state = state; - - let mut tx = self - .mint - .localstore + // Create a new quote with the same values + let quote = MintQuote::new( + Some(mint_quote.id.clone()), // id + mint_quote.request.clone(), // request + mint_quote.unit.clone(), // unit + mint_quote.amount, // amount + mint_quote.expiry, // expiry + mint_quote.request_lookup_id.clone(), // request_lookup_id + mint_quote.pubkey, // pubkey + mint_quote.amount_issued(), // amount_issued + mint_quote.amount_paid(), // amount_paid + mint_quote.payment_method.clone(), // method + 0, // created_at + vec![], // blinded_messages + vec![], // payment_ids + ); + + let mint_store = self.mint.localstore(); + let mut tx = mint_store .begin_transaction() .await .map_err(|_| Status::internal("Could not update quote".to_string()))?; - tx.add_or_replace_mint_quote(mint_quote) + tx.add_mint_quote(quote.clone()) .await .map_err(|_| Status::internal("Could not update quote".to_string()))?; tx.commit() @@ -672,14 +708,14 @@ impl CdkMint for MintRPCServer { let mint_quote = self .mint - .localstore + .localstore() .get_mint_quote("e_id) .await .map_err(|_| Status::invalid_argument("Could not find quote".to_string()))? .ok_or(Status::invalid_argument("Could not find quote".to_string()))?; Ok(Response::new(UpdateNut04QuoteRequest { - state: mint_quote.state.to_string(), + state: mint_quote.state().to_string(), quote_id: mint_quote.id.to_string(), })) } @@ -694,20 +730,22 @@ impl CdkMint for MintRPCServer { let unit = CurrencyUnit::from_str(&request.unit) .map_err(|_| Status::invalid_argument("Invalid unit".to_string()))?; + let amounts = if request.amounts.is_empty() { + return Err(Status::invalid_argument("amounts cannot be empty")); + } else { + request.amounts + }; + let keyset_info = self .mint - .rotate_keyset( - unit, - request.max_order.map(|a| a as u8).unwrap_or(32), - request.input_fee_ppk.unwrap_or(0), - ) + .rotate_keyset(unit, amounts, request.input_fee_ppk.unwrap_or(0)) .await .map_err(|_| Status::invalid_argument("Could not rotate keyset".to_string()))?; Ok(Response::new(RotateNextKeysetResponse { id: keyset_info.id.to_string(), unit: keyset_info.unit.to_string(), - max_order: keyset_info.max_order.into(), + amounts: keyset_info.amounts, input_fee_ppk: keyset_info.input_fee_ppk, })) } diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 384481907..bd0268353 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -11,19 +11,25 @@ rust-version.workspace = true readme = "README.md" [features] -default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "auth"] +default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite", "portalwallet", "auth"] +# Database features - at least one must be enabled +sqlite = ["dep:cdk-sqlite"] +postgres = ["dep:cdk-postgres"] # Ensure at least one lightning backend is enabled management-rpc = ["cdk-mint-rpc"] cln = ["dep:cdk-cln"] lnd = ["dep:cdk-lnd"] lnbits = ["dep:cdk-lnbits"] fakewallet = ["dep:cdk-fake-wallet"] +ldk-node = ["dep:cdk-ldk-node"] grpc-processor = ["dep:cdk-payment-processor", "cdk-signatory/grpc"] -sqlcipher = ["cdk-sqlite/sqlcipher"] -# MSRV is not committed to with redb enabled +sqlcipher = ["sqlite", "cdk-sqlite/sqlcipher"] +# MSRV is not committed to with swagger enabled swagger = ["cdk-axum/swagger", "dep:utoipa", "dep:utoipa-swagger-ui"] redis = ["cdk-axum/redis"] -auth = ["cdk/auth", "cdk-sqlite/auth"] +auth = ["cdk/auth", "cdk-axum/auth", "cdk-sqlite?/auth", "cdk-postgres?/auth"] +prometheus = ["cdk/prometheus", "dep:cdk-prometheus", "cdk-sqlite?/prometheus", "cdk-axum/prometheus"] +portalwallet = ["dep:cdk-portal-wallet"] [dependencies] anyhow.workspace = true @@ -33,34 +39,37 @@ cdk = { workspace = true, features = [ "mint", ] } cdk-sqlite = { workspace = true, features = [ - "mint", -] } + "mint" +], optional = true } +cdk-common = {workspace = true, features = ["prometheus"]} +cdk-postgres = { workspace = true, features = ["mint"], optional = true} cdk-cln = { workspace = true, optional = true } cdk-lnbits = { workspace = true, optional = true } cdk-lnd = { workspace = true, optional = true } +cdk-ldk-node = { workspace = true, optional = true } cdk-fake-wallet = { workspace = true, optional = true } cdk-axum.workspace = true cdk-signatory.workspace = true cdk-mint-rpc = { workspace = true, optional = true } cdk-payment-processor = { workspace = true, optional = true } -config = { version = "0.15.11", features = ["toml"] } +config.workspace = true +cdk-prometheus = { workspace = true, optional = true , features = ["system-metrics"]} clap.workspace = true bitcoin.workspace = true tokio = { workspace = true, default-features = false, features = ["signal"] } tracing.workspace = true tracing-subscriber.workspace = true +tracing-appender.workspace = true futures.workspace = true serde.workspace = true bip39.workspace = true tower-http = { workspace = true, features = ["compression-full", "decompression-full"] } -tower = "0.5.2" +tower.workspace = true lightning-invoice.workspace = true home.workspace = true -url.workspace = true utoipa = { workspace = true, optional = true } utoipa-swagger-ui = { version = "9.0.0", features = ["axum"], optional = true } +cdk-portal-wallet = { workspace = true, optional = true } + [build-dependencies] -# Dep of utopia 2.5.0 breaks so keeping here for now -zip = "=2.4.2" -time = "=0.3.39" diff --git a/crates/cdk-mintd/README.md b/crates/cdk-mintd/README.md index 4dfea02fd..f42bf0847 100644 --- a/crates/cdk-mintd/README.md +++ b/crates/cdk-mintd/README.md @@ -4,39 +4,264 @@ [![Documentation](https://docs.rs/cdk-mintd/badge.svg)](https://docs.rs/cdk-mintd) [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE) -**ALPHA** This library is in early development, the API will change and should be used with caution. +> **Warning** +> This project is in early development, it does however work with real sats! Always use amounts you don't mind losing. -Cashu mint daemon implementation for the Cashu Development Kit (CDK). This binary provides a complete Cashu mint server implementation. +Cashu mint daemon implementation for the Cashu Development Kit (CDK). This binary provides a complete Cashu mint server implementation with support for multiple database backends and Lightning Network integrations. + +## Features + +- **Multiple Database Backends**: SQLite, PostgreSQL, and ReDB +- **Lightning Network Integration**: Support for CLN, LND, LNbits, LDK Node, and test backends +- **Authentication**: Optional user authentication with OpenID Connect +- **Management RPC**: gRPC interface for mint management +- **Docker Support**: Ready-to-use Docker configurations + +## Lightning Backend Documentation + +For detailed configuration of each Lightning backend, see: + +- **[LND](../cdk-lnd/README.md)** - Lightning Network Daemon +- **[CLN](../cdk-cln/README.md)** - Core Lightning +- **[LNbits](../cdk-lnbits/README.md)** - LNbits API integration ## Installation -From crates.io: +### Option 1: Download Pre-built Binary +Download the latest release from the [GitHub releases page](https://github.com/cashubtc/cdk/releases). + +### Option 2: Build from Source ```bash -cargo install cdk-mintd +git clone https://github.com/cashubtc/cdk.git +cd cdk +cargo build --bin cdk-mintd --release +# Binary will be at ./target/release/cdk-mintd ``` -From source: +## Configuration + +> **Important**: You must create the working directory and configuration file before starting the mint. The mint does not create them automatically. + +### Setup Steps + +1. **Create working directory**: + ```bash + mkdir -p ~/.cdk-mintd + ``` + +2. **Create configuration file**: + ```bash + # Copy and customize the example config + cp example.config.toml ~/.cdk-mintd/config.toml + # Edit ~/.cdk-mintd/config.toml with your settings + ``` + +3. **Start the mint**: + ```bash + cdk-mintd # Uses ~/.cdk-mintd/config.toml automatically + ``` + +### Configuration File Locations (in order of precedence) + +1. **Explicit path**: `cdk-mintd --config /path/to/config.toml` +2. **Working directory**: `./config.toml` (in current directory) +3. **Default location**: `~/.cdk-mintd/config.toml` +4. **Environment variables**: All config options can be set via environment variables + +### Alternative Setup Methods + +**Custom working directory**: ```bash -cargo install --path . +mkdir -p /my/custom/path +cp example.config.toml /my/custom/path/config.toml +cdk-mintd --work-dir /my/custom/path ``` -## Configuration +**Environment variables only**: +```bash +export CDK_MINTD_LISTEN_PORT=3000 +export CDK_MINTD_LN_BACKEND=fakewallet +export CDK_MINTD_DATABASE=sqlite +cdk-mintd +``` + +## Production Examples + +### With LDK Node (Recommended for Testing) +```toml +[ln] +ln_backend = "ldk-node" + +[ldk_node] +bitcoin_network = "signet" # Use "mainnet" for production +esplora_url = "https://mutinynet.com/api" +rgs_url = "https://rgs.mutinynet.com/snapshot/0" +gossip_source_type = "rgs" +storage_dir_path = "/var/lib/cdk-mintd/ldk-node" +``` + + +### With CLN Lightning Backend +```toml +[ln] +ln_backend = "cln" + +[cln] +rpc_path = "/home/bitcoin/.lightning/bitcoin/lightning-rpc" +# fee_percent = 0.02 # Optional, defaults to 2% +# reserve_fee_min = 2 # Optional, defaults to 2 sats +``` + +### With LND Lightning Backend +```toml +[ln] +ln_backend = "lnd" -The mint can be configured through environment variables or a configuration file. See the documentation for available options. +[lnd] +address = "https://localhost:10009" +macaroon_file = "/home/bitcoin/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" +cert_file = "/home/bitcoin/.lnd/tls.cert" +# fee_percent = 0.02 # Optional, defaults to 2% +# reserve_fee_min = 2 # Optional, defaults to 2 sats +``` -## Usage +### With PostgreSQL Database +```toml +[database] +engine = "postgres" + +[database.postgres] +url = "postgresql://mint_user:password@localhost:5432/cdk_mint" +``` +## Directory Structure + +After setup and first run, your directory will look like: + +``` +~/.cdk-mintd/ # Working directory (create manually) +├── config.toml # Config file (create manually) +├── cdk-mintd.db # SQLite database (created automatically) +├── logs/ # Log files (created automatically if enabled) +│ ├── cdk-mintd.2024-01-01.log +│ └── cdk-mintd.2024-01-02.log +└── ldk-node/ # LDK Node data (if using LDK backend) + ├── wallet/ + └── graph/ +``` + +**What you must create manually:** +- Working directory (e.g., `~/.cdk-mintd/`) +- Config file (`config.toml`) + +**What gets created automatically:** +- Database files +- Log directories and files +- Lightning backend data directories + +## Docker Usage + +CDK Mintd provides ready-to-use Docker images with multiple Lightning backend options. + +### Quick Start + +#### Standard mint with fakewallet backend (testing only): +```bash +docker-compose up +``` + +#### Mint with LDK Node backend: ```bash -# Start the mint with default configuration +# Option 1: Use dedicated ldk-node compose file +docker-compose -f docker-compose.ldk-node.yaml up + +# Option 2: Use main compose file with profile +docker-compose --profile ldk-node up +``` + +### Available Images + +- **`cashubtc/mintd:latest`** - Standard mint with default features +- **`cashubtc/mintd-ldk-node:latest`** - Mint with LDK Node support + +### Configuration via Environment Variables + +All configuration can be done through environment variables: + +```yaml +environment: + - CDK_MINTD_LN_BACKEND=ldk-node + - CDK_MINTD_DATABASE=sqlite + - CDK_MINTD_LISTEN_HOST=0.0.0.0 + - CDK_MINTD_LISTEN_PORT=8085 + - CDK_MINTD_LDK_NODE_NETWORK=testnet + - CDK_MINTD_LDK_NODE_ESPLORA_URL=https://blockstream.info/testnet/api +``` + +### Monitoring + +Both Prometheus metrics and Grafana dashboards are included: +- Prometheus: http://localhost:9090 +- Grafana: http://localhost:3011 (admin/admin) + +For detailed Docker documentation, see [README-ldk-node.md](../../README-ldk-node.md). + +## Testing Your Mint + +1. **Verify the mint is running**: + ```bash + curl http://127.0.0.1:8085/v1/info + ``` + +2. **Get mint keys**: + ```bash + curl http://127.0.0.1:8085/v1/keys + ``` + +3. **Test with CDK CLI wallet**: + ```bash + # Download from: https://github.com/cashubtc/cdk/releases + cdk-cli wallet add-mint http://127.0.0.1:8085 + cdk-cli wallet mint-quote 100 + ``` + +4. **For LDK Node backend**: Access the management interface at + +## Command Line Usage + +```bash +# Start with default configuration cdk-mintd # Start with custom config file cdk-mintd --config /path/to/config.toml +# Start with custom working directory +cdk-mintd --work-dir /path/to/work/dir + +# Disable logging +cdk-mintd --enable-logging false + # Show help cdk-mintd --help ``` +## Key Environment Variables + +- `CDK_MINTD_DATABASE`: Database engine (`sqlite`/`postgres`/`redb`) +- `CDK_MINTD_DATABASE_URL`: PostgreSQL connection string +- `CDK_MINTD_LN_BACKEND`: Lightning backend (`cln`/`lnd`/`lnbits`/`ldk-node`/`fakewallet`) +- `CDK_MINTD_LISTEN_HOST`: Host to bind to (default: `127.0.0.1`) +- `CDK_MINTD_LISTEN_PORT`: Port to bind to (default: `8085`) + +For complete configuration options, see the [example configuration file](./example.config.toml). + +## Documentation + +- **[Configuration Examples](./example.config.toml)** - Complete configuration reference +- **[PostgreSQL Setup Guide](../../docker-compose.postgres.yaml)** - Database setup with Docker Compose +- **[Development Guide](../../DEVELOPMENT.md)** - Contributing and development setup + ## License -This project is licensed under the [MIT License](../../LICENSE). \ No newline at end of file +This project is licensed under the [MIT License](../../LICENSE). diff --git a/crates/cdk-mintd/build.rs b/crates/cdk-mintd/build.rs new file mode 100644 index 000000000..43ea2a298 --- /dev/null +++ b/crates/cdk-mintd/build.rs @@ -0,0 +1,30 @@ +fn main() { + // Check that at least one database feature is enabled + let has_database = cfg!(feature = "sqlite") || cfg!(feature = "postgres"); + + if !has_database { + panic!( + "cdk-mintd requires at least one database backend to be enabled.\n\ + Available database features: sqlite, postgres\n\ + Example: cargo build --features sqlite" + ); + } + + // Check that at least one Lightning backend is enabled + let has_lightning_backend = cfg!(feature = "cln") + || cfg!(feature = "lnd") + || cfg!(feature = "lnbits") + || cfg!(feature = "fakewallet") + || cfg!(feature = "grpc-processor") + || cfg!(feature = "ldk-node"); + + if !has_lightning_backend { + panic!( + "cdk-mintd requires at least one Lightning backend to be enabled.\n\ + Available Lightning backends: cln, lnd, lnbits, fakewallet, grpc-processor\n\ + Example: cargo build --features \"sqlite fakewallet\"" + ); + } + + println!("cargo:rerun-if-changed=build.rs"); +} diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index bd373df5e..0fcc86fef 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -1,3 +1,4 @@ + [info] url = "https://mint.thesimplekid.dev/" listen_host = "127.0.0.1" @@ -6,12 +7,30 @@ mnemonic = "" # input_fee_ppk = 0 # enable_swagger_ui = false +[info.quote_ttl] +# Prefer explicit fields over inline tables for readability and ease of overrides +mint_ttl = 600 +melt_ttl = 120 + + +[info.logging] +# Where to output logs: "stderr" (standard error stream), "file", or "both" (default: "both") +# output = "both" +# Log level for console output (default: "info") +# console_level = "info" +# Log level for file output (default: "debug") +# file_level = "debug" + [mint_management_rpc] enabled = false # address = "127.0.0.1" # port = 8086 - +#[prometheus] +#enabled = true +#address = "127.0.0.1" +#port = 9090 +# [info.http_cache] # memory or redis backend = "memory" @@ -38,42 +57,111 @@ tti = 60 [database] -# Database engine (sqlite/redb) defaults to sqlite -# engine = "sqlite" +# Database engine (sqlite/postgres) defaults to sqlite +engine = "sqlite" + +# PostgreSQL configuration (when engine = "postgres") +[database.postgres] +# PostgreSQL connection URL +# Can also be set via CDK_MINTD_POSTGRES_URL or CDK_MINTD_DATABASE_URL environment variables +# Environment variables take precedence over config file settings +url = "postgresql://user:password@localhost:5432/cdk_mint" +# TLS mode: "disable", "prefer", "require" (optional, defaults to "disable") +tls_mode = "disable" +# Maximum number of connections in the pool (optional, defaults to 20) +max_connections = 20 +# Connection timeout in seconds (optional, defaults to 10) +connection_timeout_seconds = 10 + +# Auth database configuration (optional, only used when auth is enabled) +[auth_database.postgres] +# PostgreSQL connection URL for authentication database +# Can also be set via CDK_MINTD_AUTH_POSTGRES_URL environment variable +# Environment variables take precedence over config file settings +url = "postgresql://user:password@localhost:5432/cdk_mint_auth" +# TLS mode: "disable", "prefer", "require" (optional, defaults to "disable") +tls_mode = "disable" +# Maximum number of connections in the pool (optional, defaults to 20) +max_connections = 20 +# Connection timeout in seconds (optional, defaults to 10) +connection_timeout_seconds = 10 [ln] -# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits' +# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode' ln_backend = "fakewallet" # min_mint=1 # max_mint=500000 # min_melt=1 # max_melt=500000 -[cln] -rpc_path = "" -fee_percent = 0.04 -reserve_fee_min = 4 +# [cln] +# rpc_path = "/path/to/.lightning/bitcoin/lightning-rpc" +# bolt12 = true # Optional, defaults to true +# fee_percent = 0.02 # Optional, defaults to 2% +# reserve_fee_min = 2 # Optional, defaults to 2 sats # [lnbits] # admin_api_key = "" # invoice_api_key = "" # lnbits_api = "" -# To be set true to support pre v1 lnbits api -# retro_api=false +# fee_percent = 0.02 # Optional, defaults to 2% +# reserve_fee_min = 2 # Optional, defaults to 2 sats +# Note: Only LNBits v1 API is supported (websocket-based) # [lnd] -# address = "https://domain:port" -# macaroon_file = "" -# cert_file = "" -# fee_percent=0.04 -# reserve_fee_min=4 +# address = "https://localhost:10009" +# cert_file = "/path/to/.lnd/tls.cert" +# macaroon_file = "/path/to/.lnd/data/chain/bitcoin/mainnet/admin.macaroon" +# fee_percent = 0.02 # Optional, defaults to 2% +# reserve_fee_min = 2 # Optional, defaults to 2 sats -# [fake_wallet] -# supported_units = ["sat"] -# fee_percent = 0.02 -# reserve_fee_min = 1 -# min_delay_time = 1 -# max_delay_time = 3 +# [ldk_node] +# fee_percent = 0.02 # Optional, defaults to 2% +# reserve_fee_min = 2 # Optional, defaults to 2 sats +# bitcoin_network = "signet" # mainnet, testnet, signet, regtest +# chain_source_type = "esplora" # esplora, bitcoinrpc +# +# # Mutinynet configuration (recommended for testing) +# esplora_url = "https://mutinynet.com/api" +# gossip_source_type = "rgs" # Use RGS for better performance +# rgs_url = "https://rgs.mutinynet.com/snapshot/0" +# storage_dir_path = "~/.cdk-ldk-node/mutinynet" +# +# # Testnet configuration +# # bitcoin_network = "testnet" +# # esplora_url = "https://blockstream.info/testnet/api" +# # rgs_url = "https://rapidsync.lightningdevkit.org/snapshot" +# # storage_dir_path = "~/.cdk-ldk-node/testnet" +# +# # Mainnet configuration (CAUTION: Real Bitcoin!) +# # bitcoin_network = "mainnet" +# # esplora_url = "https://blockstream.info/api" +# # rgs_url = "https://rapidsync.lightningdevkit.org/snapshot" +# # storage_dir_path = "~/.cdk-ldk-node/mainnet" +# +# # Bitcoin RPC configuration (when chain_source_type = "bitcoinrpc") +# bitcoind_rpc_host = "127.0.0.1" +# bitcoind_rpc_port = 18443 +# bitcoind_rpc_user = "testuser" +# bitcoind_rpc_password = "testpass" +# +# # Node configuration +# ldk_node_host = "127.0.0.1" +# ldk_node_port = 8090 +# +# # Gossip source configuration +# gossip_source_type = "p2p" # p2p (direct peer-to-peer) or rgs (rapid gossip sync) +# +# # Webserver configuration for LDK node management interface +# webserver_host = "127.0.0.1" # Default: 127.0.0.1 +# webserver_port = 0 # 0 = auto-assign available port + +[fake_wallet] +supported_units = ["sat"] +fee_percent = 0.02 +reserve_fee_min = 1 +min_delay_time = 1 +max_delay_time = 3 # [grpc_processor] # gRPC Payment Processor configuration @@ -83,12 +171,23 @@ reserve_fee_min = 4 # tls_dir = "/path/to/tls" # [auth] +# Set to true to enable authentication features (defaults to false) +# auth_enabled = false # openid_discovery = "http://127.0.0.1:8080/realms/cdk-test-realm/.well-known/openid-configuration" # openid_client_id = "cashu-client" # mint_max_bat=50 -# enabled_mint=true -# enabled_melt=true -# enabled_swap=true -# enabled_check_mint_quote=true -# enabled_check_melt_quote=true -# enabled_restore=true + +# Authentication settings for endpoints +# Options: "clear", "blind", "none" (none = disabled) + +# mint = "blind" +# get_mint_quote = "none" +# check_mint_quote = "none" + +# melt = "none" +# get_melt_quote = "none" +# check_melt_quote = "none" + +# swap = "blind" +# restore = "blind" +# check_proof_state = "none" diff --git a/crates/cdk-mintd/src/cli.rs b/crates/cdk-mintd/src/cli.rs index 20d07086f..9da08831f 100644 --- a/crates/cdk-mintd/src/cli.rs +++ b/crates/cdk-mintd/src/cli.rs @@ -24,4 +24,12 @@ pub struct CLIArgs { pub config: Option, #[arg(short, long, help = "Recover Greenlight from seed", required = false)] pub recover: Option, + #[arg( + long, + help = "Enable logging output", + required = false, + action = clap::ArgAction::SetTrue, + default_value = "true" + )] + pub enable_logging: bool, } diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 636080fbd..0740706fd 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -4,14 +4,60 @@ use bitcoin::hashes::{sha256, Hash}; use cdk::nuts::{CurrencyUnit, PublicKey}; use cdk::Amount; use cdk_axum::cache; +use cdk_common::common::QuoteTTL; use config::{Config, ConfigError, File}; use serde::{Deserialize, Serialize}; -#[derive(Clone, Serialize, Deserialize, Default)] +#[cfg(feature = "portalwallet")] +use std::collections::HashMap; +#[cfg(feature = "portalwallet")] +use cdk_common::common::UnitMetadata; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[serde(rename_all = "lowercase")] +pub enum LoggingOutput { + /// Log to stderr only + Stderr, + /// Log to file only + File, + /// Log to both stderr and file (default) + #[default] + Both, +} + +impl std::str::FromStr for LoggingOutput { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "stderr" => Ok(LoggingOutput::Stderr), + "file" => Ok(LoggingOutput::File), + "both" => Ok(LoggingOutput::Both), + _ => Err(format!( + "Unknown logging output: {s}. Valid options: stdout, file, both" + )), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LoggingConfig { + /// Where to output logs: stdout, file, or both + #[serde(default)] + pub output: LoggingOutput, + /// Log level for console output (when stdout or both) + pub console_level: Option, + /// Log level for file output (when file or both) + pub file_level: Option, +} + +#[derive(Clone, Serialize, Deserialize)] pub struct Info { pub url: String, pub listen_host: String, pub listen_port: u16, + /// Overrides mnemonic + pub seed: Option, pub mnemonic: Option, pub signatory_url: Option, pub signatory_certs: Option, @@ -19,17 +65,46 @@ pub struct Info { pub http_cache: cache::Config, + /// Logging configuration + #[serde(default)] + pub logging: LoggingConfig, + /// When this is set to true, the mint exposes a Swagger UI for it's API at /// `[listen_host]:[listen_port]/swagger-ui` /// /// This requires `mintd` was built with the `swagger` feature flag. pub enable_swagger_ui: Option, + + /// Optional persisted quote TTL values (seconds) to initialize the database with + /// when RPC is disabled or on first-run when RPC is enabled. + /// If not provided, defaults are used. + #[serde(skip_serializing_if = "Option::is_none")] + pub quote_ttl: Option, +} + +impl Default for Info { + fn default() -> Self { + Info { + url: String::new(), + listen_host: "127.0.0.1".to_string(), + listen_port: 8091, // Default to port 8091 instead of 0 + seed: None, + mnemonic: None, + signatory_url: None, + signatory_certs: None, + input_fee_ppk: None, + http_cache: cache::Config::default(), + enable_swagger_ui: None, + logging: LoggingConfig::default(), + quote_ttl: None, + } + } } impl std::fmt::Debug for Info { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Use a fallback approach that won't panic - let mnemonic_display = { + let mnemonic_display: String = { if let Some(mnemonic) = self.mnemonic.as_ref() { let hash = sha256::Hash::hash(mnemonic.as_bytes()); format!("") @@ -45,6 +120,7 @@ impl std::fmt::Debug for Info { .field("mnemonic", &mnemonic_display) .field("input_fee_ppk", &self.input_fee_ppk) .field("http_cache", &self.http_cache) + .field("logging", &self.logging) .field("enable_swagger_ui", &self.enable_swagger_ui) .finish() } @@ -63,8 +139,12 @@ pub enum LnBackend { FakeWallet, #[cfg(feature = "lnd")] Lnd, + #[cfg(feature = "ldk-node")] + LdkNode, #[cfg(feature = "grpc-processor")] GrpcProcessor, + #[cfg(feature = "portalwallet")] + PortalWallet, } impl std::str::FromStr for LnBackend { @@ -80,8 +160,12 @@ impl std::str::FromStr for LnBackend { "fakewallet" => Ok(LnBackend::FakeWallet), #[cfg(feature = "lnd")] "lnd" => Ok(LnBackend::Lnd), + #[cfg(feature = "ldk-node")] + "ldk-node" | "ldknode" => Ok(LnBackend::LdkNode), #[cfg(feature = "grpc-processor")] "grpcprocessor" => Ok(LnBackend::GrpcProcessor), + #[cfg(feature = "portalwallet")] + "portalwallet" => Ok(LnBackend::PortalWallet), _ => Err(format!("Unknown Lightning backend: {s}")), } } @@ -111,34 +195,164 @@ impl Default for Ln { } #[cfg(feature = "lnbits")] -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct LNbits { pub admin_api_key: String, pub invoice_api_key: String, pub lnbits_api: String, + #[serde(default = "default_fee_percent")] pub fee_percent: f32, + #[serde(default = "default_reserve_fee_min")] pub reserve_fee_min: Amount, - pub retro_api: bool, +} + +#[cfg(feature = "lnbits")] +impl Default for LNbits { + fn default() -> Self { + Self { + admin_api_key: String::new(), + invoice_api_key: String::new(), + lnbits_api: String::new(), + fee_percent: 0.02, + reserve_fee_min: 2.into(), + } + } } #[cfg(feature = "cln")] -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Cln { pub rpc_path: PathBuf, - #[serde(default)] + #[serde(default = "default_cln_bolt12")] pub bolt12: bool, + #[serde(default = "default_fee_percent")] pub fee_percent: f32, + #[serde(default = "default_reserve_fee_min")] pub reserve_fee_min: Amount, } +#[cfg(feature = "cln")] +impl Default for Cln { + fn default() -> Self { + Self { + rpc_path: PathBuf::new(), + bolt12: true, + fee_percent: 0.02, + reserve_fee_min: 2.into(), + } + } +} + +#[cfg(feature = "cln")] +fn default_cln_bolt12() -> bool { + true +} + #[cfg(feature = "lnd")] -#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Lnd { pub address: String, pub cert_file: PathBuf, pub macaroon_file: PathBuf, + #[serde(default = "default_fee_percent")] + pub fee_percent: f32, + #[serde(default = "default_reserve_fee_min")] + pub reserve_fee_min: Amount, +} + +#[cfg(feature = "lnd")] +impl Default for Lnd { + fn default() -> Self { + Self { + address: String::new(), + cert_file: PathBuf::new(), + macaroon_file: PathBuf::new(), + fee_percent: 0.02, + reserve_fee_min: 2.into(), + } + } +} + +#[cfg(feature = "ldk-node")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LdkNode { + /// Fee percentage (e.g., 0.02 for 2%) + #[serde(default = "default_ldk_fee_percent")] pub fee_percent: f32, + /// Minimum reserve fee + #[serde(default = "default_ldk_reserve_fee_min")] pub reserve_fee_min: Amount, + /// Bitcoin network (mainnet, testnet, signet, regtest) + pub bitcoin_network: Option, + /// Chain source type (esplora or bitcoinrpc) + pub chain_source_type: Option, + /// Esplora URL (when chain_source_type = "esplora") + pub esplora_url: Option, + /// Bitcoin RPC configuration (when chain_source_type = "bitcoinrpc") + pub bitcoind_rpc_host: Option, + pub bitcoind_rpc_port: Option, + pub bitcoind_rpc_user: Option, + pub bitcoind_rpc_password: Option, + /// Storage directory path + pub storage_dir_path: Option, + /// LDK node listening host + pub ldk_node_host: Option, + /// LDK node listening port + pub ldk_node_port: Option, + /// Gossip source type (p2p or rgs) + pub gossip_source_type: Option, + /// Rapid Gossip Sync URL (when gossip_source_type = "rgs") + pub rgs_url: Option, + /// Webserver host (defaults to 127.0.0.1) + #[serde(default = "default_webserver_host")] + pub webserver_host: Option, + /// Webserver port + #[serde(default = "default_webserver_port")] + pub webserver_port: Option, +} + +#[cfg(feature = "ldk-node")] +impl Default for LdkNode { + fn default() -> Self { + Self { + fee_percent: default_ldk_fee_percent(), + reserve_fee_min: default_ldk_reserve_fee_min(), + bitcoin_network: None, + chain_source_type: None, + esplora_url: None, + bitcoind_rpc_host: None, + bitcoind_rpc_port: None, + bitcoind_rpc_user: None, + bitcoind_rpc_password: None, + storage_dir_path: None, + ldk_node_host: None, + ldk_node_port: None, + gossip_source_type: None, + rgs_url: None, + webserver_host: default_webserver_host(), + webserver_port: default_webserver_port(), + } + } +} + +#[cfg(feature = "ldk-node")] +fn default_ldk_fee_percent() -> f32 { + 0.04 +} + +#[cfg(feature = "ldk-node")] +fn default_ldk_reserve_fee_min() -> Amount { + 4.into() +} + +#[cfg(feature = "ldk-node")] +fn default_webserver_host() -> Option { + Some("127.0.0.1".to_string()) +} + +#[cfg(feature = "ldk-node")] +fn default_webserver_port() -> Option { + Some(8091) } #[cfg(feature = "fakewallet")] @@ -167,6 +381,15 @@ impl Default for FakeWallet { } // Helper functions to provide default values +// Common fee defaults for all backends +fn default_fee_percent() -> f32 { + 0.02 +} + +fn default_reserve_fee_min() -> Amount { + 2.into() +} + #[cfg(feature = "fakewallet")] fn default_min_delay_time() -> u64 { 1 @@ -177,19 +400,50 @@ fn default_max_delay_time() -> u64 { 3 } -#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct GrpcProcessor { + #[serde(default)] pub supported_units: Vec, + #[serde(default = "default_grpc_addr")] pub addr: String, + #[serde(default = "default_grpc_port")] pub port: u16, + #[serde(default)] pub tls_dir: Option, } +impl Default for GrpcProcessor { + fn default() -> Self { + Self { + supported_units: Vec::new(), + addr: default_grpc_addr(), + port: default_grpc_port(), + tls_dir: None, + } + } +} + +fn default_grpc_addr() -> String { + "127.0.0.1".to_string() +} + +fn default_grpc_port() -> u16 { + 50051 +} + +#[cfg(feature = "portalwallet")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PortalWallet { + pub supported_units: Vec, + pub unit_info: HashMap, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "lowercase")] pub enum DatabaseEngine { #[default] Sqlite, + Postgres, } impl std::str::FromStr for DatabaseEngine { @@ -198,6 +452,7 @@ impl std::str::FromStr for DatabaseEngine { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "sqlite" => Ok(DatabaseEngine::Sqlite), + "postgres" => Ok(DatabaseEngine::Postgres), _ => Err(format!("Unknown database engine: {s}")), } } @@ -206,32 +461,110 @@ impl std::str::FromStr for DatabaseEngine { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Database { pub engine: DatabaseEngine, + pub postgres: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct AuthDatabase { + pub postgres: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostgresAuthConfig { + pub url: String, + pub tls_mode: Option, + pub max_connections: Option, + pub connection_timeout_seconds: Option, +} + +impl Default for PostgresAuthConfig { + fn default() -> Self { + Self { + url: String::new(), + tls_mode: Some("disable".to_string()), + max_connections: Some(20), + connection_timeout_seconds: Some(10), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostgresConfig { + pub url: String, + pub tls_mode: Option, + pub max_connections: Option, + pub connection_timeout_seconds: Option, +} + +impl Default for PostgresConfig { + fn default() -> Self { + Self { + url: String::new(), + tls_mode: Some("disable".to_string()), + max_connections: Some(20), + connection_timeout_seconds: Some(10), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum AuthType { + Clear, + Blind, + #[default] + None, +} + +impl std::str::FromStr for AuthType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "clear" => Ok(AuthType::Clear), + "blind" => Ok(AuthType::Blind), + "none" => Ok(AuthType::None), + _ => Err(format!("Unknown auth type: {s}")), + } + } } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Auth { + #[serde(default)] + pub auth_enabled: bool, pub openid_discovery: String, pub openid_client_id: String, pub mint_max_bat: u64, - #[serde(default = "default_true")] - pub enabled_mint: bool, - #[serde(default = "default_true")] - pub enabled_melt: bool, - #[serde(default = "default_true")] - pub enabled_swap: bool, - #[serde(default = "default_true")] - pub enabled_check_mint_quote: bool, - #[serde(default = "default_true")] - pub enabled_check_melt_quote: bool, - #[serde(default = "default_true")] - pub enabled_restore: bool, - #[serde(default = "default_true")] - pub enabled_check_proof_state: bool, -} - -fn default_true() -> bool { - true + #[serde(default = "default_blind")] + pub mint: AuthType, + #[serde(default)] + pub get_mint_quote: AuthType, + #[serde(default)] + pub check_mint_quote: AuthType, + #[serde(default)] + pub melt: AuthType, + #[serde(default)] + pub get_melt_quote: AuthType, + #[serde(default)] + pub check_melt_quote: AuthType, + #[serde(default = "default_blind")] + pub swap: AuthType, + #[serde(default = "default_blind")] + pub restore: AuthType, + #[serde(default)] + pub check_proof_state: AuthType, + /// Enable WebSocket authentication support + #[serde(default = "default_blind")] + pub websocket_auth: AuthType, + /// Static auth token (if set, this token will be accepted for clear auth instead of OIDC) + pub static_auth_token: Option, +} + +fn default_blind() -> AuthType { + AuthType::Blind } + /// CDK settings, derived from `config.toml` #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { @@ -244,13 +577,29 @@ pub struct Settings { pub lnbits: Option, #[cfg(feature = "lnd")] pub lnd: Option, + #[cfg(feature = "ldk-node")] + pub ldk_node: Option, #[cfg(feature = "fakewallet")] pub fake_wallet: Option, pub grpc_processor: Option, pub database: Database, + #[cfg(feature = "auth")] + pub auth_database: Option, #[cfg(feature = "management-rpc")] pub mint_management_rpc: Option, pub auth: Option, + #[cfg(feature = "prometheus")] + pub prometheus: Option, + #[cfg(feature = "portalwallet")] + pub portal_wallet: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg(feature = "prometheus")] +pub struct Prometheus { + pub enabled: bool, + pub address: Option, + pub port: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -331,39 +680,6 @@ impl Settings { .build()?; let settings: Settings = config.try_deserialize()?; - match settings.ln.ln_backend { - LnBackend::None => panic!("Ln backend must be set"), - #[cfg(feature = "cln")] - LnBackend::Cln => assert!( - settings.cln.is_some(), - "CLN backend requires a valid config." - ), - #[cfg(feature = "lnbits")] - LnBackend::LNbits => assert!( - settings.lnbits.is_some(), - "LNbits backend requires a valid config" - ), - #[cfg(feature = "lnd")] - LnBackend::Lnd => { - assert!( - settings.lnd.is_some(), - "LND backend requires a valid config." - ) - } - #[cfg(feature = "fakewallet")] - LnBackend::FakeWallet => assert!( - settings.fake_wallet.is_some(), - "FakeWallet backend requires a valid config." - ), - #[cfg(feature = "grpc-processor")] - LnBackend::GrpcProcessor => { - assert!( - settings.grpc_processor.is_some(), - "GRPC backend requires a valid config." - ) - } - } - Ok(settings) } } @@ -437,4 +753,374 @@ mod tests { assert!(!debug_output.contains("特殊字符 !@#$%^&*()")); assert!(debug_output.contains(" Self { + if let Ok(enabled_str) = env::var(ENV_AUTH_ENABLED) { + if let Ok(enabled) = enabled_str.parse() { + self.auth_enabled = enabled; + } + } + if let Ok(discovery) = env::var(ENV_AUTH_OPENID_DISCOVERY) { self.openid_discovery = discovery; } @@ -31,45 +44,63 @@ impl Auth { } } - if let Ok(enabled_mint_str) = env::var(ENV_AUTH_ENABLED_MINT) { - if let Ok(enabled) = enabled_mint_str.parse() { - self.enabled_mint = enabled; + if let Ok(mint_str) = env::var(ENV_AUTH_MINT) { + if let Ok(auth_type) = mint_str.parse() { + self.mint = auth_type; + } + } + + if let Ok(get_mint_quote_str) = env::var(ENV_AUTH_GET_MINT_QUOTE) { + if let Ok(auth_type) = get_mint_quote_str.parse() { + self.get_mint_quote = auth_type; + } + } + + if let Ok(check_mint_quote_str) = env::var(ENV_AUTH_CHECK_MINT_QUOTE) { + if let Ok(auth_type) = check_mint_quote_str.parse() { + self.check_mint_quote = auth_type; + } + } + + if let Ok(melt_str) = env::var(ENV_AUTH_MELT) { + if let Ok(auth_type) = melt_str.parse() { + self.melt = auth_type; } } - if let Ok(enabled_melt_str) = env::var(ENV_AUTH_ENABLED_MELT) { - if let Ok(enabled) = enabled_melt_str.parse() { - self.enabled_melt = enabled; + if let Ok(get_melt_quote_str) = env::var(ENV_AUTH_GET_MELT_QUOTE) { + if let Ok(auth_type) = get_melt_quote_str.parse() { + self.get_melt_quote = auth_type; } } - if let Ok(enabled_swap_str) = env::var(ENV_AUTH_ENABLED_SWAP) { - if let Ok(enabled) = enabled_swap_str.parse() { - self.enabled_swap = enabled; + if let Ok(check_melt_quote_str) = env::var(ENV_AUTH_CHECK_MELT_QUOTE) { + if let Ok(auth_type) = check_melt_quote_str.parse() { + self.check_melt_quote = auth_type; } } - if let Ok(enabled_check_mint_str) = env::var(ENV_AUTH_ENABLED_CHECK_MINT_QUOTE) { - if let Ok(enabled) = enabled_check_mint_str.parse() { - self.enabled_check_mint_quote = enabled; + if let Ok(swap_str) = env::var(ENV_AUTH_SWAP) { + if let Ok(auth_type) = swap_str.parse() { + self.swap = auth_type; } } - if let Ok(enabled_check_melt_str) = env::var(ENV_AUTH_ENABLED_CHECK_MELT_QUOTE) { - if let Ok(enabled) = enabled_check_melt_str.parse() { - self.enabled_check_melt_quote = enabled; + if let Ok(restore_str) = env::var(ENV_AUTH_RESTORE) { + if let Ok(auth_type) = restore_str.parse() { + self.restore = auth_type; } } - if let Ok(enabled_restore_str) = env::var(ENV_AUTH_ENABLED_RESTORE) { - if let Ok(enabled) = enabled_restore_str.parse() { - self.enabled_restore = enabled; + if let Ok(check_proof_state_str) = env::var(ENV_AUTH_CHECK_PROOF_STATE) { + if let Ok(auth_type) = check_proof_state_str.parse() { + self.check_proof_state = auth_type; } } - if let Ok(enabled_check_proof_str) = env::var(ENV_AUTH_ENABLED_CHECK_PROOF_STATE) { - if let Ok(enabled) = enabled_check_proof_str.parse() { - self.enabled_check_proof_state = enabled; + if let Ok(ws_auth_str) = env::var(ENV_AUTH_WEBSOCKET) { + if let Ok(auth_type) = ws_auth_str.parse() { + self.websocket_auth = auth_type; } } diff --git a/crates/cdk-mintd/src/env_vars/common.rs b/crates/cdk-mintd/src/env_vars/common.rs index 27b1cee98..f00b90479 100644 --- a/crates/cdk-mintd/src/env_vars/common.rs +++ b/crates/cdk-mintd/src/env_vars/common.rs @@ -2,9 +2,11 @@ pub const ENV_WORK_DIR: &str = "CDK_MINTD_WORK_DIR"; pub const DATABASE_ENV_VAR: &str = "CDK_MINTD_DATABASE"; +pub const DATABASE_URL_ENV_VAR: &str = "CDK_MINTD_DATABASE_URL"; // Legacy, maintained for backward compatibility pub const ENV_URL: &str = "CDK_MINTD_URL"; pub const ENV_LISTEN_HOST: &str = "CDK_MINTD_LISTEN_HOST"; pub const ENV_LISTEN_PORT: &str = "CDK_MINTD_LISTEN_PORT"; +pub const ENV_SEED: &str = "CDK_MINTD_SEED"; pub const ENV_MNEMONIC: &str = "CDK_MINTD_MNEMONIC"; pub const ENV_SIGNATORY_URL: &str = "CDK_MINTD_SIGNATORY_URL"; pub const ENV_SIGNATORY_CERTS: &str = "CDK_MINTD_SIGNATORY_CERTS"; @@ -12,4 +14,10 @@ pub const ENV_SECONDS_QUOTE_VALID: &str = "CDK_MINTD_SECONDS_QUOTE_VALID"; pub const ENV_CACHE_SECONDS: &str = "CDK_MINTD_CACHE_SECONDS"; pub const ENV_EXTEND_CACHE_SECONDS: &str = "CDK_MINTD_EXTEND_CACHE_SECONDS"; pub const ENV_INPUT_FEE_PPK: &str = "CDK_MINTD_INPUT_FEE_PPK"; +pub const ENV_QUOTE_TTL_MINT: &str = "CDK_MINTD_QUOTE_TTL_MINT"; +pub const ENV_QUOTE_TTL_MELT: &str = "CDK_MINTD_QUOTE_TTL_MELT"; + pub const ENV_ENABLE_SWAGGER: &str = "CDK_MINTD_ENABLE_SWAGGER"; +pub const ENV_LOGGING_OUTPUT: &str = "CDK_MINTD_LOGGING_OUTPUT"; +pub const ENV_LOGGING_CONSOLE_LEVEL: &str = "CDK_MINTD_LOGGING_CONSOLE_LEVEL"; +pub const ENV_LOGGING_FILE_LEVEL: &str = "CDK_MINTD_LOGGING_FILE_LEVEL"; diff --git a/crates/cdk-mintd/src/env_vars/database.rs b/crates/cdk-mintd/src/env_vars/database.rs new file mode 100644 index 000000000..6b71ed7d9 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/database.rs @@ -0,0 +1,72 @@ +//! Database environment variables + +use std::env; + +use crate::config::{PostgresAuthConfig, PostgresConfig}; + +pub const ENV_POSTGRES_URL: &str = "CDK_MINTD_POSTGRES_URL"; +pub const ENV_POSTGRES_TLS_MODE: &str = "CDK_MINTD_POSTGRES_TLS_MODE"; +pub const ENV_POSTGRES_MAX_CONNECTIONS: &str = "CDK_MINTD_POSTGRES_MAX_CONNECTIONS"; +pub const ENV_POSTGRES_CONNECTION_TIMEOUT: &str = "CDK_MINTD_POSTGRES_CONNECTION_TIMEOUT_SECONDS"; + +pub const ENV_AUTH_POSTGRES_URL: &str = "CDK_MINTD_AUTH_POSTGRES_URL"; +pub const ENV_AUTH_POSTGRES_TLS_MODE: &str = "CDK_MINTD_AUTH_POSTGRES_TLS_MODE"; +pub const ENV_AUTH_POSTGRES_MAX_CONNECTIONS: &str = "CDK_MINTD_AUTH_POSTGRES_MAX_CONNECTIONS"; +pub const ENV_AUTH_POSTGRES_CONNECTION_TIMEOUT: &str = + "CDK_MINTD_AUTH_POSTGRES_CONNECTION_TIMEOUT_SECONDS"; + +impl PostgresConfig { + pub fn from_env(mut self) -> Self { + // Check for new PostgreSQL URL env var first, then fallback to legacy DATABASE_URL + if let Ok(url) = env::var(ENV_POSTGRES_URL) { + self.url = url; + } else if let Ok(url) = env::var(super::DATABASE_URL_ENV_VAR) { + // Backward compatibility with the existing DATABASE_URL env var + self.url = url; + } + + if let Ok(tls_mode) = env::var(ENV_POSTGRES_TLS_MODE) { + self.tls_mode = Some(tls_mode); + } + + if let Ok(max_connections) = env::var(ENV_POSTGRES_MAX_CONNECTIONS) { + if let Ok(parsed) = max_connections.parse::() { + self.max_connections = Some(parsed); + } + } + + if let Ok(timeout) = env::var(ENV_POSTGRES_CONNECTION_TIMEOUT) { + if let Ok(parsed) = timeout.parse::() { + self.connection_timeout_seconds = Some(parsed); + } + } + + self + } +} + +impl PostgresAuthConfig { + pub fn from_env(mut self) -> Self { + if let Ok(url) = env::var(ENV_AUTH_POSTGRES_URL) { + self.url = url; + } + + if let Ok(tls_mode) = env::var(ENV_AUTH_POSTGRES_TLS_MODE) { + self.tls_mode = Some(tls_mode); + } + + if let Ok(max_connections) = env::var(ENV_AUTH_POSTGRES_MAX_CONNECTIONS) { + if let Ok(parsed) = max_connections.parse::() { + self.max_connections = Some(parsed); + } + } + + if let Ok(timeout) = env::var(ENV_AUTH_POSTGRES_CONNECTION_TIMEOUT) { + if let Ok(parsed) = timeout.parse::() { + self.connection_timeout_seconds = Some(parsed); + } + } + + self + } +} diff --git a/crates/cdk-mintd/src/env_vars/info.rs b/crates/cdk-mintd/src/env_vars/info.rs index 086e5a4fa..483087624 100644 --- a/crates/cdk-mintd/src/env_vars/info.rs +++ b/crates/cdk-mintd/src/env_vars/info.rs @@ -1,9 +1,12 @@ //! Info environment variables use std::env; +use std::str::FromStr; + +use cdk_common::common::QuoteTTL; use super::common::*; -use crate::config::Info; +use crate::config::{Info, LoggingOutput}; impl Info { pub fn from_env(mut self) -> Self { @@ -30,6 +33,10 @@ impl Info { self.signatory_certs = Some(signatory_certs); } + if let Ok(seed) = env::var(ENV_SEED) { + self.seed = Some(seed); + } + if let Ok(mnemonic) = env::var(ENV_MNEMONIC) { self.mnemonic = Some(mnemonic); } @@ -58,8 +65,49 @@ impl Info { } } + // Logging configuration + if let Ok(output_str) = env::var(ENV_LOGGING_OUTPUT) { + if let Ok(output) = LoggingOutput::from_str(&output_str) { + self.logging.output = output; + } else { + tracing::warn!( + "Invalid logging output '{}' in environment variable. Valid options: stdout, file, both", + output_str + ); + } + } + + if let Ok(console_level) = env::var(ENV_LOGGING_CONSOLE_LEVEL) { + self.logging.console_level = Some(console_level); + } + + if let Ok(file_level) = env::var(ENV_LOGGING_FILE_LEVEL) { + self.logging.file_level = Some(file_level); + } + self.http_cache = self.http_cache.from_env(); + // Quote TTL from env + let mut mint_ttl_env: Option = None; + let mut melt_ttl_env: Option = None; + if let Ok(mint_ttl_str) = env::var(ENV_QUOTE_TTL_MINT) { + if let Ok(v) = mint_ttl_str.parse::() { + mint_ttl_env = Some(v); + } + } + if let Ok(melt_ttl_str) = env::var(ENV_QUOTE_TTL_MELT) { + if let Ok(v) = melt_ttl_str.parse::() { + melt_ttl_env = Some(v); + } + } + if mint_ttl_env.is_some() || melt_ttl_env.is_some() { + let current = self.quote_ttl.unwrap_or_default(); + self.quote_ttl = Some(QuoteTTL { + mint_ttl: mint_ttl_env.unwrap_or(current.mint_ttl), + melt_ttl: melt_ttl_env.unwrap_or(current.melt_ttl), + }); + } + self } } diff --git a/crates/cdk-mintd/src/env_vars/ldk_node.rs b/crates/cdk-mintd/src/env_vars/ldk_node.rs new file mode 100644 index 000000000..fb6d61b68 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/ldk_node.rs @@ -0,0 +1,103 @@ +//! LDK Node environment variables + +use std::env; + +use crate::config::LdkNode; + +// LDK Node Environment Variables +pub const LDK_NODE_FEE_PERCENT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_FEE_PERCENT"; +pub const LDK_NODE_RESERVE_FEE_MIN_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_RESERVE_FEE_MIN"; +pub const LDK_NODE_BITCOIN_NETWORK_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIN_NETWORK"; +pub const LDK_NODE_CHAIN_SOURCE_TYPE_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_CHAIN_SOURCE_TYPE"; +pub const LDK_NODE_ESPLORA_URL_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_ESPLORA_URL"; +pub const LDK_NODE_BITCOIND_RPC_HOST_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_HOST"; +pub const LDK_NODE_BITCOIND_RPC_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_PORT"; +pub const LDK_NODE_BITCOIND_RPC_USER_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_USER"; +pub const LDK_NODE_BITCOIND_RPC_PASSWORD_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_BITCOIND_RPC_PASSWORD"; +pub const LDK_NODE_STORAGE_DIR_PATH_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH"; +pub const LDK_NODE_LDK_NODE_HOST_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_LDK_NODE_HOST"; +pub const LDK_NODE_LDK_NODE_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_LDK_NODE_PORT"; +pub const LDK_NODE_GOSSIP_SOURCE_TYPE_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE"; +pub const LDK_NODE_RGS_URL_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_RGS_URL"; +pub const LDK_NODE_WEBSERVER_HOST_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_WEBSERVER_HOST"; +pub const LDK_NODE_WEBSERVER_PORT_ENV_VAR: &str = "CDK_MINTD_LDK_NODE_WEBSERVER_PORT"; + +impl LdkNode { + pub fn from_env(mut self) -> Self { + if let Ok(fee_percent) = env::var(LDK_NODE_FEE_PERCENT_ENV_VAR) { + if let Ok(fee_percent) = fee_percent.parse::() { + self.fee_percent = fee_percent; + } + } + + if let Ok(reserve_fee_min) = env::var(LDK_NODE_RESERVE_FEE_MIN_ENV_VAR) { + if let Ok(reserve_fee_min) = reserve_fee_min.parse::() { + self.reserve_fee_min = reserve_fee_min.into(); + } + } + + if let Ok(bitcoin_network) = env::var(LDK_NODE_BITCOIN_NETWORK_ENV_VAR) { + self.bitcoin_network = Some(bitcoin_network); + } + + if let Ok(chain_source_type) = env::var(LDK_NODE_CHAIN_SOURCE_TYPE_ENV_VAR) { + self.chain_source_type = Some(chain_source_type); + } + + if let Ok(esplora_url) = env::var(LDK_NODE_ESPLORA_URL_ENV_VAR) { + self.esplora_url = Some(esplora_url); + } + + if let Ok(bitcoind_rpc_host) = env::var(LDK_NODE_BITCOIND_RPC_HOST_ENV_VAR) { + self.bitcoind_rpc_host = Some(bitcoind_rpc_host); + } + + if let Ok(bitcoind_rpc_port) = env::var(LDK_NODE_BITCOIND_RPC_PORT_ENV_VAR) { + if let Ok(bitcoind_rpc_port) = bitcoind_rpc_port.parse::() { + self.bitcoind_rpc_port = Some(bitcoind_rpc_port); + } + } + + if let Ok(bitcoind_rpc_user) = env::var(LDK_NODE_BITCOIND_RPC_USER_ENV_VAR) { + self.bitcoind_rpc_user = Some(bitcoind_rpc_user); + } + + if let Ok(bitcoind_rpc_password) = env::var(LDK_NODE_BITCOIND_RPC_PASSWORD_ENV_VAR) { + self.bitcoind_rpc_password = Some(bitcoind_rpc_password); + } + + if let Ok(storage_dir_path) = env::var(LDK_NODE_STORAGE_DIR_PATH_ENV_VAR) { + self.storage_dir_path = Some(storage_dir_path); + } + + if let Ok(ldk_node_host) = env::var(LDK_NODE_LDK_NODE_HOST_ENV_VAR) { + self.ldk_node_host = Some(ldk_node_host); + } + + if let Ok(ldk_node_port) = env::var(LDK_NODE_LDK_NODE_PORT_ENV_VAR) { + if let Ok(ldk_node_port) = ldk_node_port.parse::() { + self.ldk_node_port = Some(ldk_node_port); + } + } + + if let Ok(gossip_source_type) = env::var(LDK_NODE_GOSSIP_SOURCE_TYPE_ENV_VAR) { + self.gossip_source_type = Some(gossip_source_type); + } + + if let Ok(rgs_url) = env::var(LDK_NODE_RGS_URL_ENV_VAR) { + self.rgs_url = Some(rgs_url); + } + + if let Ok(webserver_host) = env::var(LDK_NODE_WEBSERVER_HOST_ENV_VAR) { + self.webserver_host = Some(webserver_host); + } + + if let Ok(webserver_port) = env::var(LDK_NODE_WEBSERVER_PORT_ENV_VAR) { + if let Ok(webserver_port) = webserver_port.parse::() { + self.webserver_port = Some(webserver_port); + } + } + + self + } +} diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index 662432bdb..3c56364b4 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -4,6 +4,7 @@ //! organized by component. mod common; +mod database; mod info; mod ln; mod mint_info; @@ -16,26 +17,36 @@ mod cln; mod fake_wallet; #[cfg(feature = "grpc-processor")] mod grpc_processor; +#[cfg(feature = "ldk-node")] +mod ldk_node; #[cfg(feature = "lnbits")] mod lnbits; #[cfg(feature = "lnd")] mod lnd; #[cfg(feature = "management-rpc")] mod management_rpc; +#[cfg(feature = "portalwallet")] +mod portal_wallet; +#[cfg(feature = "prometheus")] +mod prometheus; use std::env; use std::str::FromStr; +use crate::config::{DatabaseEngine, LnBackend, Settings}; use anyhow::{anyhow, bail, Result}; #[cfg(feature = "auth")] pub use auth::*; #[cfg(feature = "cln")] pub use cln::*; pub use common::*; +pub use database::*; #[cfg(feature = "fakewallet")] pub use fake_wallet::*; #[cfg(feature = "grpc-processor")] pub use grpc_processor::*; +#[cfg(feature = "ldk-node")] +pub use ldk_node::*; pub use ln::*; #[cfg(feature = "lnbits")] pub use lnbits::*; @@ -44,14 +55,42 @@ pub use lnd::*; #[cfg(feature = "management-rpc")] pub use management_rpc::*; pub use mint_info::*; - -use crate::config::{Database, DatabaseEngine, LnBackend, Settings}; +#[cfg(feature = "portalwallet")] +pub use portal_wallet::*; +#[cfg(feature = "prometheus")] +pub use prometheus::*; impl Settings { pub fn from_env(&mut self) -> Result { if let Ok(database) = env::var(DATABASE_ENV_VAR) { let engine = DatabaseEngine::from_str(&database).map_err(|err| anyhow!(err))?; - self.database = Database { engine }; + self.database.engine = engine; + } + + // Parse PostgreSQL-specific configuration from environment variables + if self.database.engine == DatabaseEngine::Postgres { + self.database.postgres = Some( + self.database + .postgres + .clone() + .unwrap_or_default() + .from_env(), + ); + } + + // Parse auth database configuration from environment variables (when auth is enabled) + #[cfg(feature = "auth")] + { + self.auth_database = Some(crate::config::AuthDatabase { + postgres: Some( + self.auth_database + .clone() + .unwrap_or_default() + .postgres + .unwrap_or_default() + .from_env(), + ), + }); } self.info = self.info.clone().from_env(); @@ -63,14 +102,8 @@ impl Settings { // Check env vars for auth config even if None let auth = self.auth.clone().unwrap_or_default().from_env(); - // Only set auth if env vars are present and have non-default values - if auth.openid_discovery != String::default() - || auth.openid_client_id != String::default() - || auth.mint_max_bat != 0 - || auth.enabled_mint - || auth.enabled_melt - || auth.enabled_swap - { + // Only set auth if auth_enabled flag is true + if auth.auth_enabled { self.auth = Some(auth); } else { self.auth = None; @@ -87,6 +120,11 @@ impl Settings { ); } + #[cfg(feature = "prometheus")] + { + self.prometheus = Some(self.prometheus.clone().unwrap_or_default().from_env()); + } + match self.ln.ln_backend { #[cfg(feature = "cln")] LnBackend::Cln => { @@ -104,11 +142,24 @@ impl Settings { LnBackend::Lnd => { self.lnd = Some(self.lnd.clone().unwrap_or_default().from_env()); } + #[cfg(feature = "ldk-node")] + LnBackend::LdkNode => { + self.ldk_node = Some(self.ldk_node.clone().unwrap_or_default().from_env()); + } #[cfg(feature = "grpc-processor")] LnBackend::GrpcProcessor => { self.grpc_processor = Some(self.grpc_processor.clone().unwrap_or_default().from_env()); } + #[cfg(feature = "portalwallet")] + LnBackend::PortalWallet => { + self.portal_wallet = Some( + self.portal_wallet + .clone() + .expect("Portal wallet config must be set") + .from_env(), + ); + } LnBackend::None => bail!("Ln backend must be set"), #[allow(unreachable_patterns)] _ => bail!("Selected Ln backend is not enabled in this build"), diff --git a/crates/cdk-mintd/src/env_vars/portal_wallet.rs b/crates/cdk-mintd/src/env_vars/portal_wallet.rs new file mode 100644 index 000000000..1b0f4a273 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/portal_wallet.rs @@ -0,0 +1,89 @@ +//! PortalWallet environment variables + +use std::env; + +use cdk::nuts::CurrencyUnit; + +use crate::config::PortalWallet; + +use cdk_common::common::UnitMetadata; + +// Fake Wallet environment variables +pub const ENV_PORTAL_WALLET_SUPPORTED_UNITS: &str = "CDK_MINTD_PORTAL_WALLET_SUPPORTED_UNITS"; +pub const ENV_PORTAL_WALLET_UNIT_INFO: &str = "CDK_MINTD_PORTAL_WALLET_UNIT_INFO"; + +#[derive(Debug)] +struct UnitInfo { + unit: CurrencyUnit, + description: String, + url: String, + is_non_fungible: bool, +} + +impl core::str::FromStr for UnitInfo { + type Err = String; + + fn from_str(s: &str) -> Result { + let (unit, remaining) = s.split_once('=').ok_or("Invalid format")?; + let mut parts = remaining.split('\n'); + + let description = parts.next().ok_or("Invalid format")?; + let url = parts.next().ok_or("Invalid format")?; + + let is_non_fungible = parts + .next() + .ok_or("Invalid format")? + .parse() + .map_err(|_| "Invalid is_non_fungible")?; + + Ok(Self { + unit: unit.parse().map_err(|_| "Invalid unit")?, + description: description.to_string(), + url: url.to_string(), + is_non_fungible, + }) + + } + + +} + +impl PortalWallet { + pub fn from_env(mut self) -> Self { + // Supported Units - expects comma-separated list + if let Ok(units_str) = env::var(ENV_PORTAL_WALLET_SUPPORTED_UNITS) { + if let Ok(units) = units_str + .split(',') + .map(|s| s.trim().parse()) + .collect::, _>>() + { + self.supported_units = units; + } + } + + // Unit Info - expects comma-separated list + if let Ok(unit_info_str) = env::var(ENV_PORTAL_WALLET_UNIT_INFO) { + if let Ok(unit_info) = unit_info_str + .split(',') + .map(|s| s.parse()) + .collect::, _>>() + { + self.unit_info = unit_info + .into_iter() + .map(|u| { + ( + u.unit, + UnitMetadata { + description: u.description, + url: u.url, + is_non_fungible: u.is_non_fungible, + }, + ) + }) + .collect(); + } + } + + self + } +} diff --git a/crates/cdk-mintd/src/env_vars/prometheus.rs b/crates/cdk-mintd/src/env_vars/prometheus.rs new file mode 100644 index 000000000..446bc64e4 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/prometheus.rs @@ -0,0 +1,31 @@ +//! Prometheus environment variables + +use std::env; + +use crate::config::Prometheus; + +pub const ENV_PROMETHEUS_ENABLED: &str = "CDK_MINTD_PROMETHEUS_ENABLED"; +pub const ENV_PROMETHEUS_ADDRESS: &str = "CDK_MINTD_PROMETHEUS_ADDRESS"; +pub const ENV_PROMETHEUS_PORT: &str = "CDK_MINTD_PROMETHEUS_PORT"; + +impl Prometheus { + pub fn from_env(mut self) -> Self { + if let Ok(enabled_str) = env::var(ENV_PROMETHEUS_ENABLED) { + if let Ok(enabled) = enabled_str.parse() { + self.enabled = enabled; + } + } + + if let Ok(address) = env::var(ENV_PROMETHEUS_ADDRESS) { + self.address = Some(address); + } + + if let Ok(port_str) = env::var(ENV_PROMETHEUS_PORT) { + if let Ok(port) = port_str.parse() { + self.port = Some(port); + } + } + + self + } +} diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index a8268fd94..d53e92e61 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -1,13 +1,81 @@ //! Cdk mintd lib -#[cfg(feature = "cln")] -use std::path::PathBuf; +// std +#[cfg(feature = "auth")] +use std::collections::HashMap; +use std::env::{self}; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::str::FromStr; +use std::sync::Arc; + +// external crates +use anyhow::{anyhow, bail, Result}; +use axum::Router; +use bip39::Mnemonic; +use cdk::cdk_database::{self, MintDatabase, MintKVStore, MintKeysDatabase}; +use cdk::mint::{Mint, MintBuilder, MintMeltLimits}; +#[cfg(any( + feature = "cln", + feature = "lnbits", + feature = "lnd", + feature = "ldk-node", + feature = "fakewallet", + feature = "grpc-processor", + feature = "portalwallet" +))] +use cdk::nuts::nut17::SupportedMethods; +use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path}; +#[cfg(any( + feature = "cln", + feature = "lnbits", + feature = "lnd", + feature = "ldk-node", + feature = "fakewallet", + feature = "portalwallet" +))] +use cdk::nuts::CurrencyUnit; +#[cfg(feature = "auth")] +use cdk::nuts::{AuthRequired, Method, ProtectedEndpoint, RoutePath}; +use cdk::nuts::{ContactInfo, MintVersion, PaymentMethod}; +use cdk_axum::cache::HttpCache; +use cdk_common::common::QuoteTTL; +use cdk_common::database::DynMintDatabase; +// internal crate modules +#[cfg(feature = "prometheus")] +use cdk_common::payment::MetricsMintPayment; +use cdk_common::payment::MintPayment; +#[cfg(all(feature = "auth", feature = "postgres"))] +use cdk_postgres::MintPgAuthDatabase; +#[cfg(feature = "postgres")] +use cdk_postgres::MintPgDatabase; +#[cfg(all(feature = "auth", feature = "sqlite"))] +use cdk_sqlite::mint::MintSqliteAuthDatabase; +#[cfg(feature = "sqlite")] +use cdk_sqlite::MintSqliteDatabase; +use cli::CLIArgs; +#[cfg(feature = "auth")] +use config::AuthType; +use config::{DatabaseEngine, LnBackend}; +use env_vars::ENV_WORK_DIR; +use setup::LnBackendSetup; +use tower::ServiceBuilder; +use tower_http::compression::CompressionLayer; +use tower_http::decompression::RequestDecompressionLayer; +use tower_http::trace::TraceLayer; +use tracing_appender::{non_blocking, rolling}; +use tracing_subscriber::fmt::writer::MakeWriterExt; +use tracing_subscriber::EnvFilter; +#[cfg(feature = "swagger")] +use utoipa::OpenApi; pub mod cli; pub mod config; pub mod env_vars; pub mod setup; +const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); + #[cfg(feature = "cln")] fn expand_path(path: &str) -> Option { if path.starts_with('~') { @@ -23,3 +91,1178 @@ fn expand_path(path: &str) -> Option { Some(PathBuf::from(path)) } } + +/// Performs the initial setup for the application, including configuring tracing, +/// parsing CLI arguments, setting up the working directory, loading settings, +/// and initializing the database connection. +async fn initial_setup( + work_dir: &Path, + settings: &config::Settings, + db_password: Option, +) -> Result<( + DynMintDatabase, + Arc + Send + Sync>, + Arc + Send + Sync>, +)> { + let (localstore, keystore, kv) = setup_database(settings, work_dir, db_password).await?; + Ok((localstore, keystore, kv)) +} + +/// Sets up and initializes a tracing subscriber with custom log filtering. +/// Logs can be configured to output to stdout only, file only, or both. +/// Returns a guard that must be kept alive and properly dropped on shutdown. +pub fn setup_tracing( + work_dir: &Path, + logging_config: &config::LoggingConfig, +) -> Result> { + let default_filter = "debug"; + let hyper_filter = "hyper=warn,rustls=warn,reqwest=warn"; + let h2_filter = "h2=warn"; + let tower_http = "tower_http=warn"; + let rustls = "rustls=warn"; + + let env_filter = EnvFilter::new(format!( + "{default_filter},{hyper_filter},{h2_filter},{tower_http},{rustls}" + )); + + use config::LoggingOutput; + match logging_config.output { + LoggingOutput::Stderr => { + // Console output only (stderr) + let console_level = logging_config + .console_level + .as_deref() + .unwrap_or("info") + .parse::() + .unwrap_or(tracing::Level::INFO); + + let stderr = std::io::stderr.with_max_level(console_level); + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(stderr) + .init(); + + tracing::info!("Logging initialized: console only ({}+)", console_level); + Ok(None) + } + LoggingOutput::File => { + // File output only + let file_level = logging_config + .file_level + .as_deref() + .unwrap_or("debug") + .parse::() + .unwrap_or(tracing::Level::DEBUG); + + // Create logs directory in work_dir if it doesn't exist + let logs_dir = work_dir.join("logs"); + std::fs::create_dir_all(&logs_dir)?; + + // Set up file appender with daily rotation + let file_appender = rolling::daily(&logs_dir, "cdk-mintd.log"); + let (non_blocking_appender, guard) = non_blocking(file_appender); + + let file_writer = non_blocking_appender.with_max_level(file_level); + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(file_writer) + .init(); + + tracing::info!( + "Logging initialized: file only at {}/cdk-mintd.log ({}+)", + logs_dir.display(), + file_level + ); + Ok(Some(guard)) + } + LoggingOutput::Both => { + // Both console and file output (stderr + file) + let console_level = logging_config + .console_level + .as_deref() + .unwrap_or("info") + .parse::() + .unwrap_or(tracing::Level::INFO); + let file_level = logging_config + .file_level + .as_deref() + .unwrap_or("debug") + .parse::() + .unwrap_or(tracing::Level::DEBUG); + + // Create logs directory in work_dir if it doesn't exist + let logs_dir = work_dir.join("logs"); + std::fs::create_dir_all(&logs_dir)?; + + // Set up file appender with daily rotation + let file_appender = rolling::daily(&logs_dir, "cdk-mintd.log"); + let (non_blocking_appender, guard) = non_blocking(file_appender); + + // Combine console output (stderr) and file output + let stderr = std::io::stderr.with_max_level(console_level); + let file_writer = non_blocking_appender.with_max_level(file_level); + + tracing_subscriber::fmt() + .with_env_filter(env_filter) + .with_writer(stderr.and(file_writer)) + .init(); + + tracing::info!( + "Logging initialized: console ({}+) and file at {}/cdk-mintd.log ({}+)", + console_level, + logs_dir.display(), + file_level + ); + Ok(Some(guard)) + } + } +} + +/// Retrieves the work directory based on command-line arguments, environment variables, or system defaults. +pub async fn get_work_directory(args: &CLIArgs) -> Result { + let work_dir = if let Some(work_dir) = &args.work_dir { + tracing::info!("Using work dir from cmd arg"); + work_dir.clone() + } else if let Ok(env_work_dir) = env::var(ENV_WORK_DIR) { + tracing::info!("Using work dir from env var"); + env_work_dir.into() + } else { + work_dir()? + }; + tracing::info!("Using work dir: {}", work_dir.display()); + Ok(work_dir) +} + +/// Loads the application settings based on a configuration file and environment variables. +pub fn load_settings(work_dir: &Path, config_path: Option) -> Result { + // get config file name from args + let config_file_arg = match config_path { + Some(c) => c, + None => work_dir.join("config.toml"), + }; + + let mut settings = if config_file_arg.exists() { + config::Settings::new(Some(config_file_arg)) + } else { + tracing::info!("Config file does not exist. Attempting to read env vars"); + config::Settings::default() + }; + + // ONLY FOR DEBUGGING BY PortalTechnologiesInc + tracing::trace!("Settings: {:?}", settings); + + // This check for any settings defined in ENV VARs + // ENV VARS will take **priority** over those in the config + settings.from_env() +} + +async fn setup_database( + settings: &config::Settings, + _work_dir: &Path, + _db_password: Option, +) -> Result<( + DynMintDatabase, + Arc + Send + Sync>, + Arc + Send + Sync>, +)> { + match settings.database.engine { + #[cfg(feature = "sqlite")] + DatabaseEngine::Sqlite => { + let db = setup_sqlite_database(_work_dir, _db_password).await?; + let localstore: Arc + Send + Sync> = db.clone(); + let kv: Arc + Send + Sync> = db.clone(); + let keystore: Arc + Send + Sync> = db; + Ok((localstore, keystore, kv)) + } + #[cfg(feature = "postgres")] + DatabaseEngine::Postgres => { + // Get the PostgreSQL configuration, ensuring it exists + let pg_config = settings.database.postgres.as_ref().ok_or_else(|| { + anyhow!("PostgreSQL configuration is required when using PostgreSQL engine") + })?; + + if pg_config.url.is_empty() { + bail!("PostgreSQL URL is required. Set it in config file [database.postgres] section or via CDK_MINTD_POSTGRES_URL/CDK_MINTD_DATABASE_URL environment variable"); + } + + #[cfg(feature = "postgres")] + let pg_db = Arc::new(MintPgDatabase::new(pg_config.url.as_str()).await?); + #[cfg(feature = "postgres")] + let localstore: Arc + Send + Sync> = + pg_db.clone(); + #[cfg(feature = "postgres")] + let kv: Arc + Send + Sync> = pg_db.clone(); + #[cfg(feature = "postgres")] + let keystore: Arc< + dyn MintKeysDatabase + Send + Sync, + > = pg_db; + #[cfg(feature = "postgres")] + return Ok((localstore, keystore, kv)); + + #[cfg(not(feature = "postgres"))] + bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.") + } + #[cfg(not(feature = "sqlite"))] + DatabaseEngine::Sqlite => { + bail!("SQLite support not compiled in. Enable the 'sqlite' feature to use SQLite database.") + } + #[cfg(not(feature = "postgres"))] + DatabaseEngine::Postgres => { + bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.") + } + } +} + +#[cfg(feature = "sqlite")] +async fn setup_sqlite_database( + work_dir: &Path, + _password: Option, +) -> Result> { + let sql_db_path = work_dir.join("cdk-mintd.sqlite"); + + #[cfg(not(feature = "sqlcipher"))] + let db = MintSqliteDatabase::new(&sql_db_path).await?; + #[cfg(feature = "sqlcipher")] + let db = { + // Get password from command line arguments for sqlcipher + MintSqliteDatabase::new((sql_db_path, _password.unwrap())).await? + }; + + Ok(Arc::new(db)) +} + +/** + * Configures a `MintBuilder` instance with provided settings and initializes + * routers for Lightning Network backends. + */ +async fn configure_mint_builder( + settings: &config::Settings, + mint_builder: MintBuilder, + runtime: Option>, + work_dir: &Path, + kv_store: Option + Send + Sync>>, +) -> Result { + // Configure basic mint information + let mint_builder = configure_basic_info(settings, mint_builder); + + // Configure lightning backend + let mint_builder = + configure_lightning_backend(settings, mint_builder, runtime, work_dir, kv_store).await?; + + // Configure caching + let mint_builder = configure_cache(settings, mint_builder); + + Ok(mint_builder) +} + +/// Configures basic mint information (name, contact info, descriptions, etc.) +fn configure_basic_info(settings: &config::Settings, mint_builder: MintBuilder) -> MintBuilder { + // Add contact information + let mut contacts = Vec::new(); + if let Some(nostr_key) = &settings.mint_info.contact_nostr_public_key { + contacts.push(ContactInfo::new("nostr".to_string(), nostr_key.to_string())); + } + if let Some(email) = &settings.mint_info.contact_email { + contacts.push(ContactInfo::new("email".to_string(), email.to_string())); + } + + // Add version information + let mint_version = MintVersion::new( + "cdk-mintd".to_string(), + CARGO_PKG_VERSION.unwrap_or("Unknown").to_string(), + ); + + // Configure mint builder with basic info + let mut builder = mint_builder + .with_name(settings.mint_info.name.clone()) + .with_version(mint_version) + .with_description(settings.mint_info.description.clone()); + + // Add optional information + if let Some(long_description) = &settings.mint_info.description_long { + builder = builder.with_long_description(long_description.to_string()); + } + + for contact in contacts { + builder = builder.with_contact_info(contact); + } + + if let Some(pubkey) = settings.mint_info.pubkey { + builder = builder.with_pubkey(pubkey); + } + + if let Some(icon_url) = &settings.mint_info.icon_url { + builder = builder.with_icon_url(icon_url.to_string()); + } + + if let Some(motd) = &settings.mint_info.motd { + builder = builder.with_motd(motd.to_string()); + } + + if let Some(tos_url) = &settings.mint_info.tos_url { + builder = builder.with_tos_url(tos_url.to_string()); + } + + builder +} +/// Configures Lightning Network backend based on the specified backend type +async fn configure_lightning_backend( + settings: &config::Settings, + mut mint_builder: MintBuilder, + _runtime: Option>, + work_dir: &Path, + _kv_store: Option + Send + Sync>>, +) -> Result { + let mint_melt_limits = MintMeltLimits { + mint_min: settings.ln.min_mint, + mint_max: settings.ln.max_mint, + melt_min: settings.ln.min_melt, + melt_max: settings.ln.max_melt, + }; + + tracing::debug!("Ln backend: {:?}", settings.ln.ln_backend); + + match settings.ln.ln_backend { + #[cfg(feature = "cln")] + LnBackend::Cln => { + let cln_settings = settings + .cln + .clone() + .expect("Config checked at load that cln is some"); + let cln = cln_settings + .setup(settings, CurrencyUnit::Msat, None, work_dir, _kv_store) + .await?; + #[cfg(feature = "prometheus")] + let cln = MetricsMintPayment::new(cln); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + CurrencyUnit::Sat, + mint_melt_limits, + Arc::new(cln), + ) + .await?; + } + #[cfg(feature = "lnbits")] + LnBackend::LNbits => { + let lnbits_settings = settings.clone().lnbits.expect("Checked on config load"); + let lnbits = lnbits_settings + .setup(settings, CurrencyUnit::Sat, None, work_dir, None) + .await?; + #[cfg(feature = "prometheus")] + let lnbits = MetricsMintPayment::new(lnbits); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + CurrencyUnit::Sat, + mint_melt_limits, + Arc::new(lnbits), + ) + .await?; + } + #[cfg(feature = "lnd")] + LnBackend::Lnd => { + let lnd_settings = settings.clone().lnd.expect("Checked at config load"); + let lnd = lnd_settings + .setup(settings, CurrencyUnit::Msat, None, work_dir, _kv_store) + .await?; + #[cfg(feature = "prometheus")] + let lnd = MetricsMintPayment::new(lnd); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + CurrencyUnit::Sat, + mint_melt_limits, + Arc::new(lnd), + ) + .await?; + } + #[cfg(feature = "fakewallet")] + LnBackend::FakeWallet => { + let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); + tracing::info!("Using fake wallet: {:?}", fake_wallet); + + for unit in fake_wallet.clone().supported_units { + let fake = fake_wallet + .setup(settings, unit.clone(), None, work_dir, _kv_store.clone()) + .await?; + #[cfg(feature = "prometheus")] + let fake = MetricsMintPayment::new(fake); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + unit.clone(), + mint_melt_limits, + Arc::new(fake), + ) + .await?; + } + } + #[cfg(feature = "grpc-processor")] + LnBackend::GrpcProcessor => { + let grpc_processor = settings + .clone() + .grpc_processor + .expect("grpc processor config defined"); + + tracing::info!( + "Attempting to start with gRPC payment processor at {}:{}.", + grpc_processor.addr, + grpc_processor.port + ); + + for unit in grpc_processor.clone().supported_units { + tracing::debug!("Adding unit: {:?}", unit); + let processor = grpc_processor + .setup(settings, unit.clone(), None, work_dir, None) + .await?; + #[cfg(feature = "prometheus")] + let processor = MetricsMintPayment::new(processor); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + unit.clone(), + mint_melt_limits, + Arc::new(processor), + ) + .await?; + } + } + #[cfg(feature = "ldk-node")] + LnBackend::LdkNode => { + let ldk_node_settings = settings.clone().ldk_node.expect("Checked at config load"); + tracing::info!("Using LDK Node backend: {:?}", ldk_node_settings); + + let ldk_node = ldk_node_settings + .setup(settings, CurrencyUnit::Sat, _runtime, work_dir, None) + .await?; + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + CurrencyUnit::Sat, + mint_melt_limits, + Arc::new(ldk_node), + ) + .await?; + } + #[cfg(feature = "portalwallet")] + LnBackend::PortalWallet => { + let portal_wallet = settings + .clone() + .portal_wallet + .expect("Portal wallet defined"); + tracing::info!("Using portal wallet: {:?}", portal_wallet); + + for unit in portal_wallet.clone().supported_units { + let portal = portal_wallet + .setup(settings, unit.clone(), None, work_dir, _kv_store.clone()) + .await?; + #[cfg(feature = "prometheus")] + let portal = MetricsMintPayment::new(portal); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + unit.clone(), + mint_melt_limits, + Arc::new(portal), + ) + .await?; + + + if let Some(unit_metadata) = portal_wallet.unit_info.get(&unit) { + mint_builder = mint_builder.set_unit_metadata(&unit, unit_metadata.clone()); + } + } + + + + } + LnBackend::None => { + tracing::error!( + "Payment backend was not set or feature disabled. {:?}", + settings.ln.ln_backend + ); + bail!("Lightning backend must be configured"); + } + }; + + Ok(mint_builder) +} + +/// Helper function to configure a mint builder with a lightning backend for a specific currency unit +async fn configure_backend_for_unit( + settings: &config::Settings, + mut mint_builder: MintBuilder, + unit: cdk::nuts::CurrencyUnit, + mint_melt_limits: MintMeltLimits, + backend: Arc + Send + Sync>, +) -> Result { + let payment_settings = backend.get_settings().await?; + + if let Some(bolt12) = payment_settings.get("bolt12") { + if bolt12.as_bool().unwrap_or_default() { + mint_builder + .add_payment_processor( + unit.clone(), + PaymentMethod::Bolt12, + mint_melt_limits, + Arc::clone(&backend), + ) + .await?; + + let nut17_supported = SupportedMethods::default_bolt12(unit.clone()); + mint_builder = mint_builder.with_supported_websockets(nut17_supported); + } + } + + mint_builder + .add_payment_processor( + unit.clone(), + PaymentMethod::Bolt11, + mint_melt_limits, + backend, + ) + .await?; + + if let Some(input_fee) = settings.info.input_fee_ppk { + mint_builder.set_unit_fee(&unit, input_fee)?; + } + + #[cfg(any( + feature = "cln", + feature = "lnbits", + feature = "lnd", + feature = "fakewallet", + feature = "grpc-processor", + feature = "ldk-node", + feature = "portalwallet" + ))] + { + let nut17_supported = SupportedMethods::default_bolt11(unit); + mint_builder = mint_builder.with_supported_websockets(nut17_supported); + } + + Ok(mint_builder) +} + +/// Configures cache settings +fn configure_cache(settings: &config::Settings, mint_builder: MintBuilder) -> MintBuilder { + let cached_endpoints = vec![ + CachedEndpoint::new(NUT19Method::Post, NUT19Path::MintBolt11), + CachedEndpoint::new(NUT19Method::Post, NUT19Path::MeltBolt11), + CachedEndpoint::new(NUT19Method::Post, NUT19Path::Swap), + ]; + + let cache: HttpCache = settings.info.http_cache.clone().into(); + mint_builder.with_cache(Some(cache.ttl.as_secs()), cached_endpoints) +} + +#[cfg(feature = "auth")] +async fn setup_authentication( + settings: &config::Settings, + _work_dir: &Path, + mut mint_builder: MintBuilder, + _password: Option, +) -> Result { + if let Some(auth_settings) = settings.auth.clone() { + use cdk_common::database::DynMintAuthDatabase; + + tracing::info!("Auth settings are defined. {:?}", auth_settings); + let auth_localstore: DynMintAuthDatabase = match settings.database.engine { + #[cfg(feature = "sqlite")] + DatabaseEngine::Sqlite => { + #[cfg(feature = "sqlite")] + { + let sql_db_path = _work_dir.join("cdk-mintd-auth.sqlite"); + #[cfg(not(feature = "sqlcipher"))] + let sqlite_db = MintSqliteAuthDatabase::new(&sql_db_path).await?; + #[cfg(feature = "sqlcipher")] + let sqlite_db = { + // Get password from command line arguments for sqlcipher + MintSqliteAuthDatabase::new((sql_db_path, _password.unwrap())).await? + }; + + Arc::new(sqlite_db) + } + #[cfg(not(feature = "sqlite"))] + { + bail!("SQLite support not compiled in. Enable the 'sqlite' feature to use SQLite database.") + } + } + #[cfg(feature = "postgres")] + DatabaseEngine::Postgres => { + #[cfg(feature = "postgres")] + { + // Require dedicated auth database configuration - no fallback to main database + let auth_db_config = settings.auth_database.as_ref().ok_or_else(|| { + anyhow!("Auth database configuration is required when using PostgreSQL with authentication. Set [auth_database] section in config file or CDK_MINTD_AUTH_POSTGRES_URL environment variable") + })?; + + let auth_pg_config = auth_db_config.postgres.as_ref().ok_or_else(|| { + anyhow!("PostgreSQL auth database configuration is required when using PostgreSQL with authentication. Set [auth_database.postgres] section in config file or CDK_MINTD_AUTH_POSTGRES_URL environment variable") + })?; + + if auth_pg_config.url.is_empty() { + bail!("Auth database PostgreSQL URL is required and cannot be empty. Set it in config file [auth_database.postgres] section or via CDK_MINTD_AUTH_POSTGRES_URL environment variable"); + } + + Arc::new(MintPgAuthDatabase::new(auth_pg_config.url.as_str()).await?) + } + #[cfg(not(feature = "postgres"))] + { + bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.") + } + } + #[cfg(not(feature = "sqlite"))] + DatabaseEngine::Sqlite => { + bail!("SQLite support not compiled in. Enable the 'sqlite' feature to use SQLite database.") + } + #[cfg(not(feature = "postgres"))] + DatabaseEngine::Postgres => { + bail!("PostgreSQL support not compiled in. Enable the 'postgres' feature to use PostgreSQL database.") + } + }; + + let mut protected_endpoints = HashMap::new(); + let mut blind_auth_endpoints = vec![]; + let mut clear_auth_endpoints = vec![]; + let mut unprotected_endpoints = vec![]; + + let mint_blind_auth_endpoint = + ProtectedEndpoint::new(Method::Post, RoutePath::MintBlindAuth); + + protected_endpoints.insert(mint_blind_auth_endpoint, AuthRequired::Clear); + + clear_auth_endpoints.push(mint_blind_auth_endpoint); + + // Helper function to add endpoint based on auth type + let mut add_endpoint = |endpoint: ProtectedEndpoint, auth_type: &AuthType| { + match auth_type { + AuthType::Blind => { + protected_endpoints.insert(endpoint, AuthRequired::Blind); + blind_auth_endpoints.push(endpoint); + } + AuthType::Clear => { + protected_endpoints.insert(endpoint, AuthRequired::Clear); + clear_auth_endpoints.push(endpoint); + } + AuthType::None => { + unprotected_endpoints.push(endpoint); + } + }; + }; + + // Get mint quote endpoint + { + let mint_quote_protected_endpoint = + ProtectedEndpoint::new(cdk::nuts::Method::Post, RoutePath::MintQuoteBolt11); + add_endpoint(mint_quote_protected_endpoint, &auth_settings.get_mint_quote); + } + + // Check mint quote endpoint + { + let check_mint_protected_endpoint = + ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11); + add_endpoint( + check_mint_protected_endpoint, + &auth_settings.check_mint_quote, + ); + } + + // Mint endpoint + { + let mint_protected_endpoint = + ProtectedEndpoint::new(cdk::nuts::Method::Post, RoutePath::MintBolt11); + add_endpoint(mint_protected_endpoint, &auth_settings.mint); + } + + // Get melt quote endpoint + { + let melt_quote_protected_endpoint = ProtectedEndpoint::new( + cdk::nuts::Method::Post, + cdk::nuts::RoutePath::MeltQuoteBolt11, + ); + add_endpoint(melt_quote_protected_endpoint, &auth_settings.get_melt_quote); + } + + // Check melt quote endpoint + { + let check_melt_protected_endpoint = + ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuoteBolt11); + add_endpoint( + check_melt_protected_endpoint, + &auth_settings.check_melt_quote, + ); + } + + // Melt endpoint + { + let melt_protected_endpoint = + ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt11); + add_endpoint(melt_protected_endpoint, &auth_settings.melt); + } + + // Swap endpoint + { + let swap_protected_endpoint = ProtectedEndpoint::new(Method::Post, RoutePath::Swap); + add_endpoint(swap_protected_endpoint, &auth_settings.swap); + } + + // Restore endpoint + { + let restore_protected_endpoint = + ProtectedEndpoint::new(Method::Post, RoutePath::Restore); + add_endpoint(restore_protected_endpoint, &auth_settings.restore); + } + + // Check proof state endpoint + { + let state_protected_endpoint = + ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate); + add_endpoint(state_protected_endpoint, &auth_settings.check_proof_state); + } + + // Ws endpoint + { + let ws_protected_endpoint = ProtectedEndpoint::new(Method::Get, RoutePath::Ws); + add_endpoint(ws_protected_endpoint, &auth_settings.websocket_auth); + } + + mint_builder = mint_builder.with_auth( + auth_localstore.clone(), + auth_settings.openid_discovery, + auth_settings.openid_client_id, + clear_auth_endpoints, + ); + mint_builder = + mint_builder.with_blind_auth(auth_settings.mint_max_bat, blind_auth_endpoints); + + // Set static auth token if configured + if let Some(static_token) = &auth_settings.static_auth_token { + mint_builder = mint_builder.with_static_auth_token(static_token.clone()); + } + + let mut tx = auth_localstore.begin_transaction().await?; + + tx.remove_protected_endpoints(unprotected_endpoints).await?; + tx.add_protected_endpoints(protected_endpoints).await?; + tx.commit().await?; + } + Ok(mint_builder) +} + +/// Build mints with the configured the signing method (remote signatory or local seed) +async fn build_mint( + settings: &config::Settings, + keystore: Arc + Send + Sync>, + mint_builder: MintBuilder, +) -> Result { + if let Some(signatory_url) = settings.info.signatory_url.clone() { + tracing::info!( + "Connecting to remote signatory to {} with certs {:?}", + signatory_url, + settings.info.signatory_certs.clone() + ); + + Ok(mint_builder + .build_with_signatory(Arc::new( + cdk_signatory::SignatoryRpcClient::new( + signatory_url, + settings.info.signatory_certs.clone(), + ) + .await?, + )) + .await?) + } else if let Some(seed) = settings.info.seed.clone() { + let seed_bytes: Vec = seed.into(); + Ok(mint_builder.build_with_seed(keystore, &seed_bytes).await?) + } else if let Some(mnemonic) = settings + .info + .mnemonic + .clone() + .map(|s| Mnemonic::from_str(&s)) + .transpose()? + { + Ok(mint_builder + .build_with_seed(keystore, &mnemonic.to_seed_normalized("")) + .await?) + } else { + bail!("No seed nor remote signatory set"); + } +} + +async fn start_services_with_shutdown( + mint: Arc, + settings: &config::Settings, + work_dir: &Path, + mint_builder_info: cdk::nuts::MintInfo, + shutdown_signal: impl std::future::Future + Send + 'static, + routers: Vec, +) -> Result<()> { + let listen_addr = settings.info.listen_host.clone(); + let listen_port = settings.info.listen_port; + let cache: HttpCache = settings.info.http_cache.clone().into(); + + #[cfg(feature = "management-rpc")] + let mut rpc_enabled = false; + #[cfg(not(feature = "management-rpc"))] + let rpc_enabled = false; + + #[cfg(feature = "management-rpc")] + let mut rpc_server: Option = None; + + #[cfg(feature = "management-rpc")] + { + if let Some(rpc_settings) = settings.mint_management_rpc.clone() { + if rpc_settings.enabled { + let addr = rpc_settings.address.unwrap_or("127.0.0.1".to_string()); + let port = rpc_settings.port.unwrap_or(8086); + let mut mint_rpc = cdk_mint_rpc::MintRPCServer::new(&addr, port, mint.clone())?; + + let tls_dir = rpc_settings.tls_dir_path.unwrap_or(work_dir.join("tls")); + + let tls_dir = if tls_dir.exists() { + Some(tls_dir) + } else { + tracing::warn!( + "TLS directory does not exist: {}. Starting RPC server in INSECURE mode without TLS encryption", + tls_dir.display() + ); + None + }; + + mint_rpc.start(tls_dir).await?; + + rpc_server = Some(mint_rpc); + + rpc_enabled = true; + } + } + } + + // Determine the desired QuoteTTL from config/env or fall back to defaults + let desired_quote_ttl: QuoteTTL = settings.info.quote_ttl.unwrap_or_default(); + + if rpc_enabled { + if mint.mint_info().await.is_err() { + tracing::info!("Mint info not set on mint, setting."); + // First boot with RPC enabled: seed from config + mint.set_mint_info(mint_builder_info).await?; + mint.set_quote_ttl(desired_quote_ttl).await?; + } else { + // If QuoteTTL has never been persisted, seed it now from config + if !mint.quote_ttl_is_persisted().await? { + mint.set_quote_ttl(desired_quote_ttl).await?; + } + // Add/refresh version information without altering stored mint_info fields + let mint_version = MintVersion::new( + "cdk-mintd".to_string(), + CARGO_PKG_VERSION.unwrap_or("Unknown").to_string(), + ); + let mut stored_mint_info = mint.mint_info().await?; + stored_mint_info.version = Some(mint_version); + mint.set_mint_info(stored_mint_info).await?; + + tracing::info!("Mint info already set, not using config file settings."); + } + } else { + // RPC disabled: config is source of truth on every boot + tracing::info!("RPC not enabled, using mint info and quote TTL from config."); + let mut mint_builder_info = mint_builder_info; + + if let Ok(mint_info) = mint.mint_info().await { + if mint_builder_info.pubkey.is_none() { + mint_builder_info.pubkey = mint_info.pubkey; + } + } + + mint.set_mint_info(mint_builder_info).await?; + mint.set_quote_ttl(desired_quote_ttl).await?; + } + + let mint_info = mint.mint_info().await?; + let nut04_methods = mint_info.nuts.nut04.supported_methods(); + let nut05_methods = mint_info.nuts.nut05.supported_methods(); + + let bolt12_supported = nut04_methods.contains(&&PaymentMethod::Bolt12) + || nut05_methods.contains(&&PaymentMethod::Bolt12); + + let v1_service = + cdk_axum::create_mint_router_with_custom_cache(Arc::clone(&mint), cache, bolt12_supported) + .await?; + + let mut mint_service = Router::new() + .merge(v1_service) + .layer( + ServiceBuilder::new() + .layer(RequestDecompressionLayer::new()) + .layer(CompressionLayer::new()), + ) + .layer(TraceLayer::new_for_http()); + + for router in routers { + mint_service = mint_service.merge(router); + } + + #[cfg(feature = "swagger")] + { + if settings.info.enable_swagger_ui.unwrap_or(false) { + mint_service = mint_service.merge( + utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", cdk_axum::ApiDoc::openapi()), + ); + } + } + // Create a broadcast channel to share shutdown signal between services + let (shutdown_tx, _) = tokio::sync::broadcast::channel::<()>(1); + + // Start Prometheus server if enabled + #[cfg(feature = "prometheus")] + let prometheus_handle = { + if let Some(prometheus_settings) = &settings.prometheus { + if prometheus_settings.enabled { + let addr = prometheus_settings + .address + .clone() + .unwrap_or("127.0.0.1".to_string()); + let port = prometheus_settings.port.unwrap_or(9000); + + let address = format!("{}:{}", addr, port) + .parse() + .expect("Invalid prometheus address"); + + let server = cdk_prometheus::PrometheusBuilder::new() + .bind_address(address) + .build_with_cdk_metrics()?; + + let mut shutdown_rx = shutdown_tx.subscribe(); + let prometheus_shutdown = async move { + let _ = shutdown_rx.recv().await; + }; + + Some(tokio::spawn(async move { + if let Err(e) = server.start(prometheus_shutdown).await { + tracing::error!("Failed to start prometheus server: {}", e); + } + })) + } else { + None + } + } else { + None + } + }; + + #[cfg(not(feature = "prometheus"))] + let prometheus_handle: Option> = None; + + mint.start().await?; + + let socket_addr = SocketAddr::from_str(&format!("{listen_addr}:{listen_port}"))?; + + let listener = tokio::net::TcpListener::bind(socket_addr).await?; + + tracing::info!("listening on {}", listener.local_addr().unwrap()); + + // Create a task to wait for the shutdown signal and broadcast it + let shutdown_broadcast_task = { + let shutdown_tx = shutdown_tx.clone(); + tokio::spawn(async move { + shutdown_signal.await; + tracing::info!("Shutdown signal received, broadcasting to all services"); + let _ = shutdown_tx.send(()); + }) + }; + + // Create shutdown future for axum server + let mut axum_shutdown_rx = shutdown_tx.subscribe(); + let axum_shutdown = async move { + let _ = axum_shutdown_rx.recv().await; + }; + + // Wait for axum server to complete with custom shutdown signal + let axum_result = axum::serve(listener, mint_service).with_graceful_shutdown(axum_shutdown); + + match axum_result.await { + Ok(_) => { + tracing::info!("Axum server stopped with okay status"); + } + Err(err) => { + tracing::warn!("Axum server stopped with error"); + tracing::error!("{}", err); + bail!("Axum exited with error") + } + } + + // Wait for the shutdown broadcast task to complete + let _ = shutdown_broadcast_task.await; + + // Wait for prometheus server to shutdown if it was started + #[cfg(feature = "prometheus")] + if let Some(handle) = prometheus_handle { + if let Err(e) = handle.await { + tracing::warn!("Prometheus server task failed: {}", e); + } + } + + mint.stop().await?; + + #[cfg(feature = "management-rpc")] + { + if let Some(rpc_server) = rpc_server { + rpc_server.stop().await?; + } + } + + Ok(()) +} + +async fn shutdown_signal() { + tokio::signal::ctrl_c() + .await + .expect("failed to install CTRL+C handler"); + tracing::info!("Shutdown signal received"); +} + +fn work_dir() -> Result { + let home_dir = home::home_dir().ok_or(anyhow!("Unknown home dir"))?; + let dir = home_dir.join(".cdk-mintd"); + + std::fs::create_dir_all(&dir)?; + + Ok(dir) +} + +/// The main entry point for the application when used as a library +pub async fn run_mintd( + work_dir: &Path, + settings: &config::Settings, + db_password: Option, + enable_logging: bool, + runtime: Option>, + routers: Vec, +) -> Result<()> { + let _guard = if enable_logging { + setup_tracing(work_dir, &settings.info.logging)? + } else { + None + }; + + let result = run_mintd_with_shutdown( + work_dir, + settings, + shutdown_signal(), + db_password, + runtime, + routers, + ) + .await; + + // Explicitly drop the guard to ensure proper cleanup + if let Some(guard) = _guard { + tracing::info!("Shutting down logging worker thread"); + drop(guard); + // Give the worker thread a moment to flush any remaining logs + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + } + + tracing::info!("Mintd shutdown"); + + result +} + +/// Run mintd with a custom shutdown signal +pub async fn run_mintd_with_shutdown( + work_dir: &Path, + settings: &config::Settings, + shutdown_signal: impl std::future::Future + Send + 'static, + db_password: Option, + runtime: Option>, + routers: Vec, +) -> Result<()> { + let (localstore, keystore, kv) = initial_setup(work_dir, settings, db_password.clone()).await?; + + let mint_builder = MintBuilder::new(localstore); + + // If RPC is enabled and DB contains mint_info already, initialize the builder from DB. + // This ensures subsequent builder modifications (like version injection) can respect stored values. + let maybe_mint_builder = { + #[cfg(feature = "management-rpc")] + { + if let Some(rpc_settings) = settings.mint_management_rpc.clone() { + if rpc_settings.enabled { + // Best-effort: pull DB state into builder if present + let mut tmp = mint_builder; + if let Err(e) = tmp.init_from_db_if_present().await { + tracing::warn!("Failed to init builder from DB: {}", e); + } + tmp + } else { + mint_builder + } + } else { + mint_builder + } + } + #[cfg(not(feature = "management-rpc"))] + { + mint_builder + } + }; + + let mint_builder = + configure_mint_builder(settings, maybe_mint_builder, runtime, work_dir, Some(kv)).await?; + #[cfg(feature = "auth")] + let mint_builder = setup_authentication(settings, work_dir, mint_builder, db_password).await?; + + let config_mint_info = mint_builder.current_mint_info(); + + let mint = build_mint(settings, keystore, mint_builder).await?; + + tracing::debug!("Mint built from builder."); + + let mint = Arc::new(mint); + + start_services_with_shutdown( + mint.clone(), + settings, + work_dir, + config_mint_info, + shutdown_signal, + routers, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_postgres_auth_url_validation() { + // Test that the auth database config requires explicit configuration + + // Test empty URL + let auth_config = config::PostgresAuthConfig { + url: "".to_string(), + ..Default::default() + }; + assert!(auth_config.url.is_empty()); + + // Test non-empty URL + let auth_config = config::PostgresAuthConfig { + url: "postgresql://user:password@localhost:5432/auth_db".to_string(), + ..Default::default() + }; + assert!(!auth_config.url.is_empty()); + } +} diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 5f9966668..b21b97a05 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -2,687 +2,38 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] -use std::collections::HashMap; -use std::env; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::str::FromStr; use std::sync::Arc; -use anyhow::{anyhow, bail, Result}; -use axum::Router; -use bip39::Mnemonic; -use cdk::cdk_database::{self, MintAuthDatabase}; -use cdk::mint::{MintBuilder, MintMeltLimits}; -// Feature-gated imports -#[cfg(any( - feature = "cln", - feature = "lnbits", - feature = "lnd", - feature = "fakewallet", - feature = "grpc-processor" -))] -use cdk::nuts::nut17::SupportedMethods; -use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path}; -#[cfg(any( - feature = "cln", - feature = "lnbits", - feature = "lnd", - feature = "fakewallet" -))] -use cdk::nuts::CurrencyUnit; -use cdk::nuts::{ - AuthRequired, ContactInfo, MintVersion, PaymentMethod, ProtectedEndpoint, RoutePath, -}; -use cdk::types::QuoteTTL; -use cdk_axum::cache::HttpCache; -#[cfg(feature = "management-rpc")] -use cdk_mint_rpc::MintRPCServer; +use anyhow::Result; use cdk_mintd::cli::CLIArgs; -use cdk_mintd::config::{self, DatabaseEngine, LnBackend}; -use cdk_mintd::env_vars::ENV_WORK_DIR; -use cdk_mintd::setup::LnBackendSetup; -use cdk_sqlite::mint::MintSqliteAuthDatabase; -use cdk_sqlite::MintSqliteDatabase; +use cdk_mintd::{get_work_directory, load_settings}; use clap::Parser; -use tokio::sync::Notify; -use tower::ServiceBuilder; -use tower_http::compression::CompressionLayer; -use tower_http::decompression::RequestDecompressionLayer; -use tower_http::trace::TraceLayer; -use tracing_subscriber::EnvFilter; -#[cfg(feature = "swagger")] -use utoipa::OpenApi; +use tokio::runtime::Runtime; -const CARGO_PKG_VERSION: Option<&'static str> = option_env!("CARGO_PKG_VERSION"); +fn main() -> Result<()> { + let rt = Arc::new(Runtime::new()?); -// Ensure at least one lightning backend is enabled at compile time -#[cfg(not(any( - feature = "cln", - feature = "lnbits", - feature = "lnd", - feature = "fakewallet", - feature = "grpc-processor" -)))] -compile_error!( - "At least one lightning backend feature must be enabled: cln, lnbits, lnd, fakewallet, or grpc-processor" -); + let rt_clone = Arc::clone(&rt); -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let default_filter = "debug"; + rt.block_on(async { + let args = CLIArgs::parse(); + let work_dir = get_work_directory(&args).await?; + let settings = load_settings(&work_dir, args.config)?; - let sqlx_filter = "sqlx=warn"; - let hyper_filter = "hyper=warn"; - let h2_filter = "h2=warn"; - let tower_http = "tower_http=warn"; + #[cfg(feature = "sqlcipher")] + let password = Some(CLIArgs::parse().password); - let env_filter = EnvFilter::new(format!( - "{default_filter},{sqlx_filter},{hyper_filter},{h2_filter},{tower_http}" - )); + #[cfg(not(feature = "sqlcipher"))] + let password = None; - tracing_subscriber::fmt().with_env_filter(env_filter).init(); - - let args = CLIArgs::parse(); - - let work_dir = if let Some(work_dir) = args.work_dir { - tracing::info!("Using work dir from cmd arg"); - work_dir - } else if let Ok(env_work_dir) = env::var(ENV_WORK_DIR) { - tracing::info!("Using work dir from env var"); - env_work_dir.into() - } else { - work_dir()? - }; - - tracing::info!("Using work dir: {}", work_dir.display()); - - // get config file name from args - let config_file_arg = match args.config { - Some(c) => c, - None => work_dir.join("config.toml"), - }; - - let mut settings = if config_file_arg.exists() { - config::Settings::new(Some(config_file_arg)) - } else { - tracing::info!("Config file does not exist. Attempting to read env vars"); - config::Settings::default() - }; - - // This check for any settings defined in ENV VARs - // ENV VARS will take **priority** over those in the config - let settings = settings.from_env()?; - - let mut mint_builder = match settings.database.engine { - DatabaseEngine::Sqlite => { - let sql_db_path = work_dir.join("cdk-mintd.sqlite"); - #[cfg(not(feature = "sqlcipher"))] - let sqlite_db = MintSqliteDatabase::new(&sql_db_path).await?; - #[cfg(feature = "sqlcipher")] - let sqlite_db = MintSqliteDatabase::new(&sql_db_path, args.password.clone()).await?; - - let db = Arc::new(sqlite_db); - MintBuilder::new() - .with_localstore(db.clone()) - .with_keystore(db) - } - }; - - let mut contact_info: Option> = None; - - if let Some(nostr_contact) = &settings.mint_info.contact_nostr_public_key { - let nostr_contact = ContactInfo::new("nostr".to_string(), nostr_contact.to_string()); - - contact_info = match contact_info { - Some(mut vec) => { - vec.push(nostr_contact); - Some(vec) - } - None => Some(vec![nostr_contact]), - }; - } - - if let Some(email_contact) = &settings.mint_info.contact_email { - let email_contact = ContactInfo::new("email".to_string(), email_contact.to_string()); - - contact_info = match contact_info { - Some(mut vec) => { - vec.push(email_contact); - Some(vec) - } - None => Some(vec![email_contact]), - }; - } - - let mint_version = MintVersion::new( - "cdk-mintd".to_string(), - CARGO_PKG_VERSION.unwrap_or("Unknown").to_string(), - ); - - let mut ln_routers = vec![]; - - let mint_melt_limits = MintMeltLimits { - mint_min: settings.ln.min_mint, - mint_max: settings.ln.max_mint, - melt_min: settings.ln.min_melt, - melt_max: settings.ln.max_melt, - }; - - tracing::debug!("Ln backend: {:?}", settings.ln.ln_backend); - - match settings.ln.ln_backend { - #[cfg(feature = "cln")] - LnBackend::Cln => { - let cln_settings = settings - .cln - .clone() - .expect("Config checked at load that cln is some"); - - let cln = cln_settings - .setup(&mut ln_routers, &settings, CurrencyUnit::Msat) - .await?; - let cln = Arc::new(cln); - - mint_builder = mint_builder - .add_ln_backend( - CurrencyUnit::Sat, - PaymentMethod::Bolt11, - mint_melt_limits, - cln.clone(), - ) - .await?; - - if let Some(input_fee) = settings.info.input_fee_ppk { - mint_builder = mint_builder.set_unit_fee(&CurrencyUnit::Sat, input_fee)?; - } - - let nut17_supported = SupportedMethods::default_bolt11(CurrencyUnit::Sat); - - mint_builder = mint_builder.add_supported_websockets(nut17_supported); - } - #[cfg(feature = "lnbits")] - LnBackend::LNbits => { - let lnbits_settings = settings.clone().lnbits.expect("Checked on config load"); - let lnbits = lnbits_settings - .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) - .await?; - - mint_builder = mint_builder - .add_ln_backend( - CurrencyUnit::Sat, - PaymentMethod::Bolt11, - mint_melt_limits, - Arc::new(lnbits), - ) - .await?; - if let Some(input_fee) = settings.info.input_fee_ppk { - mint_builder = mint_builder.set_unit_fee(&CurrencyUnit::Sat, input_fee)?; - } - - let nut17_supported = SupportedMethods::default_bolt11(CurrencyUnit::Sat); - - mint_builder = mint_builder.add_supported_websockets(nut17_supported); - } - #[cfg(feature = "lnd")] - LnBackend::Lnd => { - let lnd_settings = settings.clone().lnd.expect("Checked at config load"); - let lnd = lnd_settings - .setup(&mut ln_routers, &settings, CurrencyUnit::Msat) - .await?; - - mint_builder = mint_builder - .add_ln_backend( - CurrencyUnit::Sat, - PaymentMethod::Bolt11, - mint_melt_limits, - Arc::new(lnd), - ) - .await?; - if let Some(input_fee) = settings.info.input_fee_ppk { - mint_builder = mint_builder.set_unit_fee(&CurrencyUnit::Sat, input_fee)?; - } - - let nut17_supported = SupportedMethods::default_bolt11(CurrencyUnit::Sat); - - mint_builder = mint_builder.add_supported_websockets(nut17_supported); - } - #[cfg(feature = "fakewallet")] - LnBackend::FakeWallet => { - let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); - tracing::info!("Using fake wallet: {:?}", fake_wallet); - - for unit in fake_wallet.clone().supported_units { - let fake = fake_wallet - .setup(&mut ln_routers, &settings, CurrencyUnit::Sat) - .await - .expect("hhh"); - - let fake = Arc::new(fake); - - mint_builder = mint_builder - .add_ln_backend( - unit.clone(), - PaymentMethod::Bolt11, - mint_melt_limits, - fake.clone(), - ) - .await?; - if let Some(input_fee) = settings.info.input_fee_ppk { - mint_builder = mint_builder.set_unit_fee(&unit, input_fee)?; - } - - let nut17_supported = SupportedMethods::default_bolt11(unit); - - mint_builder = mint_builder.add_supported_websockets(nut17_supported); - } - } - #[cfg(feature = "grpc-processor")] - LnBackend::GrpcProcessor => { - let grpc_processor = settings - .clone() - .grpc_processor - .expect("grpc processor config defined"); - - tracing::info!( - "Attempting to start with gRPC payment processor at {}:{}.", - grpc_processor.addr, - grpc_processor.port - ); - - tracing::info!("{:?}", grpc_processor); - - for unit in grpc_processor.clone().supported_units { - tracing::debug!("Adding unit: {:?}", unit); - - let processor = grpc_processor - .setup(&mut ln_routers, &settings, unit.clone()) - .await?; - - mint_builder = mint_builder - .add_ln_backend( - unit.clone(), - PaymentMethod::Bolt11, - mint_melt_limits, - Arc::new(processor), - ) - .await?; - if let Some(input_fee) = settings.info.input_fee_ppk { - mint_builder = mint_builder.set_unit_fee(&unit, input_fee)?; - } - - let nut17_supported = SupportedMethods::default_bolt11(unit); - mint_builder = mint_builder.add_supported_websockets(nut17_supported); - } - } - LnBackend::None => { - tracing::error!( - "Pyament backend was not set or feature disabled. {:?}", - settings.ln.ln_backend - ); - bail!("Ln backend must be") - } - }; - - if let Some(long_description) = &settings.mint_info.description_long { - mint_builder = mint_builder.with_long_description(long_description.to_string()); - } - - if let Some(contact_info) = contact_info { - for info in contact_info { - mint_builder = mint_builder.add_contact_info(info); - } - } - - if let Some(pubkey) = settings.mint_info.pubkey { - mint_builder = mint_builder.with_pubkey(pubkey); - } - - if let Some(icon_url) = &settings.mint_info.icon_url { - mint_builder = mint_builder.with_icon_url(icon_url.to_string()); - } - - if let Some(motd) = settings.mint_info.motd { - mint_builder = mint_builder.with_motd(motd); - } - - if let Some(tos_url) = &settings.mint_info.tos_url { - mint_builder = mint_builder.with_tos_url(tos_url.to_string()); - } - - mint_builder = mint_builder - .with_name(settings.mint_info.name) - .with_version(mint_version.clone()) - .with_description(settings.mint_info.description); - - mint_builder = if let Some(signatory_url) = settings.info.signatory_url { - tracing::info!( - "Connecting to remote signatory to {} with certs {:?}", - signatory_url, - settings.info.signatory_certs - ); - mint_builder.with_signatory(Arc::new( - cdk_signatory::SignatoryRpcClient::new(signatory_url, settings.info.signatory_certs) - .await?, - )) - } else if let Some(mnemonic) = settings - .info - .mnemonic - .map(|s| Mnemonic::from_str(&s)) - .transpose()? - { - mint_builder.with_seed(mnemonic.to_seed_normalized("").to_vec()) - } else { - bail!("No seed nor remote signatory set"); - }; - - let cached_endpoints = vec![ - CachedEndpoint::new(NUT19Method::Post, NUT19Path::MintBolt11), - CachedEndpoint::new(NUT19Method::Post, NUT19Path::MeltBolt11), - CachedEndpoint::new(NUT19Method::Post, NUT19Path::Swap), - ]; - - let cache: HttpCache = settings.info.http_cache.into(); - - mint_builder = mint_builder.add_cache(Some(cache.ttl.as_secs()), cached_endpoints); - - // Add auth to mint - if let Some(auth_settings) = settings.auth { - tracing::info!("Auth settings are defined. {:?}", auth_settings); - let auth_localstore: Arc + Send + Sync> = - match settings.database.engine { - DatabaseEngine::Sqlite => { - let sql_db_path = work_dir.join("cdk-mintd-auth.sqlite"); - #[cfg(not(feature = "sqlcipher"))] - let sqlite_db = MintSqliteAuthDatabase::new(&sql_db_path).await?; - #[cfg(feature = "sqlcipher")] - let sqlite_db = - MintSqliteAuthDatabase::new(&sql_db_path, args.password).await?; - - Arc::new(sqlite_db) - } - }; - - mint_builder = mint_builder.with_auth_localstore(auth_localstore.clone()); - - let mint_blind_auth_endpoint = - ProtectedEndpoint::new(cdk::nuts::Method::Post, RoutePath::MintBlindAuth); - - mint_builder = mint_builder.set_clear_auth_settings( - auth_settings.openid_discovery, - auth_settings.openid_client_id, - ); - - let mut protected_endpoints = HashMap::new(); - - protected_endpoints.insert(mint_blind_auth_endpoint, AuthRequired::Clear); - - let mut blind_auth_endpoints = vec![]; - let mut unprotected_endpoints = vec![]; - - { - let mint_quote_protected_endpoint = ProtectedEndpoint::new( - cdk::nuts::Method::Post, - cdk::nuts::RoutePath::MintQuoteBolt11, - ); - let mint_protected_endpoint = - ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::MintBolt11); - if auth_settings.enabled_mint { - protected_endpoints.insert(mint_quote_protected_endpoint, AuthRequired::Blind); - - protected_endpoints.insert(mint_protected_endpoint, AuthRequired::Blind); - - blind_auth_endpoints.push(mint_quote_protected_endpoint); - blind_auth_endpoints.push(mint_protected_endpoint); - } else { - unprotected_endpoints.push(mint_protected_endpoint); - unprotected_endpoints.push(mint_quote_protected_endpoint); - } - } - - { - let melt_quote_protected_endpoint = ProtectedEndpoint::new( - cdk::nuts::Method::Post, - cdk::nuts::RoutePath::MeltQuoteBolt11, - ); - let melt_protected_endpoint = - ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::MeltBolt11); - - if auth_settings.enabled_melt { - protected_endpoints.insert(melt_quote_protected_endpoint, AuthRequired::Blind); - protected_endpoints.insert(melt_protected_endpoint, AuthRequired::Blind); - - blind_auth_endpoints.push(melt_quote_protected_endpoint); - blind_auth_endpoints.push(melt_protected_endpoint); - } else { - unprotected_endpoints.push(melt_quote_protected_endpoint); - unprotected_endpoints.push(melt_protected_endpoint); - } - } - - { - let swap_protected_endpoint = - ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Swap); - - if auth_settings.enabled_swap { - protected_endpoints.insert(swap_protected_endpoint, AuthRequired::Blind); - blind_auth_endpoints.push(swap_protected_endpoint); - } else { - unprotected_endpoints.push(swap_protected_endpoint); - } - } - - { - let check_mint_protected_endpoint = ProtectedEndpoint::new( - cdk::nuts::Method::Get, - cdk::nuts::RoutePath::MintQuoteBolt11, - ); - - if auth_settings.enabled_check_mint_quote { - protected_endpoints.insert(check_mint_protected_endpoint, AuthRequired::Blind); - blind_auth_endpoints.push(check_mint_protected_endpoint); - } else { - unprotected_endpoints.push(check_mint_protected_endpoint); - } - } - - { - let check_melt_protected_endpoint = ProtectedEndpoint::new( - cdk::nuts::Method::Get, - cdk::nuts::RoutePath::MeltQuoteBolt11, - ); - - if auth_settings.enabled_check_melt_quote { - protected_endpoints.insert(check_melt_protected_endpoint, AuthRequired::Blind); - blind_auth_endpoints.push(check_melt_protected_endpoint); - } else { - unprotected_endpoints.push(check_melt_protected_endpoint); - } - } - - { - let restore_protected_endpoint = - ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Restore); - - if auth_settings.enabled_restore { - protected_endpoints.insert(restore_protected_endpoint, AuthRequired::Blind); - blind_auth_endpoints.push(restore_protected_endpoint); - } else { - unprotected_endpoints.push(restore_protected_endpoint); - } - } - - { - let state_protected_endpoint = - ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Checkstate); - - if auth_settings.enabled_check_proof_state { - protected_endpoints.insert(state_protected_endpoint, AuthRequired::Blind); - blind_auth_endpoints.push(state_protected_endpoint); - } else { - unprotected_endpoints.push(state_protected_endpoint); - } - } - - mint_builder = mint_builder.set_blind_auth_settings(auth_settings.mint_max_bat); - - let mut tx = auth_localstore.begin_transaction().await?; - - tx.remove_protected_endpoints(unprotected_endpoints).await?; - tx.add_protected_endpoints(protected_endpoints).await?; - tx.commit().await?; - } - - let mint = mint_builder.build().await?; - - tracing::debug!("Mint built from builder."); - - let mint = Arc::new(mint); - - // Check the status of any mint quotes that are pending - // In the event that the mint server is down but the ln node is not - // it is possible that a mint quote was paid but the mint has not been updated - // this will check and update the mint state of those quotes - mint.check_pending_mint_quotes().await?; - - // 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?; - - let listen_addr = settings.info.listen_host; - let listen_port = settings.info.listen_port; - - let v1_service = - cdk_axum::create_mint_router_with_custom_cache(Arc::clone(&mint), cache).await?; - - let mut mint_service = Router::new() - .merge(v1_service) - .layer( - ServiceBuilder::new() - .layer(RequestDecompressionLayer::new()) - .layer(CompressionLayer::new()), + cdk_mintd::run_mintd( + &work_dir, + &settings, + password, + args.enable_logging, + Some(rt_clone), + vec![], ) - .layer(TraceLayer::new_for_http()); - - #[cfg(feature = "swagger")] - { - if settings.info.enable_swagger_ui.unwrap_or(false) { - mint_service = mint_service.merge( - utoipa_swagger_ui::SwaggerUi::new("/swagger-ui") - .url("/api-docs/openapi.json", cdk_axum::ApiDocV1::openapi()), - ); - } - } - - for router in ln_routers { - mint_service = mint_service.merge(router); - } - - let shutdown = Arc::new(Notify::new()); - let mint_clone = Arc::clone(&mint); - tokio::spawn({ - let shutdown = Arc::clone(&shutdown); - async move { mint_clone.wait_for_paid_invoices(shutdown).await } - }); - - #[cfg(feature = "management-rpc")] - let mut rpc_enabled = false; - #[cfg(not(feature = "management-rpc"))] - let rpc_enabled = false; - - #[cfg(feature = "management-rpc")] - let mut rpc_server: Option = None; - - #[cfg(feature = "management-rpc")] - { - if let Some(rpc_settings) = settings.mint_management_rpc { - if rpc_settings.enabled { - let addr = rpc_settings.address.unwrap_or("127.0.0.1".to_string()); - let port = rpc_settings.port.unwrap_or(8086); - let mut mint_rpc = MintRPCServer::new(&addr, port, mint.clone())?; - - let tls_dir = rpc_settings.tls_dir_path.unwrap_or(work_dir.join("tls")); - - if !tls_dir.exists() { - tracing::error!("TLS directory does not exist: {}", tls_dir.display()); - bail!("Cannot start RPC server: TLS directory does not exist"); - } - - mint_rpc.start(Some(tls_dir)).await?; - - rpc_server = Some(mint_rpc); - - rpc_enabled = true; - } - } - } - - if rpc_enabled { - if mint.mint_info().await.is_err() { - tracing::info!("Mint info not set on mint, setting."); - mint.set_mint_info(mint_builder.mint_info).await?; - mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000)).await?; - } else { - if mint.localstore.get_quote_ttl().await.is_err() { - mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000)).await?; - } - - let mut stored_mint_info = mint.mint_info().await?; - stored_mint_info.version = Some(mint_version); - mint.set_mint_info(stored_mint_info).await?; - - tracing::info!("Mint info already set, not using config file settings."); - } - } else { - tracing::warn!("RPC not enabled, using mint info from config."); - mint.set_mint_info(mint_builder.mint_info).await?; - mint.set_quote_ttl(QuoteTTL::new(10_000, 10_000)).await?; - } - - let socket_addr = SocketAddr::from_str(&format!("{listen_addr}:{listen_port}"))?; - - let listener = tokio::net::TcpListener::bind(socket_addr).await?; - - tracing::debug!("listening on {}", listener.local_addr().unwrap()); - - let axum_result = axum::serve(listener, mint_service).with_graceful_shutdown(shutdown_signal()); - - match axum_result.await { - Ok(_) => { - tracing::info!("Axum server stopped with okay status"); - } - Err(err) => { - tracing::warn!("Axum server stopped with error"); - tracing::error!("{}", err); - bail!("Axum exited with error") - } - } - - shutdown.notify_waiters(); - - #[cfg(feature = "management-rpc")] - { - if let Some(rpc_server) = rpc_server { - rpc_server.stop().await?; - } - } - - Ok(()) -} - -async fn shutdown_signal() { - tokio::signal::ctrl_c() .await - .expect("failed to install CTRL+C handler"); - tracing::info!("Shutdown signal received"); -} - -fn work_dir() -> Result { - let home_dir = home::home_dir().ok_or(anyhow!("Unknown home dir"))?; - let dir = home_dir.join(".cdk-mintd"); - - std::fs::create_dir_all(&dir)?; - - Ok(dir) + }) } diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index 6b5376bb5..b707a1901 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -1,22 +1,25 @@ -#[cfg(feature = "fakewallet")] +#[cfg(any(feature = "fakewallet", feature = "portalwallet"))] use std::collections::HashMap; -#[cfg(feature = "fakewallet")] +#[cfg(any(feature = "fakewallet", feature = "portalwallet"))] use std::collections::HashSet; +use std::path::Path; +use std::sync::Arc; #[cfg(feature = "cln")] use anyhow::anyhow; +#[cfg(any(feature = "lnbits", feature = "lnd"))] +use anyhow::bail; use async_trait::async_trait; -use axum::Router; #[cfg(feature = "fakewallet")] use bip39::rand::{thread_rng, Rng}; +use cdk::cdk_database::MintKVStore; use cdk::cdk_payment::MintPayment; -#[cfg(feature = "lnbits")] -use cdk::mint_url::MintUrl; use cdk::nuts::CurrencyUnit; #[cfg(any( feature = "lnbits", feature = "cln", feature = "lnd", + feature = "ldk-node", feature = "fakewallet" ))] use cdk::types::FeeReserve; @@ -29,9 +32,11 @@ use crate::expand_path; pub trait LnBackendSetup { async fn setup( &self, - routers: &mut Vec, settings: &Settings, unit: CurrencyUnit, + runtime: Option>, + work_dir: &Path, + kv_store: Option + Send + Sync>>, ) -> anyhow::Result; } @@ -40,10 +45,19 @@ pub trait LnBackendSetup { impl LnBackendSetup for config::Cln { async fn setup( &self, - _routers: &mut Vec, _settings: &Settings, _unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, + kv_store: Option + Send + Sync>>, ) -> anyhow::Result { + // Validate required connection field + if self.rpc_path.as_os_str().is_empty() { + return Err(anyhow!( + "CLN rpc_path must be set via config or CDK_MINTD_CLN_RPC_PATH env var" + )); + } + let cln_socket = expand_path( self.rpc_path .to_str() @@ -56,7 +70,12 @@ impl LnBackendSetup for config::Cln { percent_fee_reserve: self.fee_percent, }; - let cln = cdk_cln::Cln::new(cln_socket, fee_reserve).await?; + let cln = cdk_cln::Cln::new( + cln_socket, + fee_reserve, + kv_store.expect("Cln needs kv store"), + ) + .await?; Ok(cln) } @@ -67,58 +86,43 @@ impl LnBackendSetup for config::Cln { impl LnBackendSetup for config::LNbits { async fn setup( &self, - routers: &mut Vec, - settings: &Settings, + _settings: &Settings, _unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { + // Validate required connection fields + if self.admin_api_key.is_empty() { + bail!("LNbits admin_api_key must be set via config or CDK_MINTD_LNBITS_ADMIN_API_KEY env var"); + } + if self.invoice_api_key.is_empty() { + bail!("LNbits invoice_api_key must be set via config or CDK_MINTD_LNBITS_INVOICE_API_KEY env var"); + } + if self.lnbits_api.is_empty() { + bail!( + "LNbits lnbits_api must be set via config or CDK_MINTD_LNBITS_LNBITS_API env var" + ); + } + let admin_api_key = &self.admin_api_key; let invoice_api_key = &self.invoice_api_key; - // Channel used for lnbits web hook - let webhook_endpoint = "/webhook/lnbits/sat/invoice"; - let fee_reserve = FeeReserve { min_fee_reserve: self.reserve_fee_min, percent_fee_reserve: self.fee_percent, }; - let webhook_url = if settings - .lnbits - .as_ref() - .expect("Lnbits must be defined") - .retro_api - { - let mint_url: MintUrl = settings.info.url.parse()?; - let webhook_url = mint_url.join(webhook_endpoint)?; - - Some(webhook_url.to_string()) - } else { - None - }; - let lnbits = cdk_lnbits::LNbits::new( admin_api_key.clone(), invoice_api_key.clone(), self.lnbits_api.clone(), fee_reserve, - webhook_url, ) .await?; - if settings - .lnbits - .as_ref() - .expect("Lnbits must be defined") - .retro_api - { - let router = lnbits - .create_invoice_webhook_router(webhook_endpoint) - .await?; - - routers.push(router); - } else { - lnbits.subscribe_ws().await?; - }; + // Use v1 websocket API + lnbits.subscribe_ws().await?; Ok(lnbits) } @@ -129,10 +133,25 @@ impl LnBackendSetup for config::LNbits { impl LnBackendSetup for config::Lnd { async fn setup( &self, - _routers: &mut Vec, _settings: &Settings, _unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, + kv_store: Option + Send + Sync>>, ) -> anyhow::Result { + // Validate required connection fields + if self.address.is_empty() { + bail!("LND address must be set via config or CDK_MINTD_LND_ADDRESS env var"); + } + if self.cert_file.as_os_str().is_empty() { + bail!("LND cert_file must be set via config or CDK_MINTD_LND_CERT_FILE env var"); + } + if self.macaroon_file.as_os_str().is_empty() { + bail!( + "LND macaroon_file must be set via config or CDK_MINTD_LND_MACAROON_FILE env var" + ); + } + let address = &self.address; let cert_file = &self.cert_file; let macaroon_file = &self.macaroon_file; @@ -147,6 +166,7 @@ impl LnBackendSetup for config::Lnd { cert_file.clone(), macaroon_file.clone(), fee_reserve, + kv_store.expect("Lnd needs kv store"), ) .await?; @@ -159,9 +179,11 @@ impl LnBackendSetup for config::Lnd { impl LnBackendSetup for config::FakeWallet { async fn setup( &self, - _router: &mut Vec, _settings: &Settings, - _unit: CurrencyUnit, + unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { let fee_reserve = FeeReserve { min_fee_reserve: self.reserve_fee_min, @@ -177,6 +199,7 @@ impl LnBackendSetup for config::FakeWallet { HashMap::default(), HashSet::default(), delay_time, + unit, ); Ok(fake_wallet) @@ -188,9 +211,11 @@ impl LnBackendSetup for config::FakeWallet { impl LnBackendSetup for config::GrpcProcessor { async fn setup( &self, - _routers: &mut Vec, _settings: &Settings, _unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, + _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { let payment_processor = cdk_payment_processor::PaymentProcessorClient::new( &self.addr, @@ -202,3 +227,155 @@ impl LnBackendSetup for config::GrpcProcessor { Ok(payment_processor) } } + +#[cfg(feature = "ldk-node")] +#[async_trait] +impl LnBackendSetup for config::LdkNode { + async fn setup( + &self, + _settings: &Settings, + _unit: CurrencyUnit, + runtime: Option>, + work_dir: &Path, + _kv_store: Option + Send + Sync>>, + ) -> anyhow::Result { + use std::net::SocketAddr; + + use bitcoin::Network; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + // Parse network from config + let network = match self + .bitcoin_network + .as_ref() + .map(|n| n.to_lowercase()) + .as_deref() + .unwrap_or("regtest") + { + "mainnet" | "bitcoin" => Network::Bitcoin, + "testnet" => Network::Testnet, + "signet" => Network::Signet, + _ => Network::Regtest, + }; + + // Parse chain source from config + let chain_source = match self + .chain_source_type + .as_ref() + .map(|s| s.to_lowercase()) + .as_deref() + .unwrap_or("esplora") + { + "bitcoinrpc" => { + let host = self + .bitcoind_rpc_host + .clone() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = self.bitcoind_rpc_port.unwrap_or(18443); + let user = self + .bitcoind_rpc_user + .clone() + .unwrap_or_else(|| "testuser".to_string()); + let password = self + .bitcoind_rpc_password + .clone() + .unwrap_or_else(|| "testpass".to_string()); + + cdk_ldk_node::ChainSource::BitcoinRpc(cdk_ldk_node::BitcoinRpcConfig { + host, + port, + user, + password, + }) + } + _ => { + let esplora_url = self + .esplora_url + .clone() + .unwrap_or_else(|| "https://mutinynet.com/api".to_string()); + cdk_ldk_node::ChainSource::Esplora(esplora_url) + } + }; + + // Parse gossip source from config + let gossip_source = match self.rgs_url.clone() { + Some(rgs_url) => cdk_ldk_node::GossipSource::RapidGossipSync(rgs_url), + None => cdk_ldk_node::GossipSource::P2P, + }; + + // Get storage directory path + let storage_dir_path = if let Some(dir_path) = &self.storage_dir_path { + dir_path.clone() + } else { + let mut work_dir = work_dir.to_path_buf(); + work_dir.push("ldk-node"); + work_dir.to_string_lossy().to_string() + }; + + // Get LDK node listen address + let host = self + .ldk_node_host + .clone() + .unwrap_or_else(|| "127.0.0.1".to_string()); + let port = self.ldk_node_port.unwrap_or(8090); + + let socket_addr = SocketAddr::new(host.parse()?, port); + + // Parse socket address using ldk_node's SocketAddress + // We need to get the actual socket address struct from ldk_node + // For now, let's construct it manually based on the cdk-ldk-node implementation + let listen_address = vec![socket_addr.into()]; + + let mut ldk_node = cdk_ldk_node::CdkLdkNode::new( + network, + chain_source, + gossip_source, + storage_dir_path, + fee_reserve, + listen_address, + runtime, + )?; + + // Configure webserver address if specified + let webserver_addr = if let Some(host) = &self.webserver_host { + let port = self.webserver_port.unwrap_or(8091); + let socket_addr: SocketAddr = format!("{host}:{port}").parse()?; + Some(socket_addr) + } else if self.webserver_port.is_some() { + // If only port is specified, use default host + let port = self.webserver_port.unwrap_or(8091); + let socket_addr: SocketAddr = format!("127.0.0.1:{port}").parse()?; + Some(socket_addr) + } else { + // Use default webserver address if nothing is configured + Some(cdk_ldk_node::CdkLdkNode::default_web_addr()) + }; + + println!("webserver: {:?}", webserver_addr); + + ldk_node.set_web_addr(webserver_addr); + + Ok(ldk_node) + } +} + +#[cfg(feature = "portalwallet")] +#[async_trait] +impl LnBackendSetup for config::PortalWallet { + async fn setup( + &self, + _settings: &Settings, + unit: CurrencyUnit, + _runtime: Option>, + _work_dir: &Path, + _kv_store: Option + Send + Sync>>, + ) -> anyhow::Result { + let portal_wallet = cdk_portal_wallet::SimpleWallet::new(unit); + + Ok(portal_wallet) + } +} diff --git a/crates/cdk-payment-processor/Cargo.toml b/crates/cdk-payment-processor/Cargo.toml index ee3bc4979..e19e1ccfa 100644 --- a/crates/cdk-payment-processor/Cargo.toml +++ b/crates/cdk-payment-processor/Cargo.toml @@ -17,7 +17,7 @@ path = "src/bin/payment_processor.rs" [features] default = ["cln", "fake", "lnd"] bench = [] -cln = ["dep:cdk-cln"] +cln = ["dep:cdk-cln", "dep:cdk-sqlite"] fake = ["dep:cdk-fake-wallet"] lnd = ["dep:cdk-lnd"] @@ -25,16 +25,19 @@ lnd = ["dep:cdk-lnd"] anyhow.workspace = true async-trait.workspace = true bitcoin.workspace = true +cashu.workspace = true cdk-common = { workspace = true, features = ["mint"] } cdk-cln = { workspace = true, optional = true } cdk-lnd = { workspace = true, optional = true } cdk-fake-wallet = { workspace = true, optional = true } +cdk-sqlite = { workspace = true, optional = true } +clap = { workspace = true, features = ["derive"] } serde.workspace = true thiserror.workspace = true tracing.workspace = true tracing-subscriber.workspace = true lightning-invoice.workspace = true -uuid = { workspace = true, optional = true } +uuid = { workspace = true } utoipa = { workspace = true, optional = true } futures.workspace = true serde_json.workspace = true @@ -43,6 +46,8 @@ tonic = { workspace = true, features = ["router"] } prost.workspace = true tokio-stream.workspace = true tokio-util = { workspace = true, default-features = false } +hex = "0.4" +lightning = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -57,6 +62,7 @@ tokio = { workspace = true, features = [ [target.'cfg(target_arch = "wasm32")'.dependencies] tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } +uuid = { workspace = true, features = ["js"], optional = true } [dev-dependencies] rand.workspace = true diff --git a/crates/cdk-payment-processor/src/bin/payment_processor.rs b/crates/cdk-payment-processor/src/bin/payment_processor.rs index 46d11bef5..aecfa2506 100644 --- a/crates/cdk-payment-processor/src/bin/payment_processor.rs +++ b/crates/cdk-payment-processor/src/bin/payment_processor.rs @@ -1,5 +1,5 @@ #[cfg(feature = "fake")] -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::env; use std::path::PathBuf; #[cfg(any(feature = "cln", feature = "lnd", feature = "fake"))] @@ -14,11 +14,49 @@ use cdk_common::payment::{self, MintPayment}; use cdk_common::Amount; #[cfg(feature = "fake")] use cdk_fake_wallet::FakeWallet; +#[cfg(feature = "cln")] +use cdk_sqlite::MintSqliteDatabase; +use clap::Parser; use serde::{Deserialize, Serialize}; #[cfg(any(feature = "cln", feature = "lnd", feature = "fake"))] use tokio::signal; use tracing_subscriber::EnvFilter; +/// Common CLI arguments for CDK binaries +#[derive(Parser, Debug)] +pub struct CommonArgs { + /// Enable logging (default is false) + #[arg(long, default_value_t = false)] + pub enable_logging: bool, + + /// Logging level when enabled (default is debug) + #[arg(long, default_value = "debug")] + pub log_level: tracing::Level, +} + +/// Initialize logging based on CLI arguments +pub fn init_logging(enable_logging: bool, log_level: tracing::Level) { + if enable_logging { + let default_filter = log_level.to_string(); + + // Common filters to reduce noise + let sqlx_filter = "sqlx=warn"; + let hyper_filter = "hyper=warn"; + let h2_filter = "h2=warn"; + let rustls_filter = "rustls=warn"; + let reqwest_filter = "reqwest=warn"; + + let env_filter = EnvFilter::new(format!( + "{default_filter},{sqlx_filter},{hyper_filter},{h2_filter},{rustls_filter},{reqwest_filter}" + )); + + // Ok if successful, Err if already initialized + let _ = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .try_init(); + } +} + pub const ENV_LN_BACKEND: &str = "CDK_PAYMENT_PROCESSOR_LN_BACKEND"; pub const ENV_LISTEN_HOST: &str = "CDK_PAYMENT_PROCESSOR_LISTEN_HOST"; pub const ENV_LISTEN_PORT: &str = "CDK_PAYMENT_PROCESSOR_LISTEN_PORT"; @@ -36,20 +74,20 @@ pub const ENV_LND_ADDRESS: &str = "CDK_PAYMENT_PROCESSOR_LND_ADDRESS"; pub const ENV_LND_CERT_FILE: &str = "CDK_PAYMENT_PROCESSOR_LND_CERT_FILE"; pub const ENV_LND_MACAROON_FILE: &str = "CDK_PAYMENT_PROCESSOR_LND_MACAROON_FILE"; +#[derive(Parser)] +#[command(name = "payment-processor")] +#[command(about = "CDK Payment Processor", long_about = None)] +struct Args { + #[command(flatten)] + common: CommonArgs, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { - let default_filter = "debug"; - - let sqlx_filter = "sqlx=warn"; - let hyper_filter = "hyper=warn"; - let h2_filter = "h2=warn"; - let rustls_filter = "rustls=warn"; + let args = Args::parse(); - let env_filter = EnvFilter::new(format!( - "{default_filter},{sqlx_filter},{hyper_filter},{h2_filter},{rustls_filter}" - )); - - tracing_subscriber::fmt().with_env_filter(env_filter).init(); + // Initialize logging based on CLI arguments + init_logging(args.common.enable_logging, args.common.log_level); #[cfg(any(feature = "cln", feature = "lnd", feature = "fake"))] { @@ -70,17 +108,26 @@ async fn main() -> anyhow::Result<()> { percent_fee_reserve: cln_settings.fee_percent, }; - Arc::new(cdk_cln::Cln::new(cln_settings.rpc_path, fee_reserve).await?) + let kv_store = Arc::new(MintSqliteDatabase::new(":memory:").await?); + Arc::new(cdk_cln::Cln::new(cln_settings.rpc_path, fee_reserve, kv_store).await?) } #[cfg(feature = "fake")] "FAKEWALLET" => { + use std::collections::HashMap; + use std::sync::Arc; + let fee_reserve = FeeReserve { min_fee_reserve: 1.into(), percent_fee_reserve: 0.0, }; - let fake_wallet = - FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0); + let fake_wallet = FakeWallet::new( + fee_reserve, + HashMap::default(), + HashSet::default(), + 2, + cashu::CurrencyUnit::Sat, + ); Arc::new(fake_wallet) } @@ -92,12 +139,14 @@ async fn main() -> anyhow::Result<()> { percent_fee_reserve: lnd_settings.fee_percent, }; + let kv_store = Arc::new(MintSqliteDatabase::new(":memory:").await?); Arc::new( cdk_lnd::Lnd::new( lnd_settings.address, lnd_settings.cert_file, lnd_settings.macaroon_file, fee_reserve, + kv_store, ) .await?, ) diff --git a/crates/cdk-payment-processor/src/error.rs b/crates/cdk-payment-processor/src/error.rs index a4c27251b..6aa90ba03 100644 --- a/crates/cdk-payment-processor/src/error.rs +++ b/crates/cdk-payment-processor/src/error.rs @@ -1,6 +1,7 @@ -//! Errors +//! Error for payment processor use thiserror::Error; +use tonic::Status; /// CDK Payment processor error #[derive(Debug, Error)] @@ -8,13 +9,73 @@ pub enum Error { /// Invalid ID #[error("Invalid id")] InvalidId, + /// Invalid payment identifier + #[error("Invalid payment identifier")] + InvalidPaymentIdentifier, + /// Invalid hash + #[error("Invalid hash")] + InvalidHash, + /// Invalid currency unit + #[error("Invalid currency unit: {0}")] + InvalidCurrencyUnit(String), + /// Parse invoice error + #[error(transparent)] + Invoice(#[from] lightning_invoice::ParseOrSemanticError), + /// Hex decode error + #[error(transparent)] + Hex(#[from] hex::FromHexError), + /// BOLT12 parse error + #[error("BOLT12 parse error")] + Bolt12Parse, /// NUT00 Error #[error(transparent)] NUT00(#[from] cdk_common::nuts::nut00::Error), /// NUT05 error #[error(transparent)] NUT05(#[from] cdk_common::nuts::nut05::Error), - /// Parse invoice error + /// Payment error #[error(transparent)] - Invoice(#[from] lightning_invoice::ParseOrSemanticError), + Payment(#[from] cdk_common::payment::Error), +} + +impl From for Status { + fn from(error: Error) -> Self { + match error { + Error::InvalidId => Status::invalid_argument("Invalid ID"), + Error::InvalidPaymentIdentifier => { + Status::invalid_argument("Invalid payment identifier") + } + Error::InvalidHash => Status::invalid_argument("Invalid hash"), + Error::InvalidCurrencyUnit(unit) => { + Status::invalid_argument(format!("Invalid currency unit: {unit}")) + } + Error::Invoice(err) => Status::invalid_argument(format!("Invoice error: {err}")), + Error::Hex(err) => Status::invalid_argument(format!("Hex decode error: {err}")), + Error::Bolt12Parse => Status::invalid_argument("BOLT12 parse error"), + Error::NUT00(err) => Status::internal(format!("NUT00 error: {err}")), + Error::NUT05(err) => Status::internal(format!("NUT05 error: {err}")), + Error::Payment(err) => Status::internal(format!("Payment error: {err}")), + } + } +} + +impl From for cdk_common::payment::Error { + fn from(error: Error) -> Self { + match error { + Error::InvalidId => Self::Custom("Invalid ID".to_string()), + Error::InvalidPaymentIdentifier => { + Self::Custom("Invalid payment identifier".to_string()) + } + Error::InvalidHash => Self::Custom("Invalid hash".to_string()), + Error::InvalidCurrencyUnit(unit) => { + Self::Custom(format!("Invalid currency unit: {unit}")) + } + Error::Invoice(err) => Self::Custom(format!("Invoice error: {err}")), + Error::Hex(err) => Self::Custom(format!("Hex decode error: {err}")), + Error::Bolt12Parse => Self::Custom("BOLT12 parse error".to_string()), + Error::NUT00(err) => Self::Custom(format!("NUT00 error: {err}")), + Error::NUT05(err) => err.into(), + Error::Payment(err) => err, + } + } } diff --git a/crates/cdk-payment-processor/src/lib.rs b/crates/cdk-payment-processor/src/lib.rs index 89ff00cad..31b39a002 100644 --- a/crates/cdk-payment-processor/src/lib.rs +++ b/crates/cdk-payment-processor/src/lib.rs @@ -3,6 +3,7 @@ #![warn(rustdoc::bare_urls)] pub mod error; +/// Protocol types and functionality for the CDK payment processor pub mod proto; pub use proto::cdk_payment_processor_client::CdkPaymentProcessorClient; diff --git a/crates/cdk-payment-processor/src/proto/client.rs b/crates/cdk-payment-processor/src/proto/client.rs index ab9292aac..bd6482c39 100644 --- a/crates/cdk-payment-processor/src/proto/client.rs +++ b/crates/cdk-payment-processor/src/proto/client.rs @@ -1,15 +1,14 @@ use std::path::PathBuf; use std::pin::Pin; -use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use anyhow::anyhow; use cdk_common::payment::{ - CreateIncomingPaymentResponse, MakePaymentResponse as CdkMakePaymentResponse, MintPayment, - PaymentQuoteResponse, + CreateIncomingPaymentResponse, IncomingPaymentOptions as CdkIncomingPaymentOptions, + MakePaymentResponse as CdkMakePaymentResponse, MintPayment, + PaymentQuoteResponse as CdkPaymentQuoteResponse, WaitPaymentResponse, }; -use cdk_common::{mint, Amount, CurrencyUnit, MeltOptions, MintQuoteState}; use futures::{Stream, StreamExt}; use serde_json::Value; use tokio_util::sync::CancellationToken; @@ -17,10 +16,10 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity}; use tonic::{async_trait, Request}; use tracing::instrument; -use super::cdk_payment_processor_client::CdkPaymentProcessorClient; -use super::{ - CheckIncomingPaymentRequest, CheckOutgoingPaymentRequest, CreatePaymentRequest, - MakePaymentRequest, SettingsRequest, WaitIncomingPaymentRequest, +use crate::proto::cdk_payment_processor_client::CdkPaymentProcessorClient; +use crate::proto::{ + CheckIncomingPaymentRequest, CheckOutgoingPaymentRequest, CreatePaymentRequest, EmptyRequest, + IncomingPaymentOptions, MakePaymentRequest, OutgoingPaymentRequestType, PaymentQuoteRequest, }; /// Payment Processor @@ -48,32 +47,23 @@ impl PaymentProcessorClient { // Check for client.pem let client_pem_path = tls_dir.join("client.pem"); - if !client_pem_path.exists() { - let err_msg = format!( - "Client certificate file not found: {}", - client_pem_path.display() - ); - tracing::error!("{}", err_msg); - return Err(anyhow!(err_msg)); - } // Check for client.key let client_key_path = tls_dir.join("client.key"); - if !client_key_path.exists() { - let err_msg = format!("Client key file not found: {}", client_key_path.display()); - tracing::error!("{}", err_msg); - return Err(anyhow!(err_msg)); - } - + // check for ca cert let server_root_ca_cert = std::fs::read_to_string(&ca_pem_path)?; let server_root_ca_cert = Certificate::from_pem(server_root_ca_cert); - let client_cert = std::fs::read_to_string(&client_pem_path)?; - let client_key = std::fs::read_to_string(&client_key_path)?; - let client_identity = Identity::from_pem(client_cert, client_key); - let tls = ClientTlsConfig::new() - .ca_certificate(server_root_ca_cert) - .identity(client_identity); - + let tls: ClientTlsConfig = match client_pem_path.exists() && client_key_path.exists() { + true => { + let client_cert = std::fs::read_to_string(&client_pem_path)?; + let client_key = std::fs::read_to_string(&client_key_path)?; + let client_identity = Identity::from_pem(client_cert, client_key); + ClientTlsConfig::new() + .ca_certificate(server_root_ca_cert) + .identity(client_identity) + } + false => ClientTlsConfig::new().ca_certificate(server_root_ca_cert), + }; Channel::from_shared(addr)? .tls_config(tls)? .connect() @@ -100,7 +90,7 @@ impl MintPayment for PaymentProcessorClient { async fn get_settings(&self) -> Result { let mut inner = self.inner.clone(); let response = inner - .get_settings(Request::new(SettingsRequest {})) + .get_settings(Request::new(EmptyRequest {})) .await .map_err(|err| { tracing::error!("Could not get settings: {}", err); @@ -115,18 +105,36 @@ impl MintPayment for PaymentProcessorClient { /// Create a new invoice async fn create_incoming_payment_request( &self, - amount: Amount, - unit: &CurrencyUnit, - description: String, - unix_expiry: Option, + unit: &cdk_common::CurrencyUnit, + options: CdkIncomingPaymentOptions, ) -> Result { let mut inner = self.inner.clone(); + + let proto_options = match options { + CdkIncomingPaymentOptions::Bolt11(opts) => IncomingPaymentOptions { + options: Some(super::incoming_payment_options::Options::Bolt11( + super::Bolt11IncomingPaymentOptions { + description: opts.description, + amount: opts.amount.into(), + unix_expiry: opts.unix_expiry, + }, + )), + }, + CdkIncomingPaymentOptions::Bolt12(opts) => IncomingPaymentOptions { + options: Some(super::incoming_payment_options::Options::Bolt12( + super::Bolt12IncomingPaymentOptions { + description: opts.description, + amount: opts.amount.map(Into::into), + unix_expiry: opts.unix_expiry, + }, + )), + }, + }; + let response = inner .create_payment(Request::new(CreatePaymentRequest { - amount: amount.into(), unit: unit.to_string(), - description, - unix_expiry, + options: Some(proto_options), })) .await .map_err(|err| { @@ -143,16 +151,36 @@ impl MintPayment for PaymentProcessorClient { async fn get_payment_quote( &self, - request: &str, - unit: &CurrencyUnit, - options: Option, - ) -> Result { + unit: &cdk_common::CurrencyUnit, + options: cdk_common::payment::OutgoingPaymentOptions, + ) -> Result { let mut inner = self.inner.clone(); + + let request_type = match &options { + cdk_common::payment::OutgoingPaymentOptions::Bolt11(_) => { + OutgoingPaymentRequestType::Bolt11Invoice + } + cdk_common::payment::OutgoingPaymentOptions::Bolt12(_) => { + OutgoingPaymentRequestType::Bolt12Offer + } + }; + + let proto_request = match &options { + cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11.to_string(), + cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => opts.offer.to_string(), + }; + + let proto_options = match &options { + cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => opts.melt_options, + cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => opts.melt_options, + }; + let response = inner - .get_payment_quote(Request::new(super::PaymentQuoteRequest { - request: request.to_string(), + .get_payment_quote(Request::new(PaymentQuoteRequest { + request: proto_request, unit: unit.to_string(), - options: options.map(|o| o.into()), + options: proto_options.map(Into::into), + request_type: request_type.into(), })) .await .map_err(|err| { @@ -167,16 +195,43 @@ impl MintPayment for PaymentProcessorClient { async fn make_payment( &self, - melt_quote: mint::MeltQuote, - partial_amount: Option, - max_fee_amount: Option, + _unit: &cdk_common::CurrencyUnit, + options: cdk_common::payment::OutgoingPaymentOptions, ) -> Result { let mut inner = self.inner.clone(); + + let payment_options = match options { + cdk_common::payment::OutgoingPaymentOptions::Bolt11(opts) => { + super::OutgoingPaymentVariant { + options: Some(super::outgoing_payment_variant::Options::Bolt11( + super::Bolt11OutgoingPaymentOptions { + bolt11: opts.bolt11.to_string(), + max_fee_amount: opts.max_fee_amount.map(Into::into), + timeout_secs: opts.timeout_secs, + melt_options: opts.melt_options.map(Into::into), + }, + )), + } + } + cdk_common::payment::OutgoingPaymentOptions::Bolt12(opts) => { + super::OutgoingPaymentVariant { + options: Some(super::outgoing_payment_variant::Options::Bolt12( + super::Bolt12OutgoingPaymentOptions { + offer: opts.offer.to_string(), + max_fee_amount: opts.max_fee_amount.map(Into::into), + timeout_secs: opts.timeout_secs, + melt_options: opts.melt_options.map(Into::into), + }, + )), + } + } + }; + let response = inner .make_payment(Request::new(MakePaymentRequest { - melt_quote: Some(melt_quote.into()), - partial_amount: partial_amount.map(|a| a.into()), - max_fee_amount: max_fee_amount.map(|a| a.into()), + payment_options: Some(payment_options), + partial_amount: None, + max_fee_amount: None, })) .await .map_err(|err| { @@ -198,17 +253,16 @@ impl MintPayment for PaymentProcessorClient { })?) } - /// Listen for invoices to be paid to the mint #[instrument(skip_all)] - async fn wait_any_incoming_payment( + async fn wait_payment_event( &self, - ) -> Result + Send>>, Self::Err> { + ) -> Result + Send>>, Self::Err> { self.wait_incoming_payment_stream_is_active .store(true, Ordering::SeqCst); tracing::debug!("Client waiting for payment"); let mut inner = self.inner.clone(); let stream = inner - .wait_incoming_payment(WaitIncomingPaymentRequest {}) + .wait_incoming_payment(EmptyRequest {}) .await .map_err(|err| { tracing::error!("Could not check incoming payment stream: {}", err); @@ -222,15 +276,20 @@ impl MintPayment for PaymentProcessorClient { let transformed_stream = stream .take_until(cancel_fut) - .filter_map(|item| async move { + .filter_map(|item| async { match item { - Ok(value) => { - tracing::warn!("{}", value.lookup_id); - Some(value.lookup_id) - } + Ok(value) => match value.try_into() { + Ok(payment_response) => Some(cdk_common::payment::Event::PaymentReceived( + payment_response, + )), + Err(e) => { + tracing::error!("Error converting payment response: {}", e); + None + } + }, Err(e) => { tracing::error!("Error in payment stream: {}", e); - None // Skip this item and continue with the stream + None } } }) @@ -255,12 +314,12 @@ impl MintPayment for PaymentProcessorClient { async fn check_incoming_payment_status( &self, - request_lookup_id: &str, - ) -> Result { + payment_identifier: &cdk_common::payment::PaymentIdentifier, + ) -> Result, Self::Err> { let mut inner = self.inner.clone(); let response = inner .check_incoming_payment(Request::new(CheckIncomingPaymentRequest { - request_lookup_id: request_lookup_id.to_string(), + request_identifier: Some(payment_identifier.clone().into()), })) .await .map_err(|err| { @@ -269,20 +328,21 @@ impl MintPayment for PaymentProcessorClient { })?; let check_incoming = response.into_inner(); - - let status = check_incoming.status().as_str_name(); - - Ok(MintQuoteState::from_str(status)?) + check_incoming + .payments + .into_iter() + .map(|resp| resp.try_into().map_err(Self::Err::from)) + .collect() } async fn check_outgoing_payment( &self, - request_lookup_id: &str, + payment_identifier: &cdk_common::payment::PaymentIdentifier, ) -> Result { let mut inner = self.inner.clone(); let response = inner .check_outgoing_payment(Request::new(CheckOutgoingPaymentRequest { - request_lookup_id: request_lookup_id.to_string(), + request_identifier: Some(payment_identifier.clone().into()), })) .await .map_err(|err| { diff --git a/crates/cdk-payment-processor/src/proto/mod.rs b/crates/cdk-payment-processor/src/proto/mod.rs index 95c6e8199..229d1c02e 100644 --- a/crates/cdk-payment-processor/src/proto/mod.rs +++ b/crates/cdk-payment-processor/src/proto/mod.rs @@ -1,12 +1,11 @@ -//! Proto types for payment processor - use std::str::FromStr; use cdk_common::payment::{ CreateIncomingPaymentResponse, MakePaymentResponse as CdkMakePaymentResponse, + PaymentIdentifier as CdkPaymentIdentifier, WaitPaymentResponse, }; -use cdk_common::{Bolt11Invoice, CurrencyUnit, MeltQuoteBolt11Request}; -use melt_options::Options; +use cdk_common::{CurrencyUnit, MeltOptions as CdkMeltOptions}; + mod client; mod server; @@ -15,15 +14,89 @@ pub use server::PaymentProcessorServer; tonic::include_proto!("cdk_payment_processor"); +impl From for PaymentIdentifier { + fn from(value: CdkPaymentIdentifier) -> Self { + match value { + CdkPaymentIdentifier::Label(id) => Self { + r#type: PaymentIdentifierType::Label.into(), + value: Some(payment_identifier::Value::Id(id)), + }, + CdkPaymentIdentifier::OfferId(id) => Self { + r#type: PaymentIdentifierType::OfferId.into(), + value: Some(payment_identifier::Value::Id(id)), + }, + CdkPaymentIdentifier::PaymentHash(hash) => Self { + r#type: PaymentIdentifierType::PaymentHash.into(), + value: Some(payment_identifier::Value::Hash(hex::encode(hash))), + }, + CdkPaymentIdentifier::Bolt12PaymentHash(hash) => Self { + r#type: PaymentIdentifierType::Bolt12PaymentHash.into(), + value: Some(payment_identifier::Value::Hash(hex::encode(hash))), + }, + CdkPaymentIdentifier::CustomId(id) => Self { + r#type: PaymentIdentifierType::CustomId.into(), + value: Some(payment_identifier::Value::Id(id)), + }, + CdkPaymentIdentifier::PaymentId(hash) => Self { + r#type: PaymentIdentifierType::PaymentId.into(), + value: Some(payment_identifier::Value::Hash(hex::encode(hash))), + }, + } + } +} + +impl TryFrom for CdkPaymentIdentifier { + type Error = crate::error::Error; + + fn try_from(value: PaymentIdentifier) -> Result { + match (value.r#type(), value.value) { + (PaymentIdentifierType::Label, Some(payment_identifier::Value::Id(id))) => { + Ok(CdkPaymentIdentifier::Label(id)) + } + (PaymentIdentifierType::OfferId, Some(payment_identifier::Value::Id(id))) => { + Ok(CdkPaymentIdentifier::OfferId(id)) + } + (PaymentIdentifierType::PaymentHash, Some(payment_identifier::Value::Hash(hash))) => { + let decoded = hex::decode(hash)?; + let hash_array: [u8; 32] = decoded + .try_into() + .map_err(|_| crate::error::Error::InvalidHash)?; + Ok(CdkPaymentIdentifier::PaymentHash(hash_array)) + } + ( + PaymentIdentifierType::Bolt12PaymentHash, + Some(payment_identifier::Value::Hash(hash)), + ) => { + let decoded = hex::decode(hash)?; + let hash_array: [u8; 32] = decoded + .try_into() + .map_err(|_| crate::error::Error::InvalidHash)?; + Ok(CdkPaymentIdentifier::Bolt12PaymentHash(hash_array)) + } + (PaymentIdentifierType::CustomId, Some(payment_identifier::Value::Id(id))) => { + Ok(CdkPaymentIdentifier::CustomId(id)) + } + _ => Err(crate::error::Error::InvalidPaymentIdentifier), + } + } +} + impl TryFrom for CdkMakePaymentResponse { type Error = crate::error::Error; fn try_from(value: MakePaymentResponse) -> Result { + let status = value.status().as_str_name().parse()?; + let payment_proof = value.payment_proof; + let total_spent = value.total_spent.into(); + let unit = CurrencyUnit::from_str(&value.unit)?; + let payment_identifier = value + .payment_identifier + .ok_or(crate::error::Error::InvalidPaymentIdentifier)?; Ok(Self { - payment_lookup_id: value.payment_lookup_id.clone(), - payment_proof: value.payment_proof.clone(), - status: value.status().as_str_name().parse()?, - total_spent: value.total_spent.into(), - unit: value.unit.parse()?, + payment_lookup_id: payment_identifier.try_into()?, + payment_proof, + status, + total_spent, + unit, }) } } @@ -31,8 +104,8 @@ impl TryFrom for CdkMakePaymentResponse { impl From for MakePaymentResponse { fn from(value: CdkMakePaymentResponse) -> Self { Self { - payment_lookup_id: value.payment_lookup_id.clone(), - payment_proof: value.payment_proof.clone(), + payment_identifier: Some(value.payment_lookup_id.into()), + payment_proof: value.payment_proof, status: QuoteState::from(value.status).into(), total_spent: value.total_spent.into(), unit: value.unit.to_string(), @@ -43,8 +116,8 @@ impl From for MakePaymentResponse { impl From for CreatePaymentResponse { fn from(value: CreateIncomingPaymentResponse) -> Self { Self { - request_lookup_id: value.request_lookup_id, - request: value.request.to_string(), + request_identifier: Some(value.request_lookup_id.into()), + request: value.request, expiry: value.expiry, } } @@ -54,82 +127,80 @@ impl TryFrom for CreateIncomingPaymentResponse { type Error = crate::error::Error; fn try_from(value: CreatePaymentResponse) -> Result { + let request_identifier = value + .request_identifier + .ok_or(crate::error::Error::InvalidPaymentIdentifier)?; Ok(Self { - request_lookup_id: value.request_lookup_id, + request_lookup_id: request_identifier.try_into()?, request: value.request, expiry: value.expiry, }) } } -impl From<&MeltQuoteBolt11Request> for PaymentQuoteRequest { - fn from(value: &MeltQuoteBolt11Request) -> Self { - Self { - request: value.request.to_string(), - unit: value.unit.to_string(), - options: value.options.map(|o| o.into()), - } - } -} - impl From for PaymentQuoteResponse { fn from(value: cdk_common::payment::PaymentQuoteResponse) -> Self { Self { - request_lookup_id: value.request_lookup_id, + request_identifier: value.request_lookup_id.map(|i| i.into()), amount: value.amount.into(), fee: value.fee.into(), - state: QuoteState::from(value.state).into(), unit: value.unit.to_string(), + state: QuoteState::from(value.state).into(), } } } -impl From for MeltOptions { - fn from(value: cdk_common::nut23::MeltOptions) -> Self { - Self { - options: Some(value.into()), - } - } -} +impl From for cdk_common::payment::PaymentQuoteResponse { + fn from(value: PaymentQuoteResponse) -> Self { + let state_val = value.state(); + let request_identifier = value.request_identifier; -impl From for Options { - fn from(value: cdk_common::nut23::MeltOptions) -> Self { - match value { - cdk_common::MeltOptions::Mpp { mpp } => Self::Mpp(Mpp { - amount: mpp.amount.into(), - }), - cdk_common::MeltOptions::Amountless { amountless } => Self::Amountless(Amountless { - amount_msat: amountless.amount_msat.into(), - }), + Self { + request_lookup_id: request_identifier + .map(|i| i.try_into().expect("valid request identifier")), + amount: value.amount.into(), + fee: value.fee.into(), + unit: CurrencyUnit::from_str(&value.unit).unwrap_or_default(), + state: state_val.into(), } } } -impl From for cdk_common::nut23::MeltOptions { +impl From for CdkMeltOptions { fn from(value: MeltOptions) -> Self { - let options = value.options.expect("option defined"); - match options { - Options::Mpp(mpp) => cdk_common::MeltOptions::new_mpp(mpp.amount), - Options::Amountless(amountless) => { - cdk_common::MeltOptions::new_amountless(amountless.amount_msat) - } + match value.options.expect("option defined") { + melt_options::Options::Mpp(mpp) => Self::Mpp { + mpp: cashu::nuts::nut15::Mpp { + amount: mpp.amount.into(), + }, + }, + melt_options::Options::Amountless(amountless) => Self::Amountless { + amountless: cashu::nuts::nut23::Amountless { + amount_msat: amountless.amount_msat.into(), + }, + }, } } } -impl From for cdk_common::payment::PaymentQuoteResponse { - fn from(value: PaymentQuoteResponse) -> Self { - Self { - request_lookup_id: value.request_lookup_id.clone(), - amount: value.amount.into(), - unit: CurrencyUnit::from_str(&value.unit).unwrap_or_default(), - fee: value.fee.into(), - state: value.state().into(), +impl From for MeltOptions { + fn from(value: CdkMeltOptions) -> Self { + match value { + CdkMeltOptions::Mpp { mpp } => Self { + options: Some(melt_options::Options::Mpp(Mpp { + amount: mpp.amount.into(), + })), + }, + CdkMeltOptions::Amountless { amountless } => Self { + options: Some(melt_options::Options::Amountless(Amountless { + amount_msat: amountless.amount_msat.into(), + })), + }, } } } -impl From for cdk_common::nut05::QuoteState { +impl From for cdk_common::nuts::MeltQuoteState { fn from(value: QuoteState) -> Self { match value { QuoteState::Unpaid => Self::Unpaid, @@ -142,80 +213,53 @@ impl From for cdk_common::nut05::QuoteState { } } -impl From for QuoteState { - fn from(value: cdk_common::nut05::QuoteState) -> Self { +impl From for QuoteState { + fn from(value: cdk_common::nuts::MeltQuoteState) -> Self { match value { - cdk_common::MeltQuoteState::Unpaid => Self::Unpaid, - cdk_common::MeltQuoteState::Paid => Self::Paid, - cdk_common::MeltQuoteState::Pending => Self::Pending, - cdk_common::MeltQuoteState::Unknown => Self::Unknown, - cdk_common::MeltQuoteState::Failed => Self::Failed, + cdk_common::nuts::MeltQuoteState::Unpaid => Self::Unpaid, + cdk_common::nuts::MeltQuoteState::Paid => Self::Paid, + cdk_common::nuts::MeltQuoteState::Pending => Self::Pending, + cdk_common::nuts::MeltQuoteState::Unknown => Self::Unknown, + cdk_common::nuts::MeltQuoteState::Failed => Self::Failed, } } } -impl From for QuoteState { - fn from(value: cdk_common::nut23::QuoteState) -> Self { +impl From for QuoteState { + fn from(value: cdk_common::nuts::MintQuoteState) -> Self { match value { - cdk_common::MintQuoteState::Unpaid => Self::Unpaid, - cdk_common::MintQuoteState::Paid => Self::Paid, - cdk_common::MintQuoteState::Pending => Self::Pending, - cdk_common::MintQuoteState::Issued => Self::Issued, + cdk_common::nuts::MintQuoteState::Unpaid => Self::Unpaid, + cdk_common::nuts::MintQuoteState::Paid => Self::Paid, + cdk_common::nuts::MintQuoteState::Issued => Self::Issued, } } } -impl From for MeltQuote { - fn from(value: cdk_common::mint::MeltQuote) -> Self { +impl From for WaitIncomingPaymentResponse { + fn from(value: WaitPaymentResponse) -> Self { Self { - id: value.id.to_string(), + payment_identifier: Some(value.payment_identifier.into()), + payment_amount: value.payment_amount.into(), unit: value.unit.to_string(), - amount: value.amount.into(), - request: value.request, - fee_reserve: value.fee_reserve.into(), - state: QuoteState::from(value.state).into(), - expiry: value.expiry, - payment_preimage: value.payment_preimage, - request_lookup_id: value.request_lookup_id, - msat_to_pay: value.msat_to_pay.map(|a| a.into()), - created_time: value.created_time, - paid_time: value.paid_time, + payment_id: value.payment_id, } } } -impl TryFrom for cdk_common::mint::MeltQuote { +impl TryFrom for WaitPaymentResponse { type Error = crate::error::Error; - fn try_from(value: MeltQuote) -> Result { - Ok(Self { - id: value - .id - .parse() - .map_err(|_| crate::error::Error::InvalidId)?, - unit: value.unit.parse()?, - amount: value.amount.into(), - request: value.request.clone(), - fee_reserve: value.fee_reserve.into(), - state: cdk_common::nut05::QuoteState::from(value.state()), - expiry: value.expiry, - payment_preimage: value.payment_preimage, - request_lookup_id: value.request_lookup_id, - msat_to_pay: value.msat_to_pay.map(|a| a.into()), - created_time: value.created_time, - paid_time: value.paid_time, - }) - } -} - -impl TryFrom for MeltQuoteBolt11Request { - type Error = crate::error::Error; + fn try_from(value: WaitIncomingPaymentResponse) -> Result { + let payment_identifier = value + .payment_identifier + .ok_or(crate::error::Error::InvalidPaymentIdentifier)? + .try_into()?; - fn try_from(value: PaymentQuoteRequest) -> Result { Ok(Self { - request: Bolt11Invoice::from_str(&value.request)?, + payment_identifier, + payment_amount: value.payment_amount.into(), unit: CurrencyUnit::from_str(&value.unit)?, - options: value.options.map(|o| o.into()), + payment_id: value.payment_id, }) } } diff --git a/crates/cdk-payment-processor/src/proto/payment_processor.proto b/crates/cdk-payment-processor/src/proto/payment_processor.proto index 94a6021c5..fad00ffa3 100644 --- a/crates/cdk-payment-processor/src/proto/payment_processor.proto +++ b/crates/cdk-payment-processor/src/proto/payment_processor.proto @@ -3,30 +3,74 @@ syntax = "proto3"; package cdk_payment_processor; service CdkPaymentProcessor { - rpc GetSettings(SettingsRequest) returns (SettingsResponse) {} + rpc GetSettings(EmptyRequest) returns (SettingsResponse) {} rpc CreatePayment(CreatePaymentRequest) returns (CreatePaymentResponse) {} rpc GetPaymentQuote(PaymentQuoteRequest) returns (PaymentQuoteResponse) {} rpc MakePayment(MakePaymentRequest) returns (MakePaymentResponse) {} rpc CheckIncomingPayment(CheckIncomingPaymentRequest) returns (CheckIncomingPaymentResponse) {} rpc CheckOutgoingPayment(CheckOutgoingPaymentRequest) returns (MakePaymentResponse) {} - rpc WaitIncomingPayment(WaitIncomingPaymentRequest) returns (stream WaitIncomingPaymentResponse) {} + rpc WaitIncomingPayment(EmptyRequest) returns (stream WaitIncomingPaymentResponse) {} } -message SettingsRequest {} +message EmptyRequest {} message SettingsResponse { string inner = 1; } +message Bolt11IncomingPaymentOptions { + optional string description = 1; + uint64 amount = 2; + optional uint64 unix_expiry = 3; +} + +message Bolt12IncomingPaymentOptions { + optional string description = 1; + optional uint64 amount = 2; + optional uint64 unix_expiry = 3; +} + +enum PaymentMethodType { + BOLT11 = 0; + BOLT12 = 1; +} + +enum OutgoingPaymentRequestType { + BOLT11_INVOICE = 0; + BOLT12_OFFER = 1; +} + +enum PaymentIdentifierType { + PAYMENT_HASH = 0; + OFFER_ID = 1; + LABEL = 2; + BOLT12_PAYMENT_HASH = 3; + CUSTOM_ID = 4; + PAYMENT_ID = 5; +} + +message PaymentIdentifier { + PaymentIdentifierType type = 1; + oneof value { + string hash = 2; // Used for PAYMENT_HASH and BOLT12_PAYMENT_HASH + string id = 3; // Used for OFFER_ID, LABEL, and CUSTOM_ID + } +} + +message IncomingPaymentOptions { + oneof options { + Bolt11IncomingPaymentOptions bolt11 = 1; + Bolt12IncomingPaymentOptions bolt12 = 2; + } +} + message CreatePaymentRequest { - uint64 amount = 1; - string unit = 2; - string description = 3; - optional uint64 unix_expiry = 4; + string unit = 1; + IncomingPaymentOptions options = 2; } message CreatePaymentResponse { - string request_lookup_id = 1; + PaymentIdentifier request_identifier = 1; string request = 2; optional uint64 expiry = 3; } @@ -35,7 +79,6 @@ message Mpp { uint64 amount = 1; } - message Amountless { uint64 amount_msat = 1; } @@ -51,6 +94,7 @@ message PaymentQuoteRequest { string request = 1; string unit = 2; optional MeltOptions options = 3; + OutgoingPaymentRequestType request_type = 4; } enum QuoteState { @@ -64,36 +108,47 @@ enum QuoteState { message PaymentQuoteResponse { - string request_lookup_id = 1; + PaymentIdentifier request_identifier = 1; uint64 amount = 2; uint64 fee = 3; QuoteState state = 4; string unit = 5; } -message MeltQuote { - string id = 1; - string unit = 2; - uint64 amount = 3; - string request = 4; - uint64 fee_reserve = 5; - QuoteState state = 6; - uint64 expiry = 7; - optional string payment_preimage = 8; - string request_lookup_id = 9; - optional uint64 msat_to_pay = 10; - uint64 created_time = 11; - optional uint64 paid_time = 12; +message Bolt11OutgoingPaymentOptions { + string bolt11 = 1; + optional uint64 max_fee_amount = 2; + optional uint64 timeout_secs = 3; + optional MeltOptions melt_options = 4; +} + +message Bolt12OutgoingPaymentOptions { + string offer = 1; + optional uint64 max_fee_amount = 2; + optional uint64 timeout_secs = 3; + optional MeltOptions melt_options = 5; +} + +enum OutgoingPaymentOptionsType { + OUTGOING_BOLT11 = 0; + OUTGOING_BOLT12 = 1; +} + +message OutgoingPaymentVariant { + oneof options { + Bolt11OutgoingPaymentOptions bolt11 = 1; + Bolt12OutgoingPaymentOptions bolt12 = 2; + } } message MakePaymentRequest { - MeltQuote melt_quote = 1; + OutgoingPaymentVariant payment_options = 1; optional uint64 partial_amount = 2; optional uint64 max_fee_amount = 3; } message MakePaymentResponse { - string payment_lookup_id = 1; + PaymentIdentifier payment_identifier = 1; optional string payment_proof = 2; QuoteState status = 3; uint64 total_spent = 4; @@ -101,22 +156,20 @@ message MakePaymentResponse { } message CheckIncomingPaymentRequest { - string request_lookup_id = 1; + PaymentIdentifier request_identifier = 1; } message CheckIncomingPaymentResponse { - QuoteState status = 1; + repeated WaitIncomingPaymentResponse payments = 1; } message CheckOutgoingPaymentRequest { - string request_lookup_id = 1; -} - - -message WaitIncomingPaymentRequest { + PaymentIdentifier request_identifier = 1; } - message WaitIncomingPaymentResponse { - string lookup_id = 1; + PaymentIdentifier payment_identifier = 1; + uint64 payment_amount = 2; + string unit = 3; + string payment_id = 4; } diff --git a/crates/cdk-payment-processor/src/proto/server.rs b/crates/cdk-payment-processor/src/proto/server.rs index 823b73c3a..81231a8ef 100644 --- a/crates/cdk-payment-processor/src/proto/server.rs +++ b/crates/cdk-payment-processor/src/proto/server.rs @@ -5,8 +5,10 @@ use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use cdk_common::payment::MintPayment; +use cdk_common::payment::{IncomingPaymentOptions, MintPayment}; +use cdk_common::CurrencyUnit; use futures::{Stream, StreamExt}; +use lightning::offers::offer::Offer; use serde_json::Value; use tokio::sync::{mpsc, Notify}; use tokio::task::JoinHandle; @@ -17,6 +19,7 @@ use tonic::{async_trait, Request, Response, Status}; use tracing::instrument; use super::cdk_payment_processor_server::{CdkPaymentProcessor, CdkPaymentProcessorServer}; +use crate::error::Error; use crate::proto::*; type ResponseStream = @@ -162,7 +165,7 @@ impl Drop for PaymentProcessorServer { impl CdkPaymentProcessor for PaymentProcessorServer { async fn get_settings( &self, - _request: Request, + _request: Request, ) -> Result, Status> { let settings: Value = self .inner @@ -179,18 +182,36 @@ impl CdkPaymentProcessor for PaymentProcessorServer { &self, request: Request, ) -> Result, Status> { - let CreatePaymentRequest { - amount, - unit, - description, - unix_expiry, - } = request.into_inner(); - - let unit = - CurrencyUnit::from_str(&unit).map_err(|_| Status::invalid_argument("Invalid unit"))?; + let CreatePaymentRequest { unit, options } = request.into_inner(); + + let unit = CurrencyUnit::from_str(&unit) + .map_err(|_| Status::invalid_argument("Invalid currency unit"))?; + + let options = options.ok_or_else(|| Status::invalid_argument("Missing payment options"))?; + + let proto_options = match options + .options + .ok_or_else(|| Status::invalid_argument("Missing options"))? + { + incoming_payment_options::Options::Bolt11(opts) => { + IncomingPaymentOptions::Bolt11(cdk_common::payment::Bolt11IncomingPaymentOptions { + description: opts.description, + amount: opts.amount.into(), + unix_expiry: opts.unix_expiry, + }) + } + incoming_payment_options::Options::Bolt12(opts) => IncomingPaymentOptions::Bolt12( + Box::new(cdk_common::payment::Bolt12IncomingPaymentOptions { + description: opts.description, + amount: opts.amount.map(Into::into), + unix_expiry: opts.unix_expiry, + }), + ), + }; + let invoice_response = self .inner - .create_incoming_payment_request(amount.into(), &unit, description, unix_expiry) + .create_incoming_payment_request(&unit, proto_options) .await .map_err(|_| Status::internal("Could not create invoice"))?; @@ -203,21 +224,45 @@ impl CdkPaymentProcessor for PaymentProcessorServer { ) -> Result, Status> { let request = request.into_inner(); - let options: Option = - request.options.as_ref().map(|options| (*options).into()); + let unit = CurrencyUnit::from_str(&request.unit) + .map_err(|_| Status::invalid_argument("Invalid currency unit"))?; + + let options = match request.request_type() { + OutgoingPaymentRequestType::Bolt11Invoice => { + let bolt11: cdk_common::Bolt11Invoice = + request.request.parse().map_err(Error::Invoice)?; + + cdk_common::payment::OutgoingPaymentOptions::Bolt11(Box::new( + cdk_common::payment::Bolt11OutgoingPaymentOptions { + bolt11, + max_fee_amount: None, + timeout_secs: None, + melt_options: request.options.map(Into::into), + }, + )) + } + OutgoingPaymentRequestType::Bolt12Offer => { + // Parse offer to verify it's valid, but store as string + let _: Offer = request.request.parse().map_err(|_| Error::Bolt12Parse)?; + + cdk_common::payment::OutgoingPaymentOptions::Bolt12(Box::new( + cdk_common::payment::Bolt12OutgoingPaymentOptions { + offer: Offer::from_str(&request.request).unwrap(), + max_fee_amount: None, + timeout_secs: None, + melt_options: request.options.map(Into::into), + }, + )) + } + }; let payment_quote = self .inner - .get_payment_quote( - &request.request, - &CurrencyUnit::from_str(&request.unit) - .map_err(|_| Status::invalid_argument("Invalid currency unit"))?, - options, - ) + .get_payment_quote(&unit, options) .await .map_err(|err| { - tracing::error!("Could not get bolt11 melt quote: {}", err); - Status::internal("Could not get melt quote") + tracing::error!("Could not get payment quote: {}", err); + Status::internal("Could not get quote") })?; Ok(Response::new(payment_quote.into())) @@ -229,17 +274,50 @@ impl CdkPaymentProcessor for PaymentProcessorServer { ) -> Result, Status> { let request = request.into_inner(); - let pay_invoice = self + let options = request + .payment_options + .ok_or_else(|| Status::invalid_argument("Missing payment options"))?; + + let (unit, payment_options) = match options + .options + .ok_or_else(|| Status::invalid_argument("Missing options"))? + { + outgoing_payment_variant::Options::Bolt11(opts) => { + let bolt11: cdk_common::Bolt11Invoice = + opts.bolt11.parse().map_err(Error::Invoice)?; + + let payment_options = cdk_common::payment::OutgoingPaymentOptions::Bolt11( + Box::new(cdk_common::payment::Bolt11OutgoingPaymentOptions { + bolt11, + max_fee_amount: opts.max_fee_amount.map(Into::into), + timeout_secs: opts.timeout_secs, + melt_options: opts.melt_options.map(Into::into), + }), + ); + + (CurrencyUnit::Msat, payment_options) + } + outgoing_payment_variant::Options::Bolt12(opts) => { + let offer = Offer::from_str(&opts.offer) + .map_err(|_| Error::Bolt12Parse) + .unwrap(); + + let payment_options = cdk_common::payment::OutgoingPaymentOptions::Bolt12( + Box::new(cdk_common::payment::Bolt12OutgoingPaymentOptions { + offer, + max_fee_amount: opts.max_fee_amount.map(Into::into), + timeout_secs: opts.timeout_secs, + melt_options: opts.melt_options.map(Into::into), + }), + ); + + (CurrencyUnit::Msat, payment_options) + } + }; + + let pay_response = self .inner - .make_payment( - request - .melt_quote - .ok_or(Status::invalid_argument("Meltquote is required"))? - .try_into() - .map_err(|_err| Status::invalid_argument("Invalid melt quote"))?, - request.partial_amount.map(|a| a.into()), - request.max_fee_amount.map(|a| a.into()), - ) + .make_payment(&unit, payment_options) .await .map_err(|err| { tracing::error!("Could not make payment: {}", err); @@ -255,7 +333,7 @@ impl CdkPaymentProcessor for PaymentProcessorServer { } })?; - Ok(Response::new(pay_invoice.into())) + Ok(Response::new(pay_response.into())) } async fn check_incoming_payment( @@ -264,14 +342,20 @@ impl CdkPaymentProcessor for PaymentProcessorServer { ) -> Result, Status> { let request = request.into_inner(); - let check_response = self + let payment_identifier = request + .request_identifier + .ok_or_else(|| Status::invalid_argument("Missing request identifier"))? + .try_into() + .map_err(|_| Status::invalid_argument("Invalid request identifier"))?; + + let check_responses = self .inner - .check_incoming_payment_status(&request.request_lookup_id) + .check_incoming_payment_status(&payment_identifier) .await .map_err(|_| Status::internal("Could not check incoming payment status"))?; Ok(Response::new(CheckIncomingPaymentResponse { - status: QuoteState::from(check_response).into(), + payments: check_responses.into_iter().map(|r| r.into()).collect(), })) } @@ -281,23 +365,28 @@ impl CdkPaymentProcessor for PaymentProcessorServer { ) -> Result, Status> { let request = request.into_inner(); + let payment_identifier = request + .request_identifier + .ok_or_else(|| Status::invalid_argument("Missing request identifier"))? + .try_into() + .map_err(|_| Status::invalid_argument("Invalid request identifier"))?; + let check_response = self .inner - .check_outgoing_payment(&request.request_lookup_id) + .check_outgoing_payment(&payment_identifier) .await - .map_err(|_| Status::internal("Could not check incoming payment status"))?; + .map_err(|_| Status::internal("Could not check outgoing payment status"))?; Ok(Response::new(check_response.into())) } type WaitIncomingPaymentStream = ResponseStream; - // Clippy thinks select is not stable but it compiles fine on MSRV (1.63.0) #[allow(clippy::incompatible_msrv)] #[instrument(skip_all)] async fn wait_incoming_payment( &self, - _request: Request, + _request: Request, ) -> Result, Status> { tracing::debug!("Server waiting for payment stream"); let (tx, rx) = mpsc::channel(128); @@ -307,34 +396,39 @@ impl CdkPaymentProcessor for PaymentProcessorServer { tokio::spawn(async move { loop { tokio::select! { - _ = shutdown_clone.notified() => { - tracing::info!("Shutdown signal received, stopping task for "); - ln.cancel_wait_invoice(); - break; - } - result = ln.wait_any_incoming_payment() => { - match result { - Ok(mut stream) => { - while let Some(request_lookup_id) = stream.next().await { - match tx.send(Result::<_, Status>::Ok(WaitIncomingPaymentResponse{lookup_id: request_lookup_id} )).await { - Ok(_) => { - // item (server response) was queued to be send to client - } - Err(item) => { - tracing::error!("Error adding incoming payment to stream: {}", item); + _ = shutdown_clone.notified() => { + tracing::info!("Shutdown signal received, stopping task"); + ln.cancel_wait_invoice(); break; } - } + result = ln.wait_payment_event() => { + match result { + Ok(mut stream) => { + while let Some(event) = stream.next().await { + match event { + cdk_common::payment::Event::PaymentReceived(payment_response) => { + match tx.send(Result::<_, Status>::Ok(payment_response.into())) + .await + { + Ok(_) => { + // Response was queued to be sent to client + } + Err(item) => { + tracing::error!("Error adding incoming payment to stream: {}", item); + break; + } + } + } + } + } + } + Err(err) => { + tracing::warn!("Could not get invoice stream: {}", err); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; } - } - Err(err) => { - tracing::warn!("Could not get invoice stream for {}", err); - - tokio::time::sleep(std::time::Duration::from_secs(5)).await; } } } - } } }); diff --git a/crates/cdk-rexie/Cargo.toml b/crates/cdk-portal-wallet/Cargo.toml similarity index 51% rename from crates/cdk-rexie/Cargo.toml rename to crates/cdk-portal-wallet/Cargo.toml index b3298de51..23c0c01b8 100644 --- a/crates/cdk-rexie/Cargo.toml +++ b/crates/cdk-portal-wallet/Cargo.toml @@ -1,27 +1,29 @@ [package] -name = "cdk-rexie" +name = "cdk-portal-wallet" version.workspace = true edition.workspace = true authors = ["CDK Developers"] -description = "Indexdb storage backend for CDK in the browser" license.workspace = true homepage = "https://github.com/cashubtc/cdk" repository = "https://github.com/cashubtc/cdk.git" rust-version.workspace = true # MSRV +description = "CDK portal wallet" readme = "README.md" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[features] -default = ["wallet"] -wallet = ["cdk/wallet"] - [dependencies] -rexie = "0.6.0" -cdk.workspace = true async-trait.workspace = true +bitcoin.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +futures.workspace = true tokio.workspace = true +tokio-util.workspace = true +tracing.workspace = true +thiserror.workspace = true serde.workspace = true serde_json.workspace = true -thiserror.workspace = true -serde-wasm-bindgen = "0.6.5" -web-sys = { version = "0.3.69", default-features = false, features = ["console"] } +lightning-invoice.workspace = true +lightning.workspace = true +tokio-stream.workspace = true +reqwest.workspace = true +uuid.workspace = true +rand.workspace = true diff --git a/crates/cdk-portal-wallet/README.md b/crates/cdk-portal-wallet/README.md new file mode 100644 index 000000000..cca6c6476 --- /dev/null +++ b/crates/cdk-portal-wallet/README.md @@ -0,0 +1,26 @@ +# CDK Fake Wallet + +[![crates.io](https://img.shields.io/crates/v/cdk-fake-wallet.svg)](https://crates.io/crates/cdk-fake-wallet) +[![Documentation](https://docs.rs/cdk-fake-wallet/badge.svg)](https://docs.rs/cdk-fake-wallet) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE) + +**ALPHA** This library is in early development, the API will change and should be used with caution. + +A fake Lightning wallet implementation for the Cashu Development Kit (CDK). This is intended for testing purposes only - quotes are automatically filled without actual Lightning Network interaction. + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +cdk-fake-wallet = "*" +``` + +## Warning + +This is for testing purposes only. Do not use in production environments. + +## License + +This project is licensed under the [MIT License](../../LICENSE). diff --git a/crates/cdk-portal-wallet/src/error.rs b/crates/cdk-portal-wallet/src/error.rs new file mode 100644 index 000000000..9e77e9735 --- /dev/null +++ b/crates/cdk-portal-wallet/src/error.rs @@ -0,0 +1,21 @@ +//! Fake Wallet Error + +use thiserror::Error; + +/// Fake Wallet Error +#[derive(Debug, Error)] +pub enum Error { + /// Unsupported Bolt12 + #[error("Unsupported Bolt12")] + UnsupportedBolt12, + + /// Payment not found + #[error("Payment not found")] + PaymentNotFound, +} + +impl From for cdk_common::payment::Error { + fn from(e: Error) -> Self { + Self::Custom(e.to_string()) + } +} diff --git a/crates/cdk-portal-wallet/src/lib.rs b/crates/cdk-portal-wallet/src/lib.rs new file mode 100644 index 000000000..76a8dda52 --- /dev/null +++ b/crates/cdk-portal-wallet/src/lib.rs @@ -0,0 +1,247 @@ +//! CDK portal wallet + +pub mod error; + +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use async_trait::async_trait; +use futures::Stream; +use futures::StreamExt; +use rand::Rng; +use tokio::sync::mpsc::{self, Receiver, Sender}; +use tokio::sync::Mutex; +use tokio_stream::wrappers::ReceiverStream; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +use cdk_common::amount::Amount; +use cdk_common::nuts::{CurrencyUnit, MeltQuoteState}; +use cdk_common::payment::{ + self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, +}; +use serde_json::Value; + +pub struct SimpleWallet { + sender: Sender<[u8; 32]>, + receiver: Arc>>>, + invoices: Arc>>, // payment_hash -> (invoice_id, paid, amount, unit) + wait_invoice_cancel_token: CancellationToken, + wait_invoice_is_active: Arc, + settings: Bolt11Settings, +} + +impl SimpleWallet { + pub fn new(currency_unit: CurrencyUnit) -> Self { + let (sender, receiver) = mpsc::channel(32); + Self { + sender, + receiver: Arc::new(Mutex::new(Some(receiver))), + invoices: Arc::new(Mutex::new(HashMap::new())), + wait_invoice_cancel_token: CancellationToken::new(), + wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + settings: Bolt11Settings { + mpp: false, + unit: currency_unit, + invoice_description: true, + amountless: false, + bolt12: false, + }, + } + } +} + +#[async_trait] +impl MintPayment for SimpleWallet { + type Err = payment::Error; + + async fn get_settings(&self) -> Result { + Ok(serde_json::to_value(&self.settings)?) + } + + fn is_wait_invoice_active(&self) -> bool { + self.wait_invoice_is_active.load(Ordering::SeqCst) + } + + fn cancel_wait_invoice(&self) { + self.wait_invoice_cancel_token.cancel() + } + + async fn wait_payment_event( + &self, + ) -> Result + Send>>, Self::Err> { + // Take the current receiver out. Only one consumer at a time! + let mut slot = self.receiver.lock().await; + let receiver = slot.take(); + let invoices = self.invoices.clone(); + + if let Some(receiver) = receiver { + let stream = ReceiverStream::new(receiver).filter_map(move |payment_hash| { + let invoices = invoices.clone(); + + async move { + let guard = invoices.lock().await; + if let Some((invoice_id, paid, amount, unit)) = guard.get(&payment_hash) { + if *paid { + Some(Event::PaymentReceived(WaitPaymentResponse { + payment_identifier: PaymentIdentifier::PaymentHash(payment_hash), + payment_amount: *amount, + unit: unit.clone(), + payment_id: invoice_id.clone(), + })) + } else { + None + } + } else { + None + } + } + }); + Ok(Box::pin(stream)) + } else { + // Already active + Ok(Box::pin(futures::stream::empty())) + } + } + + async fn get_payment_quote( + &self, + _unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let amount_msat = match options { + OutgoingPaymentOptions::Bolt11(ref bolt11_options) => { + match bolt11_options.melt_options { + Some(ref amt) => amt.amount_msat(), + None => bolt11_options + .bolt11 + .amount_milli_satoshis() + .ok_or(payment::Error::Custom("Unknown invoice amount".to_string()))? + .into(), + } + } + OutgoingPaymentOptions::Bolt12(_) => return Err(error::Error::UnsupportedBolt12.into()), + }; + let random_hash: [u8; 32] = rand::rng().random(); + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::PaymentHash(random_hash)), + amount: Amount::from(amount_msat), + fee: Amount::ZERO, + state: MeltQuoteState::Unpaid, + unit: CurrencyUnit::Msat, + }) + } + + async fn make_payment( + &self, + _unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let invoice_id = match options { + OutgoingPaymentOptions::Bolt11(ref bolt11_options) => bolt11_options.bolt11.to_string(), + OutgoingPaymentOptions::Bolt12(_) => return Err(error::Error::UnsupportedBolt12.into()), + }; + let mut invoices = self.invoices.lock().await; + if let Some((payment_hash, paid, amount, unit)) = + invoices + .iter_mut() + .find_map(|(hash, (id, paid, amount, unit))| { + (id == &invoice_id).then_some((hash, paid, amount, unit)) + }) + { + *paid = true; + // Optionally, you could do self.sender.send(*payment_hash).await, but we already auto-send. + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::PaymentHash(*payment_hash), + payment_proof: Some(invoice_id.clone()), + status: MeltQuoteState::Paid, + total_spent: *amount, + unit: unit.clone(), + }) + } else { + Err(error::Error::PaymentNotFound.into()) + } + } + + async fn create_incoming_payment_request( + &self, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + let (amount, _expiry) = match options { + IncomingPaymentOptions::Bolt11(ref bolt11_options) => { + (Some(bolt11_options.amount), bolt11_options.unix_expiry) + } + IncomingPaymentOptions::Bolt12(_) => return Err(error::Error::UnsupportedBolt12.into()), + }; + let invoice_id = Uuid::new_v4().to_string(); + let random_hash: [u8; 32] = rand::rng().random(); + let payment_amount = amount.unwrap_or(Amount::ZERO); + // Insert as paid at creation (auto-pay) + self.invoices.lock().await.insert( + random_hash, + (invoice_id.clone(), true, payment_amount, unit.clone()), + ); + + // Notify immediately + let _ = self.sender.send(random_hash).await; + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::PaymentHash(random_hash), + request: invoice_id.clone(), + expiry: None, + }) + } + + async fn check_incoming_payment_status( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let payment_hash = match payment_identifier { + PaymentIdentifier::PaymentHash(hash) => hash, + _ => return Ok(vec![]), + }; + let guard = self.invoices.lock().await; + if let Some((invoice_id, paid, amount, unit)) = guard.get(payment_hash) { + if *paid { + return Ok(vec![WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: *amount, + unit: unit.clone(), + payment_id: invoice_id.clone(), + }]); + } + } + Ok(vec![]) + } + + async fn check_outgoing_payment( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + let payment_hash = match payment_identifier { + PaymentIdentifier::PaymentHash(hash) => hash, + _ => return Err(error::Error::PaymentNotFound.into()), + }; + let guard = self.invoices.lock().await; + if let Some((invoice_id, paid, amount, unit)) = guard.get(payment_hash) { + let status = if *paid { + MeltQuoteState::Paid + } else { + MeltQuoteState::Unpaid + }; + return Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: Some(invoice_id.clone()), + status, + total_spent: *amount, + unit: unit.clone(), + }); + } + Err(error::Error::PaymentNotFound.into()) + } +} diff --git a/crates/cdk-postgres/Cargo.toml b/crates/cdk-postgres/Cargo.toml new file mode 100644 index 000000000..474bf9cf1 --- /dev/null +++ b/crates/cdk-postgres/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "cdk-postgres" +version.workspace = true +edition.workspace = true +authors = ["CDK Developers"] +description = "PostgreSQL storage backend for CDK" +license.workspace = true +homepage = "https://github.com/cashubtc/cdk" +repository = "https://github.com/cashubtc/cdk.git" +rust-version.workspace = true # MSRV + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = ["mint", "wallet", "auth"] +mint = ["cdk-common/mint", "cdk-sql-common/mint"] +wallet = ["cdk-common/wallet", "cdk-sql-common/wallet"] +auth = ["cdk-common/auth", "cdk-sql-common/auth"] + +[dependencies] +async-trait.workspace = true +cdk-common = { workspace = true, features = ["test"] } +bitcoin.workspace = true +cdk-sql-common = { workspace = true } +thiserror.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread"] } +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true +lightning-invoice.workspace = true +uuid.workspace = true +tokio-postgres = "0.7.13" +futures-util = "0.3.31" +postgres-native-tls = "0.5.1" +native-tls = "0.2" +once_cell.workspace = true diff --git a/crates/cdk-postgres/src/db.rs b/crates/cdk-postgres/src/db.rs new file mode 100644 index 000000000..be7a9eb38 --- /dev/null +++ b/crates/cdk-postgres/src/db.rs @@ -0,0 +1,155 @@ +use cdk_common::database::Error; +use cdk_sql_common::run_db_operation; +use cdk_sql_common::stmt::{Column, Statement}; +use futures_util::{pin_mut, TryStreamExt}; +use tokio_postgres::error::SqlState; +use tokio_postgres::{Client, Error as PgError}; + +use crate::value::PgValue; + +#[inline(always)] +fn to_pgsql_error(err: PgError) -> Error { + if let Some(err) = err.as_db_error() { + let code = err.code().to_owned(); + if code == SqlState::INTEGRITY_CONSTRAINT_VIOLATION || code == SqlState::UNIQUE_VIOLATION { + return Error::Duplicate; + } + } + + Error::Database(Box::new(err)) +} + +#[inline(always)] +pub async fn pg_batch(conn: &Client, statement: Statement) -> Result<(), Error> { + let (sql, _placeholder_values) = statement.to_sql()?; + + run_db_operation(&sql, conn.batch_execute(&sql), to_pgsql_error).await +} + +#[inline(always)] +pub async fn pg_execute(conn: &Client, statement: Statement) -> Result { + let (sql, placeholder_values) = statement.to_sql()?; + let prepared_statement = conn.prepare(&sql).await.map_err(to_pgsql_error)?; + + run_db_operation( + &sql, + async { + conn.execute_raw( + &prepared_statement, + placeholder_values + .iter() + .map(|x| x.into()) + .collect::>(), + ) + .await + .map(|x| x as usize) + }, + to_pgsql_error, + ) + .await +} + +#[inline(always)] +pub async fn pg_fetch_one( + conn: &Client, + statement: Statement, +) -> Result>, Error> { + let (sql, placeholder_values) = statement.to_sql()?; + let prepared_statement = conn.prepare(&sql).await.map_err(to_pgsql_error)?; + + run_db_operation( + &sql, + async { + let stream = conn + .query_raw( + &prepared_statement, + placeholder_values + .iter() + .map(|x| x.into()) + .collect::>(), + ) + .await?; + + pin_mut!(stream); + + stream + .try_next() + .await? + .map(|row| { + (0..row.len()) + .map(|i| row.try_get::<_, PgValue>(i).map(|value| value.into())) + .collect::, _>>() + }) + .transpose() + }, + to_pgsql_error, + ) + .await +} + +#[inline(always)] +pub async fn pg_fetch_all(conn: &Client, statement: Statement) -> Result>, Error> { + let (sql, placeholder_values) = statement.to_sql()?; + let prepared_statement = conn.prepare(&sql).await.map_err(to_pgsql_error)?; + + run_db_operation( + &sql, + async { + let stream = conn + .query_raw( + &prepared_statement, + placeholder_values + .iter() + .map(|x| x.into()) + .collect::>(), + ) + .await?; + + pin_mut!(stream); + + let mut rows = vec![]; + while let Some(row) = stream.try_next().await? { + rows.push( + (0..row.len()) + .map(|i| row.try_get::<_, PgValue>(i).map(|value| value.into())) + .collect::, _>>()?, + ); + } + + Ok(rows) + }, + to_pgsql_error, + ) + .await +} + +#[inline(always)] +pub async fn pg_pluck(conn: &Client, statement: Statement) -> Result, Error> { + let (sql, placeholder_values) = statement.to_sql()?; + let prepared_statement = conn.prepare(&sql).await.map_err(to_pgsql_error)?; + + run_db_operation( + &sql, + async { + let stream = conn + .query_raw( + &prepared_statement, + placeholder_values + .iter() + .map(|x| x.into()) + .collect::>(), + ) + .await?; + + pin_mut!(stream); + + stream + .try_next() + .await? + .map(|row| row.try_get::<_, PgValue>(0).map(|value| value.into())) + .transpose() + }, + to_pgsql_error, + ) + .await +} diff --git a/crates/cdk-postgres/src/lib.rs b/crates/cdk-postgres/src/lib.rs new file mode 100644 index 000000000..bea6ac1f0 --- /dev/null +++ b/crates/cdk-postgres/src/lib.rs @@ -0,0 +1,350 @@ +use std::fmt::Debug; +use std::sync::atomic::AtomicBool; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use cdk_common::database::Error; +use cdk_sql_common::database::{DatabaseConnector, DatabaseExecutor, GenericTransactionHandler}; +use cdk_sql_common::mint::SQLMintAuthDatabase; +use cdk_sql_common::pool::{DatabaseConfig, DatabasePool}; +use cdk_sql_common::stmt::{Column, Statement}; +use cdk_sql_common::{SQLMintDatabase, SQLWalletDatabase}; +use db::{pg_batch, pg_execute, pg_fetch_all, pg_fetch_one, pg_pluck}; +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; +use tokio::sync::{Mutex, Notify}; +use tokio::time::timeout; +use tokio_postgres::{connect, Client, Error as PgError, NoTls}; + +mod db; +mod value; + +#[derive(Debug)] +pub struct PgConnectionPool; + +#[derive(Clone)] +pub enum SslMode { + NoTls(NoTls), + NativeTls(postgres_native_tls::MakeTlsConnector), +} +const SSLMODE_VERIFY_FULL: &str = "sslmode=verify-full"; +const SSLMODE_VERIFY_CA: &str = "sslmode=verify-ca"; +const SSLMODE_PREFER: &str = "sslmode=prefer"; +const SSLMODE_ALLOW: &str = "sslmode=allow"; +const SSLMODE_REQUIRE: &str = "sslmode=require"; + +impl Default for SslMode { + fn default() -> Self { + SslMode::NoTls(NoTls {}) + } +} + +impl Debug for SslMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let debug_text = match self { + Self::NoTls(_) => "NoTls", + Self::NativeTls(_) => "NativeTls", + }; + + write!(f, "SslMode::{debug_text}") + } +} + +/// Postgres configuration +#[derive(Clone, Debug)] +pub struct PgConfig { + url: String, + schema: Option, + tls: SslMode, +} + +impl DatabaseConfig for PgConfig { + fn default_timeout(&self) -> Duration { + Duration::from_secs(10) + } + + fn max_size(&self) -> usize { + 20 + } +} + +impl PgConfig { + /// strip schema from the connection string + fn strip_schema(input: &str) -> (Option, String) { + let mut schema: Option = None; + + // Split by whitespace + let mut parts = Vec::new(); + for token in input.split_whitespace() { + if let Some(rest) = token.strip_prefix("schema=") { + schema = Some(rest.to_string()); + } else { + parts.push(token); + } + } + + let cleaned = parts.join(" "); + (schema, cleaned) + } +} + +impl From<&str> for PgConfig { + fn from(conn_str: &str) -> Self { + let (schema, conn_str) = Self::strip_schema(conn_str); + fn build_tls(accept_invalid_certs: bool, accept_invalid_hostnames: bool) -> SslMode { + let mut builder = TlsConnector::builder(); + if accept_invalid_certs { + builder.danger_accept_invalid_certs(true); + } + if accept_invalid_hostnames { + builder.danger_accept_invalid_hostnames(true); + } + + match builder.build() { + Ok(connector) => { + let make_tls_connector = MakeTlsConnector::new(connector); + SslMode::NativeTls(make_tls_connector) + } + Err(_) => SslMode::NoTls(NoTls {}), + } + } + + let tls = if conn_str.contains(SSLMODE_VERIFY_FULL) { + // Strict TLS: valid certs and hostnames required + build_tls(false, false) + } else if conn_str.contains(SSLMODE_VERIFY_CA) { + // Verify CA, but allow invalid hostnames + build_tls(false, true) + } else if conn_str.contains(SSLMODE_PREFER) + || conn_str.contains(SSLMODE_ALLOW) + || conn_str.contains(SSLMODE_REQUIRE) + { + // Lenient TLS for preferred/allow/require: accept invalid certs and hostnames + build_tls(true, true) + } else { + SslMode::NoTls(NoTls {}) + }; + + PgConfig { + url: conn_str.to_owned(), + schema, + tls, + } + } +} + +impl DatabasePool for PgConnectionPool { + type Config = PgConfig; + + type Connection = PostgresConnection; + + type Error = PgError; + + fn new_resource( + config: &Self::Config, + stale: Arc, + timeout: Duration, + ) -> Result> { + Ok(PostgresConnection::new(config.to_owned(), timeout, stale)) + } +} + +/// A postgres connection +#[derive(Debug)] +pub struct PostgresConnection { + timeout: Duration, + error: Arc>>, + result: Arc>, + notify: Arc, +} + +impl PostgresConnection { + /// Creates a new instance + pub fn new(config: PgConfig, timeout: Duration, stale: Arc) -> Self { + let failed = Arc::new(Mutex::new(None)); + let result = Arc::new(OnceLock::new()); + let notify = Arc::new(Notify::new()); + let error_clone = failed.clone(); + let result_clone = result.clone(); + let notify_clone = notify.clone(); + + async fn select_schema(conn: &Client, schema: &str) -> Result<(), Error> { + conn.batch_execute(&format!( + r#" + CREATE SCHEMA IF NOT EXISTS "{schema}"; + SET search_path TO "{schema}" + "# + )) + .await + .map_err(|e| Error::Database(Box::new(e))) + } + + tokio::spawn(async move { + match config.tls { + SslMode::NoTls(tls) => { + let (client, connection) = match connect(&config.url, tls).await { + Ok((client, connection)) => (client, connection), + Err(err) => { + *error_clone.lock().await = + Some(cdk_common::database::Error::Database(Box::new(err))); + stale.store(false, std::sync::atomic::Ordering::Release); + notify_clone.notify_waiters(); + return; + } + }; + + let stale_for_spawn = stale.clone(); + tokio::spawn(async move { + let _ = connection.await; + stale_for_spawn.store(true, std::sync::atomic::Ordering::Release); + }); + + if let Some(schema) = config.schema.as_ref() { + if let Err(err) = select_schema(&client, schema).await { + *error_clone.lock().await = Some(err); + stale.store(false, std::sync::atomic::Ordering::Release); + notify_clone.notify_waiters(); + return; + } + } + + let _ = result_clone.set(client); + notify_clone.notify_waiters(); + } + SslMode::NativeTls(tls) => { + let (client, connection) = match connect(&config.url, tls).await { + Ok((client, connection)) => (client, connection), + Err(err) => { + *error_clone.lock().await = + Some(cdk_common::database::Error::Database(Box::new(err))); + stale.store(false, std::sync::atomic::Ordering::Release); + notify_clone.notify_waiters(); + return; + } + }; + + let stale_for_spawn = stale.clone(); + tokio::spawn(async move { + let _ = connection.await; + stale_for_spawn.store(true, std::sync::atomic::Ordering::Release); + }); + + if let Some(schema) = config.schema.as_ref() { + if let Err(err) = select_schema(&client, schema).await { + *error_clone.lock().await = Some(err); + stale.store(true, std::sync::atomic::Ordering::Release); + notify_clone.notify_waiters(); + return; + } + } + + let _ = result_clone.set(client); + notify_clone.notify_waiters(); + } + } + }); + + Self { + error: failed, + timeout, + result, + notify, + } + } + + /// Gets the wrapped instance or the connection error. The connection is returned as reference, + /// and the actual error is returned once, next times a generic error would be returned + async fn inner(&self) -> Result<&Client, cdk_common::database::Error> { + if let Some(client) = self.result.get() { + return Ok(client); + } + + if let Some(error) = self.error.lock().await.take() { + return Err(error); + } + + if timeout(self.timeout, self.notify.notified()).await.is_err() { + return Err(cdk_common::database::Error::Internal("Timeout".to_owned())); + } + + // Check result again + if let Some(client) = self.result.get() { + Ok(client) + } else if let Some(error) = self.error.lock().await.take() { + Err(error) + } else { + Err(cdk_common::database::Error::Internal( + "Failed connection".to_owned(), + )) + } + } +} + +#[async_trait::async_trait] +impl DatabaseConnector for PostgresConnection { + type Transaction = GenericTransactionHandler; +} + +#[async_trait::async_trait] +impl DatabaseExecutor for PostgresConnection { + fn name() -> &'static str { + "postgres" + } + + async fn execute(&self, statement: Statement) -> Result { + pg_execute(self.inner().await?, statement).await + } + + async fn fetch_one(&self, statement: Statement) -> Result>, Error> { + pg_fetch_one(self.inner().await?, statement).await + } + + async fn fetch_all(&self, statement: Statement) -> Result>, Error> { + pg_fetch_all(self.inner().await?, statement).await + } + + async fn pluck(&self, statement: Statement) -> Result, Error> { + pg_pluck(self.inner().await?, statement).await + } + + async fn batch(&self, statement: Statement) -> Result<(), Error> { + pg_batch(self.inner().await?, statement).await + } +} + +/// Mint DB implementation with PostgreSQL +pub type MintPgDatabase = SQLMintDatabase; + +/// Mint Auth database with Postgres +#[cfg(feature = "auth")] +pub type MintPgAuthDatabase = SQLMintAuthDatabase; + +/// Wallet DB implementation with PostgreSQL +pub type WalletPgDatabase = SQLWalletDatabase; + +/// Convenience free functions (cannot add inherent impls for a foreign type). +/// These mirror the Mint patterns and call through to the generic constructors. +pub async fn new_wallet_pg_database(conn_str: &str) -> Result { + >::new(conn_str).await +} + +#[cfg(test)] +mod test { + use cdk_common::mint_db_test; + + use super::*; + + async fn provide_db(test_id: String) -> MintPgDatabase { + let db_url = std::env::var("CDK_MINTD_DATABASE_URL") + .or_else(|_| std::env::var("PG_DB_URL")) // Fallback for compatibility + .unwrap_or("host=localhost user=test password=test dbname=testdb port=5433".to_owned()); + + let db_url = format!("{db_url} schema={test_id}"); + + MintPgDatabase::new(db_url.as_str()) + .await + .expect("database") + } + + mint_db_test!(provide_db); +} diff --git a/crates/cdk-postgres/src/value.rs b/crates/cdk-postgres/src/value.rs new file mode 100644 index 000000000..e7c9ed646 --- /dev/null +++ b/crates/cdk-postgres/src/value.rs @@ -0,0 +1,130 @@ +use std::fmt::Debug; + +use cdk_sql_common::value::Value; +use tokio_postgres::types::{self, FromSql, ToSql}; + +#[derive(Debug)] +pub enum PgValue<'a> { + Null, + Integer(i64), + Real(f64), + Text(&'a str), + Blob(&'a [u8]), +} + +impl<'a> From<&'a Value> for PgValue<'a> { + fn from(value: &'a Value) -> Self { + match value { + Value::Blob(b) => PgValue::Blob(b), + Value::Text(text) => PgValue::Text(text.as_str()), + Value::Null => PgValue::Null, + Value::Integer(i) => PgValue::Integer(*i), + Value::Real(r) => PgValue::Real(*r), + } + } +} + +impl<'a> From> for Value { + fn from(val: PgValue<'a>) -> Self { + match val { + PgValue::Blob(value) => Value::Blob(value.to_owned()), + PgValue::Text(value) => Value::Text(value.to_owned()), + PgValue::Null => Value::Null, + PgValue::Integer(n) => Value::Integer(n), + PgValue::Real(r) => Value::Real(r), + } + } +} + +impl<'a> FromSql<'a> for PgValue<'a> { + fn accepts(_ty: &types::Type) -> bool { + true + } + + fn from_sql( + ty: &types::Type, + raw: &'a [u8], + ) -> Result> { + Ok(match *ty { + types::Type::VARCHAR | types::Type::TEXT | types::Type::BPCHAR | types::Type::NAME => { + PgValue::Text(<&str as FromSql>::from_sql(ty, raw)?) + } + types::Type::BOOL => PgValue::Integer(if ::from_sql(ty, raw)? { + 1 + } else { + 0 + }), + types::Type::INT2 => PgValue::Integer(::from_sql(ty, raw)? as i64), + types::Type::INT4 => PgValue::Integer(::from_sql(ty, raw)? as i64), + types::Type::INT8 => PgValue::Integer(::from_sql(ty, raw)?), + types::Type::BIT_ARRAY | types::Type::BYTEA | types::Type::UNKNOWN => { + PgValue::Blob(<&[u8] as FromSql>::from_sql(ty, raw)?) + } + _ => panic!("Unsupported type {ty:?}"), + }) + } + + fn from_sql_null(_ty: &types::Type) -> Result> { + Ok(PgValue::Null) + } +} + +impl ToSql for PgValue<'_> { + fn to_sql( + &self, + ty: &types::Type, + out: &mut types::private::BytesMut, + ) -> Result> + where + Self: Sized, + { + match self { + PgValue::Blob(blob) => (*blob).to_sql(ty, out), + PgValue::Text(text) => (*text).to_sql(ty, out), + PgValue::Null => Ok(types::IsNull::Yes), + PgValue::Real(r) => r.to_sql(ty, out), + PgValue::Integer(i) => match *ty { + types::Type::BOOL => (*i != 0).to_sql(ty, out), + types::Type::INT2 => (*i as i16).to_sql(ty, out), + types::Type::INT4 => (*i as i32).to_sql(ty, out), + _ => i.to_sql_checked(ty, out), + }, + } + } + + fn accepts(_ty: &types::Type) -> bool + where + Self: Sized, + { + true + } + + fn encode_format(&self, ty: &types::Type) -> types::Format { + match self { + PgValue::Blob(blob) => blob.encode_format(ty), + PgValue::Text(text) => text.encode_format(ty), + PgValue::Null => types::Format::Text, + PgValue::Real(r) => r.encode_format(ty), + PgValue::Integer(i) => i.encode_format(ty), + } + } + + fn to_sql_checked( + &self, + ty: &types::Type, + out: &mut types::private::BytesMut, + ) -> Result> { + match self { + PgValue::Blob(blob) => blob.to_sql_checked(ty, out), + PgValue::Text(text) => text.to_sql_checked(ty, out), + PgValue::Null => Ok(types::IsNull::Yes), + PgValue::Real(r) => r.to_sql_checked(ty, out), + PgValue::Integer(i) => match *ty { + types::Type::BOOL => (*i != 0).to_sql_checked(ty, out), + types::Type::INT2 => (*i as i16).to_sql_checked(ty, out), + types::Type::INT4 => (*i as i32).to_sql_checked(ty, out), + _ => i.to_sql_checked(ty, out), + }, + } + } +} diff --git a/crates/cdk-postgres/start_db_for_test.sh b/crates/cdk-postgres/start_db_for_test.sh new file mode 100755 index 000000000..7a53af47a --- /dev/null +++ b/crates/cdk-postgres/start_db_for_test.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER_NAME="rust-test-pg" +DB_USER="test" +DB_PASS="test" +DB_NAME="testdb" +DB_PORT="5433" + +echo "Starting fresh PostgreSQL container..." +docker run -d --rm \ + --name "${CONTAINER_NAME}" \ + -e POSTGRES_USER="${DB_USER}" \ + -e POSTGRES_PASSWORD="${DB_PASS}" \ + -e POSTGRES_DB="${DB_NAME}" \ + -p ${DB_PORT}:5432 \ + postgres:16 + +echo "Waiting for PostgreSQL to be ready and database '${DB_NAME}' to exist..." +until docker exec -e PGPASSWORD="${DB_PASS}" "${CONTAINER_NAME}" \ + psql -U "${DB_USER}" -d "${DB_NAME}" -c "SELECT 1;" >/dev/null 2>&1; do + sleep 0.5 +done + +docker exec -e PGPASSWORD="${DB_PASS}" "${CONTAINER_NAME}" \ + psql -U "${DB_USER}" -d "${DB_NAME}" -c "CREATE DATABASE mintdb;" +docker exec -e PGPASSWORD="${DB_PASS}" "${CONTAINER_NAME}" \ + psql -U "${DB_USER}" -d "${DB_NAME}" -c "CREATE DATABASE mintdb_auth;" + +# Export environment variables for both main and auth databases +export DATABASE_URL="host=localhost user=${DB_USER} password=${DB_PASS} dbname=${DB_NAME} port=${DB_PORT}" +export CDK_MINTD_POSTGRES_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:${DB_PORT}/mintdb" +export CDK_MINTD_AUTH_POSTGRES_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:${DB_PORT}/mintdb_auth" + +echo "Database URLs configured:" +echo "Main database: ${CDK_MINTD_POSTGRES_URL}" +echo "Auth database: ${CDK_MINTD_AUTH_POSTGRES_URL}" diff --git a/crates/cdk-prometheus/Cargo.toml b/crates/cdk-prometheus/Cargo.toml new file mode 100644 index 000000000..13c8a7de1 --- /dev/null +++ b/crates/cdk-prometheus/Cargo.toml @@ -0,0 +1,47 @@ +[package] +name = "cdk-prometheus" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true +readme = "README.md" +description = "Prometheus metrics export server for CDK applications" + +[features] +default = ["system-metrics"] +system-metrics = ["sysinfo"] + +[dependencies] +# Prometheus +prometheus = "0.13" + +# Async runtime +tokio.workspace = true +futures.workspace = true + +# Error handling +anyhow.workspace = true +thiserror.workspace = true + +# Serialization +serde.workspace = true +serde_json.workspace = true + +# System metrics (optional) +sysinfo = { version = "0.32", optional = true } + +# Tracing +tracing.workspace = true + +# Utility +once_cell.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["full"] } +reqwest.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/crates/cdk-prometheus/README.md b/crates/cdk-prometheus/README.md new file mode 100644 index 000000000..00ecdaf87 --- /dev/null +++ b/crates/cdk-prometheus/README.md @@ -0,0 +1,189 @@ +# CDK Prometheus + +A small, focused crate that provides Prometheus metrics for CDK-based services. It bundles a ready-to-use metrics registry, a background HTTP server to expose metrics, helper functions for common CDK domains (HTTP, auth, Lightning, DB, mint operations), and an ergonomic macro for conditional metrics recording. + +- Out-of-the-box metrics for HTTP, auth, Lightning payments, database, and mint operations +- Global, lazily-initialized metrics instance you can use anywhere +- Optional background server to expose metrics on /metrics +- Re-exports the prometheus crate for custom instrumentation +- Optional system metrics (feature-gated) + +## Installation + +Add the crate to your Cargo.toml (replace the version as needed): + +```toml +[dependencies] cdk-prometheus = { version = "0.1", features = ["system-metrics"] } +``` + +- Feature flags: + - system-metrics: include basic process/system metrics collected periodically. + +Note for downstream crates: the provided record_metrics! macro is gated at call-site by a feature named prometheus. If you use that macro, declare a prometheus feature in your application crate and enable it to compile the macro calls into real metrics (otherwise they no-op). + +## Quick start +### Docker +Start Prometheus and Grafana with docker-compose: +``` +docker compose up -d prometheus grafana +``` +Start your mintd +``` +./mintd -w ~/.cdk-mintd +``` +Check Prometheus and Grafana +* `curl localhost:9000/metrics` for checking CDK metrics +* `http://localhost:9090/targets?search=` checking the prometheus collector (you should see http://host.docker.internal:9000/metrics) +* `http://localhost:3011/d/cdk-mint-dashboard/cdk-mint-dashboard` Grafana dashboard (default login: admin/admin) + +### Rust +Expose a Prometheus endpoint with a default registry and CDK metrics: +```rust +use cdk_prometheus::start_default_server_with_metrics; +#[tokio::main] async fn main() -> anyhow::Result<()> { // Starts an HTTP server (default bind and path) and registers CDK metrics into its registry start_default_server_with_metrics().await?; Ok(()) } +``` + +Or start it in the background (e.g., from your application bootstrap): +```rust +use cdk_prometheus::start_background_server_with_metrics; +fn main() -> anyhow::Result<()> { let _handle = start_background_server_with_metrics()?; // Continue bootstrapping your application... Ok(()) } +``` + +## Recording metrics + +You can record metrics using: +- The global helpers (simple functions) +- The global singleton METRICS (direct methods) +- The record_metrics! macro (conditional recording with an optional instance) + +### Global helpers +```rust +use cdk_prometheus::global; +fn handle_request() { + global::record_http_request("/health", "200"); global::record_http_request_duration(0.003, "/health"); + global::record_auth_attempt(); + global::record_auth_success(); + + // Lightning and DB + global::record_lightning_payment(1500.0, 2.0); // amount, fee (both in base units you track) + global::record_db_operation(0.015, "select_user"); + global::set_db_connections_active(8); + + // Mint operations + global::inc_in_flight_requests("get_payment_quote"); + // ... do work ... + global::record_mint_operation("get_payment_quote", true); + global::record_mint_operation_histogram("get_payment_quote", true, 0.021); + global::dec_in_flight_requests("get_payment_quote"); + + // Errors + global::record_error(); +} +``` + +### Using the global METRICS instance directly +```rust +use cdk_prometheus::METRICS; +fn do_db_work() { METRICS.record_db_operation(0.005, "update_user"); } +``` + +### Using the record_metrics! macro + +The macro lets you write grouped calls concisely and optionally pass an instance to use; if no instance is present, it automatically falls back to the global helpers. At call-site, wrap your invocations with a prometheus feature so they can be disabled in minimal builds. +```rust +use cdk_prometheus::record_metrics; +fn run_operation(metrics_opt: Option) { // Use instance if present, otherwise fallback to global record_metrics!(metrics_opt => { inc_in_flight_requests("make_payment"); record_mint_operation("make_payment", true); record_mint_operation_histogram("make_payment", true, 0.123); dec_in_flight_requests("make_payment"); }); + // Or call directly on the global helpers + record_metrics!({ + record_error(); + }); +} +``` + +## Exposing the /metrics endpoint + +If you just need sane defaults, use the convenience starters shown above. If you want finer control (bind address, path, system metrics), build the server explicitly: +```rust +use cdk_prometheus::{PrometheusBuilder, PrometheusServer, CdkMetrics, prometheus::Registry}; +fn build_and_run() -> anyhow::Result>> { // Build a server wired up with the default CDK metrics let server = PrometheusBuilder::new().build_with_cdk_metrics()?; let handle = server.start_background(); Ok(handle) } +``` + +Notes: +- Default bind address and metrics path are set by the server configuration (commonly 127.0.0.1:9090 and /metrics). +- With system-metrics enabled, the server periodically updates process/system gauges. + +## What’s included + +The default CDK metrics instance (CdkMetrics) registers and maintains counters, histograms, and gauges for common areas: +- HTTP: request totals, durations +- Auth: attempts and successes +- Lightning: payment totals, amounts, fees +- Database: operation totals, latencies, active connections +- Mint: operation totals, in-flight gauges, per-operation latencies +- Errors: a general counter + +You can use these immediately through the global helpers or the METRICS instance. + +## Adding custom metrics + +This crate re-exports the prometheus crate and exposes the underlying Registry so you can define and register your own metrics: +```rust +use cdk_prometheus::{prometheus, global}; +fn register_custom_metric() -> Result<(), prometheus::Error> { let my_counter = prometheus::IntCounter::new("my_counter", "A custom counter")?; let registry = global::registry(); // Arcregistry.register(Box::new(my_counter.clone()))?; + my_counter.inc(); + Ok(()) +} +``` + +If you prefer instance-level control: +```rust +use std::sync::Arc; use cdk_prometheus::{create_cdk_metrics, prometheus}; +fn with_instance() -> anyhow::Result<()> { let metrics = create_cdk_metrics()?; let registry: Arc[prometheus::Registry]() = metrics.registry(); + let hist = prometheus::Histogram::with_opts( + prometheus::HistogramOpts::new("my_latency_seconds", "My op latency") +)?; +registry.register(Box::new(hist))?; +Ok(()) +} +``` + +## Scraping with Prometheus + +Example scrape_config: +```yaml +scrape_configs: +- job_name: 'cdk' + scrape_interval: 15s + static_configs: + - targets: ['127.0.0.1:9090'] +``` + +If you changed the bind address or path, make sure to update targets or the metrics_path in your Prometheus configuration accordingly. + +## System metrics (optional) + +Enable the system-metrics feature to export basic process/system metrics. The server updates these at a configurable interval. +```toml +cdk-prometheus = { version = "0.1", features = ["system-metrics"] } +``` + +## Error handling + +Common error types surfaced by this crate include: +- Server bind failures +- Metrics collection/registry errors +- System metrics collection errors (when enabled) + +Handle these at startup and monitor logs during runtime. + +## Best practices + +- Run the metrics server on localhost or a private interface and use a Prometheus agent/sidecar if needed. +- Register application-specific metrics early in your bootstrap so they are visible from the first scrape. +- Use histograms for latencies and size distributions; use counters for event totals; use gauges for in-flight or current-state values. +- Keep label cardinality bounded. + +## License + +MIT +``` diff --git a/crates/cdk-prometheus/src/error.rs b/crates/cdk-prometheus/src/error.rs new file mode 100644 index 000000000..9d8746f9c --- /dev/null +++ b/crates/cdk-prometheus/src/error.rs @@ -0,0 +1,32 @@ +use thiserror::Error; + +/// Errors that can occur in the Prometheus crate +#[derive(Error, Debug)] +pub enum PrometheusError { + /// Server binding error + #[error("Failed to bind to address {address}: {source}")] + ServerBind { + address: String, + #[source] + source: std::io::Error, + }, + + /// Metrics collection error + #[error("Failed to collect metrics: {0}")] + MetricsCollection(String), + + /// Registry error + #[error("Registry error: {source}")] + Registry { + #[from] + source: prometheus::Error, + }, + + /// System metrics error + #[cfg(feature = "system-metrics")] + #[error("System metrics error: {0}")] + SystemMetrics(String), +} + +/// Result type for Prometheus operations +pub type Result = std::result::Result; diff --git a/crates/cdk-prometheus/src/lib.rs b/crates/cdk-prometheus/src/lib.rs new file mode 100644 index 000000000..913b2645b --- /dev/null +++ b/crates/cdk-prometheus/src/lib.rs @@ -0,0 +1,84 @@ +//! # CDK Prometheus + +pub mod error; +pub mod metrics; +pub mod server; + +#[cfg(feature = "system-metrics")] +pub mod process; + +// Re-exports for convenience +pub use error::{PrometheusError, Result}; +pub use metrics::{global, CdkMetrics, METRICS}; +#[cfg(feature = "system-metrics")] +pub use process::SystemMetrics; +// Re-export prometheus crate for custom metrics +pub use prometheus; +pub use server::{PrometheusBuilder, PrometheusConfig, PrometheusServer}; + +/// Macro for recording metrics with optional fallback to global instance +/// +/// Usage: +/// ```rust +/// use cdk_prometheus::record_metrics; +/// +/// // With optional metrics instance +/// record_metrics!(metrics_option => { +/// dec_in_flight_requests("operation"); +/// record_mint_operation("operation", true); +/// }); +/// +/// // Direct global calls +/// record_metrics!({ +/// dec_in_flight_requests("operation"); +/// record_mint_operation("operation", true); +/// }); +/// ``` +#[macro_export] +macro_rules! record_metrics { + // Pattern for using optional metrics with fallback to global + ($metrics_opt:expr => { $($method:ident($($arg:expr),*));* $(;)? }) => { + #[cfg(feature = "prometheus")] + { + if let Some(metrics) = $metrics_opt.as_ref() { + $( + metrics.$method($($arg),*); + )* + } else { + $( + $crate::global::$method($($arg),*); + )* + } + } + }; + + // Pattern for using global metrics directly + ({ $($method:ident($($arg:expr),*));* $(;)? }) => { + #[cfg(feature = "prometheus")] + { + $( + $crate::global::$method($($arg),*); + )* + } + }; +} + +/// Convenience function to create a new CDK metrics instance +/// +/// # Errors +/// Returns an error if any of the metrics cannot be created or registered +pub fn create_cdk_metrics() -> Result { + CdkMetrics::new() +} + +/// Convenience function to start a Prometheus server with specific metrics +/// +/// # Errors +/// Returns an error if the server cannot be created or started +pub async fn start_default_server_with_metrics( + shutdown_signal: impl std::future::Future + Send + 'static, +) -> Result<()> { + let server = PrometheusBuilder::new().build_with_cdk_metrics()?; + + server.start(shutdown_signal).await +} diff --git a/crates/cdk-prometheus/src/metrics.rs b/crates/cdk-prometheus/src/metrics.rs new file mode 100644 index 000000000..e761c5a33 --- /dev/null +++ b/crates/cdk-prometheus/src/metrics.rs @@ -0,0 +1,427 @@ +use std::sync::Arc; + +use prometheus::{ + Histogram, HistogramVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Registry, +}; + +/// Global metrics instance +pub static METRICS: std::sync::LazyLock = std::sync::LazyLock::new(CdkMetrics::default); + +/// Custom metrics for CDK applications +#[derive(Clone, Debug)] +pub struct CdkMetrics { + registry: Arc, + + // HTTP metrics + http_requests_total: IntCounterVec, + http_request_duration: HistogramVec, + + // Authentication metrics + auth_attempts_total: IntCounter, + auth_successes_total: IntCounter, + + // Lightning metrics + lightning_payments_total: IntCounter, + lightning_payment_amount: Histogram, + lightning_payment_fees: Histogram, + + // Database metrics + db_operations_total: IntCounter, + db_operation_duration: HistogramVec, + db_connections_active: IntGauge, + + // Error metrics + errors_total: IntCounter, + + // Mint metrics + mint_operations_total: IntCounterVec, + mint_in_flight_requests: IntGaugeVec, + mint_operation_duration: HistogramVec, +} + +impl CdkMetrics { + /// Create a new instance with default metrics + /// + /// # Errors + /// Returns an error if any of the metrics cannot be created or registered + pub fn new() -> crate::Result { + let registry = Arc::new(Registry::new()); + + // Create and register HTTP metrics + let (http_requests_total, http_request_duration) = Self::create_http_metrics(®istry)?; + + // Create and register authentication metrics + let (auth_attempts_total, auth_successes_total) = Self::create_auth_metrics(®istry)?; + + // Create and register Lightning metrics + let (lightning_payments_total, lightning_payment_amount, lightning_payment_fees) = + Self::create_lightning_metrics(®istry)?; + + // Create and register database metrics + let (db_operations_total, db_operation_duration, db_connections_active) = + Self::create_db_metrics(®istry)?; + + // Create and register error metrics + let errors_total = Self::create_error_metrics(®istry)?; + + // Create and register mint metrics + let (mint_operations_total, mint_operation_duration, mint_in_flight_requests) = + Self::create_mint_metrics(®istry)?; + + Ok(Self { + registry, + http_requests_total, + http_request_duration, + auth_attempts_total, + auth_successes_total, + lightning_payments_total, + lightning_payment_amount, + lightning_payment_fees, + db_operations_total, + db_operation_duration, + db_connections_active, + errors_total, + mint_operations_total, + mint_in_flight_requests, + mint_operation_duration, + }) + } + + /// Create and register HTTP metrics + /// + /// # Errors + /// Returns an error if any of the metrics cannot be created or registered + fn create_http_metrics(registry: &Registry) -> crate::Result<(IntCounterVec, HistogramVec)> { + let http_requests_total = IntCounterVec::new( + prometheus::Opts::new("cdk_http_requests_total", "Total number of HTTP requests"), + &["endpoint", "status"], + )?; + registry.register(Box::new(http_requests_total.clone()))?; + + let http_request_duration = HistogramVec::new( + prometheus::HistogramOpts::new( + "cdk_http_request_duration_seconds", + "HTTP request duration in seconds", + ) + .buckets(vec![ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, + ]), + &["endpoint"], + )?; + registry.register(Box::new(http_request_duration.clone()))?; + + Ok((http_requests_total, http_request_duration)) + } + + /// Create and register authentication metrics + /// + /// # Errors + /// Returns an error if any of the metrics cannot be created or registered + fn create_auth_metrics(registry: &Registry) -> crate::Result<(IntCounter, IntCounter)> { + let auth_attempts_total = + IntCounter::new("cdk_auth_attempts_total", "Total authentication attempts")?; + registry.register(Box::new(auth_attempts_total.clone()))?; + + let auth_successes_total = IntCounter::new( + "cdk_auth_successes_total", + "Total successful authentications", + )?; + registry.register(Box::new(auth_successes_total.clone()))?; + + Ok((auth_attempts_total, auth_successes_total)) + } + + /// Create and register Lightning metrics + /// + /// # Errors + /// Returns an error if any of the metrics cannot be created or registered + fn create_lightning_metrics( + registry: &Registry, + ) -> crate::Result<(IntCounter, Histogram, Histogram)> { + let wallet_operations_total = + IntCounter::new("cdk_wallet_operations_total", "Total wallet operations")?; + registry.register(Box::new(wallet_operations_total))?; + + let lightning_payments_total = + IntCounter::new("cdk_lightning_payments_total", "Total Lightning payments")?; + registry.register(Box::new(lightning_payments_total.clone()))?; + + let lightning_payment_amount = Histogram::with_opts( + prometheus::HistogramOpts::new( + "cdk_lightning_payment_amount_sats", + "Lightning payment amounts in satoshis", + ) + .buckets(vec![ + 1.0, + 10.0, + 100.0, + 1000.0, + 10_000.0, + 100_000.0, + 1_000_000.0, + ]), + )?; + registry.register(Box::new(lightning_payment_amount.clone()))?; + + let lightning_payment_fees = Histogram::with_opts( + prometheus::HistogramOpts::new( + "cdk_lightning_payment_fees_sats", + "Lightning payment fees in satoshis", + ) + .buckets(vec![0.0, 1.0, 5.0, 10.0, 50.0, 100.0, 500.0, 1000.0]), + )?; + registry.register(Box::new(lightning_payment_fees.clone()))?; + + Ok(( + lightning_payments_total, + lightning_payment_amount, + lightning_payment_fees, + )) + } + + /// Create and register database metrics + /// + /// # Errors + /// Returns an error if any of the metrics cannot be created or registered + fn create_db_metrics( + registry: &Registry, + ) -> crate::Result<(IntCounter, HistogramVec, IntGauge)> { + let db_operations_total = + IntCounter::new("cdk_db_operations_total", "Total database operations")?; + registry.register(Box::new(db_operations_total.clone()))?; + let db_operation_duration = HistogramVec::new( + prometheus::HistogramOpts::new( + "cdk_db_operation_duration_seconds", + "Database operation duration in seconds", + ) + .buckets(vec![0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0]), + &["operation"], + )?; + registry.register(Box::new(db_operation_duration.clone()))?; + + let db_connections_active = IntGauge::new( + "cdk_db_connections_active", + "Number of active database connections", + )?; + registry.register(Box::new(db_connections_active.clone()))?; + + Ok(( + db_operations_total, + db_operation_duration, + db_connections_active, + )) + } + + /// Create and register error metrics + /// + /// # Errors + /// Returns an error if any of the metrics cannot be created or registered + fn create_error_metrics(registry: &Registry) -> crate::Result { + let errors_total = IntCounter::new("cdk_errors_total", "Total errors")?; + registry.register(Box::new(errors_total.clone()))?; + + Ok(errors_total) + } + + /// Create and register mint metrics + /// + /// # Errors + /// Returns an error if any of the metrics cannot be created or registered + fn create_mint_metrics( + registry: &Registry, + ) -> crate::Result<(IntCounterVec, HistogramVec, IntGaugeVec)> { + let mint_operations_total = IntCounterVec::new( + prometheus::Opts::new( + "cdk_mint_operations_total", + "Total number of mint operations", + ), + &["operation", "status"], + )?; + registry.register(Box::new(mint_operations_total.clone()))?; + + let mint_operation_duration = HistogramVec::new( + prometheus::HistogramOpts::new( + "cdk_mint_operation_duration_seconds", + "Duration of mint operations in seconds", + ) + .buckets(vec![ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, + ]), + &["operation", "status"], + )?; + registry.register(Box::new(mint_operation_duration.clone()))?; + + let mint_in_flight_requests = IntGaugeVec::new( + prometheus::Opts::new( + "cdk_mint_in_flight_requests", + "Number of in-flight mint requests", + ), + &["operation"], + )?; + registry.register(Box::new(mint_in_flight_requests.clone()))?; + + Ok(( + mint_operations_total, + mint_operation_duration, + mint_in_flight_requests, + )) + } + + /// Get the metrics registry + #[must_use] + pub fn registry(&self) -> Arc { + Arc::::clone(&self.registry) + } + + // HTTP metrics methods + pub fn record_http_request(&self, endpoint: &str, status: &str) { + self.http_requests_total + .with_label_values(&[endpoint, status]) + .inc(); + } + + pub fn record_http_request_duration(&self, duration_seconds: f64, endpoint: &str) { + self.http_request_duration + .with_label_values(&[endpoint]) + .observe(duration_seconds); + } + + // Authentication metrics methods + pub fn record_auth_attempt(&self) { + self.auth_attempts_total.inc(); + } + + pub fn record_auth_success(&self) { + self.auth_successes_total.inc(); + } + + // Lightning metrics methods + pub fn record_lightning_payment(&self, amount: f64, fee: f64) { + self.lightning_payments_total.inc(); + self.lightning_payment_amount.observe(amount); + self.lightning_payment_fees.observe(fee); + } + + // Database metrics methods + pub fn record_db_operation(&self, duration_seconds: f64, op: &str) { + self.db_operations_total.inc(); + self.db_operation_duration + .with_label_values(&[op]) + .observe(duration_seconds); + } + + pub fn set_db_connections_active(&self, count: i64) { + self.db_connections_active.set(count); + } + + // Error metrics methods + pub fn record_error(&self) { + self.errors_total.inc(); + } + + // Mint metrics methods + pub fn record_mint_operation(&self, operation: &str, success: bool) { + let status = if success { "success" } else { "error" }; + self.mint_operations_total + .with_label_values(&[operation, status]) + .inc(); + } + pub fn record_mint_operation_histogram( + &self, + operation: &str, + success: bool, + duration_seconds: f64, + ) { + let status = if success { "success" } else { "error" }; + self.mint_operation_duration + .with_label_values(&[operation, status]) + .observe(duration_seconds); + } + pub fn inc_in_flight_requests(&self, operation: &str) { + self.mint_in_flight_requests + .with_label_values(&[operation]) + .inc(); + } + + pub fn dec_in_flight_requests(&self, operation: &str) { + self.mint_in_flight_requests + .with_label_values(&[operation]) + .dec(); + } +} + +impl Default for CdkMetrics { + fn default() -> Self { + Self::new().expect("Failed to create default CdkMetrics") + } +} + +/// Helper functions for recording metrics using the global instance +pub mod global { + use super::METRICS; + + /// Record an HTTP request using the global metrics instance + pub fn record_http_request(endpoint: &str, status: &str) { + METRICS.record_http_request(endpoint, status); + } + + /// Record HTTP request duration using the global metrics instance + pub fn record_http_request_duration(duration_seconds: f64, endpoint: &str) { + METRICS.record_http_request_duration(duration_seconds, endpoint); + } + + /// Record authentication attempt using the global metrics instance + pub fn record_auth_attempt() { + METRICS.record_auth_attempt(); + } + + /// Record authentication success using the global metrics instance + pub fn record_auth_success() { + METRICS.record_auth_success(); + } + + /// Record Lightning payment using the global metrics instance + pub fn record_lightning_payment(amount: f64, fee: f64) { + METRICS.record_lightning_payment(amount, fee); + } + + /// Record database operation using the global metrics instance + pub fn record_db_operation(duration_seconds: f64, op: &str) { + METRICS.record_db_operation(duration_seconds, op); + } + + /// Set database connections active using the global metrics instance + pub fn set_db_connections_active(count: i64) { + METRICS.set_db_connections_active(count); + } + + /// Record error using the global metrics instance + pub fn record_error() { + METRICS.record_error(); + } + + /// Record mint operation using the global metrics instance + pub fn record_mint_operation(operation: &str, success: bool) { + METRICS.record_mint_operation(operation, success); + } + + /// Record mint operation with histogram using the global metrics instance + pub fn record_mint_operation_histogram(operation: &str, success: bool, duration_seconds: f64) { + METRICS.record_mint_operation_histogram(operation, success, duration_seconds); + } + + /// Increment in-flight requests using the global metrics instance + pub fn inc_in_flight_requests(operation: &str) { + METRICS.inc_in_flight_requests(operation); + } + + /// Decrement in-flight requests using the global metrics instance + pub fn dec_in_flight_requests(operation: &str) { + METRICS.dec_in_flight_requests(operation); + } + + /// Get the metrics registry from the global instance + pub fn registry() -> std::sync::Arc { + METRICS.registry() + } +} diff --git a/crates/cdk-prometheus/src/process.rs b/crates/cdk-prometheus/src/process.rs new file mode 100644 index 000000000..7ccc11f00 --- /dev/null +++ b/crates/cdk-prometheus/src/process.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +#[cfg(feature = "system-metrics")] +use prometheus::{Gauge, IntGauge, Registry}; +#[cfg(feature = "system-metrics")] +use sysinfo::{Pid, System}; + +/// System metrics collector that provides CPU, memory, disk, network, and process metrics +#[cfg(feature = "system-metrics")] +#[derive(Clone, Debug)] +pub struct SystemMetrics { + registry: Arc, + system: Arc>, + + // Process metrics (for the CDK process) + process_cpu_usage_percent: Gauge, + process_memory_bytes: IntGauge, + process_memory_percent: Gauge, +} + +#[cfg(feature = "system-metrics")] +impl SystemMetrics { + /// Create a new `SystemMetrics` instance + /// + /// # Errors + /// Returns an error if any of the metrics cannot be created or registered + pub fn new() -> crate::Result { + let registry = Arc::new(Registry::new()); + // Process metrics + let process_cpu_usage_percent = Gauge::new( + "process_cpu_usage_percent", + "CPU usage percentage of the CDK process (0-100)", + )?; + registry.register(Box::new(process_cpu_usage_percent.clone()))?; + + let process_memory_bytes = IntGauge::new( + "process_memory_bytes", + "Memory usage of the CDK process in bytes", + )?; + registry.register(Box::new(process_memory_bytes.clone()))?; + + let process_memory_percent = Gauge::new( + "process_memory_percent", + "Memory usage percentage of the CDK process (0-100)", + )?; + registry.register(Box::new(process_memory_percent.clone()))?; + + // Initialize system with all needed refresh kinds + let system = Arc::new(std::sync::Mutex::new(System::new())); + + let result = Self { + registry, + system, + process_cpu_usage_percent, + process_memory_bytes, + process_memory_percent, + }; + + Ok(result) + } + + /// Get the metrics registry + #[must_use] + pub fn registry(&self) -> Arc { + Arc::::clone(&self.registry) + } + + /// Update all system metrics + /// + /// # Errors + /// Returns an error if the system mutex cannot be locked + pub fn update_metrics(&self) -> crate::Result<()> { + let mut system = self.system.lock().map_err(|e| { + crate::error::PrometheusError::SystemMetrics(format!("Failed to lock system: {e}")) + })?; + // Refresh system information + system.refresh_all(); + + // Update memory metrics + let total_memory = i64::try_from(system.total_memory()).unwrap_or(i64::MAX); + + // Update process metrics for the current process + // This is a simplified approach that may not work perfectly in all cases + if let Some(process) = system.process(Pid::from(std::process::id() as usize)) { + // Get CPU usage if available + let process_cpu = process.cpu_usage(); + self.process_cpu_usage_percent.set(f64::from(process_cpu)); + + // Get memory usage if available + let process_memory = i64::try_from(process.memory()).unwrap_or(i64::MAX); + self.process_memory_bytes.set(process_memory); + + // Calculate memory percentage + if total_memory > 0 { + // Precision loss is acceptable for percentage calculation + #[allow(clippy::cast_precision_loss)] + let process_memory_percent = (process_memory as f64 / total_memory as f64) * 100.0; + self.process_memory_percent.set(process_memory_percent); + } + } + + // Drop the system lock early to avoid resource contention + drop(system); + + Ok(()) + } +} diff --git a/crates/cdk-prometheus/src/server.rs b/crates/cdk-prometheus/src/server.rs new file mode 100644 index 000000000..68e495ab7 --- /dev/null +++ b/crates/cdk-prometheus/src/server.rs @@ -0,0 +1,317 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use prometheus::{Registry, TextEncoder}; + +use crate::metrics::METRICS; +#[cfg(feature = "system-metrics")] +use crate::process::SystemMetrics; + +/// Configuration for the Prometheus server +#[derive(Debug, Clone)] +pub struct PrometheusConfig { + /// Address to bind the server to (default: "127.0.0.1:9090") + pub bind_address: SocketAddr, + /// Path to serve metrics on (default: "/metrics") + pub metrics_path: String, + /// Whether to include system metrics (default: true if feature enabled) + #[cfg(feature = "system-metrics")] + pub include_system_metrics: bool, + /// How often to update system metrics in seconds (default: 15) + #[cfg(feature = "system-metrics")] + pub system_metrics_interval: u64, +} + +impl Default for PrometheusConfig { + fn default() -> Self { + Self { + bind_address: "127.0.0.1:9090".parse().expect("Invalid default address"), + metrics_path: "/metrics".to_string(), + #[cfg(feature = "system-metrics")] + include_system_metrics: true, + #[cfg(feature = "system-metrics")] + system_metrics_interval: 15, + } + } +} + +/// Prometheus metrics server +#[derive(Debug)] +pub struct PrometheusServer { + config: PrometheusConfig, + registry: Arc, + #[cfg(feature = "system-metrics")] + system_metrics: Option, +} + +impl PrometheusServer { + /// Create a new Prometheus server with CDK metrics + /// + /// # Errors + /// Returns an error if system metrics cannot be created (when enabled) + pub fn new(config: PrometheusConfig) -> crate::Result { + let registry = METRICS.registry(); + + #[cfg(feature = "system-metrics")] + let system_metrics = if config.include_system_metrics { + let sys_metrics = SystemMetrics::new()?; + Some(sys_metrics) + } else { + None + }; + + Ok(Self { + config, + registry, + #[cfg(feature = "system-metrics")] + system_metrics, + }) + } + + /// Create a new Prometheus server with custom registry + #[must_use] + pub const fn with_registry(config: PrometheusConfig, registry: Arc) -> Self { + Self { + config, + registry, + #[cfg(feature = "system-metrics")] + system_metrics: None, + } + } + + /// Create a metrics handler function that gathers and encodes metrics + fn create_metrics_handler( + registry: Arc, + #[cfg(feature = "system-metrics")] system_metrics: Option, + ) -> impl Fn() -> String { + move || { + let encoder = TextEncoder::new(); + + // Collect metrics from our registry + #[cfg(feature = "system-metrics")] + let mut metric_families = registry.gather(); + #[cfg(not(feature = "system-metrics"))] + let metric_families = registry.gather(); + + // Add system metrics if available + #[cfg(feature = "system-metrics")] + if let Some(ref sys_metrics) = system_metrics { + // Update system metrics before collection + if let Err(e) = sys_metrics.update_metrics() { + tracing::warn!("Failed to update system metrics: {e}"); + } + + let sys_registry = sys_metrics.registry(); + let mut sys_families = sys_registry.gather(); + metric_families.append(&mut sys_families); + } + + // Encode metrics to string + encoder + .encode_to_string(&metric_families) + .unwrap_or_else(|e| { + tracing::error!("Failed to encode metrics: {e}"); + format!("Failed to encode metrics: {e}") + }) + } + } + + /// Start the Prometheus HTTP server + /// + /// # Errors + /// This function always returns Ok as errors are handled internally + pub async fn start( + self, + shutdown_signal: impl std::future::Future + Send + 'static, + ) -> crate::Result<()> { + // Create and start the exporter + let binding = self.config.bind_address; + let registry_clone = Arc::::clone(&self.registry); + + // Create a handler that exposes our registry + #[cfg(feature = "system-metrics")] + let metrics_handler = + Self::create_metrics_handler(registry_clone, self.system_metrics.clone()); + + #[cfg(not(feature = "system-metrics"))] + let metrics_handler = Self::create_metrics_handler(registry_clone); + + // Start the exporter in a background task + let path = self.config.metrics_path.clone(); + + // Create a channel for signaling the server task to shutdown + let (shutdown_tx, mut shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + // Spawn the server task + let server_handle = tokio::spawn(async move { + // We're using a simple HTTP server to expose our metrics + use std::io::{Read, Write}; + use std::net::TcpListener; + + // Create a TCP listener + let listener = match TcpListener::bind(binding) { + Ok(listener) => { + // Set non-blocking mode to allow for shutdown checking + if let Err(e) = listener.set_nonblocking(true) { + tracing::error!("Failed to set non-blocking mode: {e}"); + return; + } + listener + } + Err(e) => { + tracing::error!("Failed to bind TCP listener: {e}"); + return; + } + }; + tracing::info!("Started Prometheus server on {} at path {}", binding, path); + + // Accept connections with shutdown signal handling + loop { + // Check for shutdown signal + if shutdown_rx.try_recv().is_ok() { + tracing::info!("Shutdown signal received, stopping Prometheus server"); + break; + } + + // Try to accept a connection (non-blocking) + match listener.accept() { + Ok((mut stream, _)) => { + // Handle the connection + let mut buffer = [0; 1024]; + match stream.read(&mut buffer) { + Ok(0) => {} + Ok(bytes_read) => { + // Convert the buffer to a string + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + + // Check if the request is for our metrics path + if request.contains(&format!("GET {path} HTTP")) { + // Get the metrics + let metrics = metrics_handler(); + + // Write the response + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\n\r\n{}", + metrics.len(), + metrics + ); + + if let Err(e) = stream.write_all(response.as_bytes()) { + tracing::error!("Failed to write response: {e}"); + } + } else { + // Write a 404 response + let response = "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 9\r\n\r\nNot Found"; + if let Err(e) = stream.write_all(response.as_bytes()) { + tracing::error!("Failed to write response: {e}"); + } + } + } + Err(e) => { + tracing::error!("Failed to read from stream: {e}"); + } + } + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { + // No connection available, continue the loop + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(e) => { + tracing::error!("Failed to accept connection: {e}"); + // Add a small delay to prevent busy looping on persistent errors + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + } + + tracing::info!("Prometheus server stopped"); + }); + + // Wait for the shutdown signal + shutdown_signal.await; + + // Signal the server to shutdown + let _ = shutdown_tx.send(()); + + // Wait for the server task to complete (with a timeout) + match tokio::time::timeout(Duration::from_secs(5), server_handle).await { + Ok(result) => { + if let Err(e) = result { + tracing::error!("Server task failed: {e}"); + } + } + Err(_) => { + tracing::warn!("Server shutdown timed out after 5 seconds"); + } + } + + Ok(()) + } +} + +/// Builder for easy Prometheus server setup +#[derive(Debug)] +pub struct PrometheusBuilder { + config: PrometheusConfig, +} + +impl PrometheusBuilder { + /// Create a new builder with default configuration + #[must_use] + pub fn new() -> Self { + Self { + config: PrometheusConfig::default(), + } + } + + /// Set the bind address + #[must_use] + pub const fn bind_address(mut self, addr: SocketAddr) -> Self { + self.config.bind_address = addr; + self + } + + /// Set the metrics path + #[must_use] + pub fn metrics_path>(mut self, path: S) -> Self { + self.config.metrics_path = path.into(); + self + } + + /// Enable or disable system metrics + #[cfg(feature = "system-metrics")] + #[must_use] + pub const fn system_metrics(mut self, enabled: bool) -> Self { + self.config.include_system_metrics = enabled; + self + } + + /// Set system metrics update interval + #[cfg(feature = "system-metrics")] + #[must_use] + pub const fn system_metrics_interval(mut self, seconds: u64) -> Self { + self.config.system_metrics_interval = seconds; + self + } + + /// Build the server with specific CDK metrics instance + /// + /// # Errors + /// Returns an error if system metrics cannot be created (when enabled) + pub fn build_with_cdk_metrics(self) -> crate::Result { + PrometheusServer::new(self.config) + } + + /// Build the server with custom registry + #[must_use] + pub fn build_with_registry(self, registry: Arc) -> PrometheusServer { + PrometheusServer::with_registry(self.config, registry) + } +} + +impl Default for PrometheusBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/cdk-redb/Cargo.toml b/crates/cdk-redb/Cargo.toml index f50b85115..94a0c520e 100644 --- a/crates/cdk-redb/Cargo.toml +++ b/crates/cdk-redb/Cargo.toml @@ -19,7 +19,7 @@ auth = ["cdk-common/auth"] [dependencies] async-trait.workspace = true cdk-common = { workspace = true, features = ["test"] } -redb = "2.4.0" +redb = "2.6.3" thiserror.workspace = true tracing.workspace = true serde.workspace = true @@ -30,3 +30,6 @@ uuid.workspace = true [dev-dependencies] tempfile = "3.17.1" tokio.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +uuid = { workspace = true, features = ["js"] } diff --git a/crates/cdk-redb/src/error.rs b/crates/cdk-redb/src/error.rs index 818f57d22..2964280f5 100644 --- a/crates/cdk-redb/src/error.rs +++ b/crates/cdk-redb/src/error.rs @@ -25,6 +25,9 @@ pub enum Error { /// Redb Storage Error #[error(transparent)] Storage(#[from] Box), + /// Upgrade Transaction Error + #[error(transparent)] + Upgrade(#[from] Box), /// Serde Json Error #[error(transparent)] Serde(#[from] serde_json::Error), @@ -40,6 +43,9 @@ pub enum Error { /// CDK Error #[error(transparent)] CDK(#[from] cdk_common::error::Error), + /// IO Error + #[error(transparent)] + Io(#[from] std::io::Error), /// NUT00 Error #[error(transparent)] CDKNUT00(#[from] cdk_common::nuts::nut00::Error), @@ -64,6 +70,9 @@ pub enum Error { /// Unknown Database Version #[error("Unknown database version")] UnknownDatabaseVersion, + /// Duplicate + #[error("Duplicate")] + Duplicate, } impl From for cdk_common::database::Error { @@ -108,3 +117,9 @@ impl From for Error { Self::Storage(Box::new(e)) } } + +impl From for Error { + fn from(e: redb::UpgradeError) -> Self { + Self::Upgrade(Box::new(e)) + } +} diff --git a/crates/cdk-redb/src/wallet/migrations.rs b/crates/cdk-redb/src/wallet/migrations.rs index 27f113325..948e9ab1c 100644 --- a/crates/cdk-redb/src/wallet/migrations.rs +++ b/crates/cdk-redb/src/wallet/migrations.rs @@ -1,14 +1,17 @@ //! Wallet Migrations +use std::collections::HashSet; use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; use cdk_common::mint_url::MintUrl; +use cdk_common::Id; use redb::{ Database, MultimapTableDefinition, ReadableMultimapTable, ReadableTable, TableDefinition, }; use super::Error; +use crate::wallet::{KEYSETS_TABLE, KEYSET_COUNTER, KEYSET_U32_MAPPING, MINT_KEYS_TABLE}; // const MINTS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("mints_table"); @@ -16,6 +19,57 @@ const MINTS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("mints_tab const MINT_KEYSETS_TABLE: MultimapTableDefinition<&str, &[u8]> = MultimapTableDefinition::new("mint_keysets"); +pub(crate) fn migrate_02_to_03(db: Arc) -> Result { + let write_txn = db.begin_write().map_err(Error::from)?; + + let mut duplicate = false; + + { + let table = write_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?; + + let ids: Vec = table + .iter() + .map_err(Error::from)? + .flatten() + .flat_map(|(id, _)| Id::from_str(id.value())) + .collect(); + + let mut table = write_txn + .open_table(KEYSET_U32_MAPPING) + .map_err(Error::from)?; + + // Also process existing keysets + let keysets_table = write_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?; + let keyset_ids: Vec = keysets_table + .iter() + .map_err(Error::from)? + .flatten() + .flat_map(|(id_bytes, _)| Id::from_bytes(id_bytes.value())) + .collect(); + + let ids: HashSet = ids.into_iter().chain(keyset_ids).collect(); + + for id in ids { + let t = table.insert(u32::from(id), id.to_string().as_str())?; + + tracing::info!("Adding u32 {} for keyset {}", u32::from(id), id.to_string()); + + if t.is_some() { + duplicate = true; + } + } + } + + if duplicate { + write_txn.abort()?; + return Err(Error::Duplicate); + } + + write_txn.commit()?; + + Ok(3) +} + pub fn migrate_01_to_02(db: Arc) -> Result { migrate_trim_mint_urls_01_to_02(db)?; Ok(2) @@ -98,3 +152,51 @@ fn migrate_trim_mint_urls_01_to_02(db: Arc) -> Result<(), Error> { migrate_mint_keyset_table_01_to_02(Arc::clone(&db))?; Ok(()) } + +pub(crate) fn migrate_03_to_04(db: Arc) -> Result { + let write_txn = db.begin_write().map_err(Error::from)?; + + // Get all existing keyset IDs from the KEYSET_COUNTER table that have a counter > 0 + let keyset_ids_to_increment: Vec<(String, u32)>; + { + let table = write_txn.open_table(KEYSET_COUNTER).map_err(Error::from)?; + + keyset_ids_to_increment = table + .iter() + .map_err(Error::from)? + .flatten() + .filter_map(|(keyset_id, counter)| { + let counter_value = counter.value(); + // Only include keysets where counter > 0 + if counter_value > 0 { + Some((keyset_id.value().to_string(), counter_value)) + } else { + None + } + }) + .collect(); + } + + // Increment counter by 1 for all keysets where counter > 0 + { + let mut table = write_txn.open_table(KEYSET_COUNTER).map_err(Error::from)?; + + for (keyset_id, current_counter) in keyset_ids_to_increment { + let new_counter = current_counter + 1; + table + .insert(keyset_id.as_str(), new_counter) + .map_err(Error::from)?; + + tracing::info!( + "Incremented counter for keyset {} from {} to {}", + keyset_id, + current_counter, + new_counter + ); + } + } + + write_txn.commit()?; + + Ok(4) +} diff --git a/crates/cdk-redb/src/wallet/mod.rs b/crates/cdk-redb/src/wallet/mod.rs index cdeac6f8e..70b6af160 100644 --- a/crates/cdk-redb/src/wallet/mod.rs +++ b/crates/cdk-redb/src/wallet/mod.rs @@ -21,7 +21,7 @@ use tracing::instrument; use super::error::Error; use crate::migrations::migrate_00_to_01; -use crate::wallet::migrations::migrate_01_to_02; +use crate::wallet::migrations::{migrate_01_to_02, migrate_02_to_03, migrate_03_to_04}; mod migrations; @@ -44,7 +44,9 @@ const KEYSET_COUNTER: TableDefinition<&str, u32> = TableDefinition::new("keyset_ // const TRANSACTIONS_TABLE: TableDefinition<&[u8], &str> = TableDefinition::new("transactions"); -const DATABASE_VERSION: u32 = 2; +const KEYSET_U32_MAPPING: TableDefinition = TableDefinition::new("keyset_u32_mapping"); + +const DATABASE_VERSION: u32 = 4; /// Wallet Redb Database #[derive(Debug, Clone)] @@ -56,6 +58,16 @@ impl WalletRedbDatabase { /// Create new [`WalletRedbDatabase`] pub fn new(path: &Path) -> Result { { + // Check if parent directory exists before attempting to create database + if let Some(parent) = path.parent() { + if !parent.exists() { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Parent directory does not exist: {parent:?}"), + ))); + } + } + let db = Arc::new(Database::create(path)?); let db_version: Option; @@ -90,6 +102,14 @@ impl WalletRedbDatabase { current_file_version = migrate_01_to_02(Arc::clone(&db))?; } + if current_file_version == 2 { + current_file_version = migrate_02_to_03(Arc::clone(&db))?; + } + + if current_file_version == 3 { + current_file_version = migrate_03_to_04(Arc::clone(&db))?; + } + if current_file_version != DATABASE_VERSION { tracing::warn!( "Database upgrade did not complete at {} current is {}", @@ -136,6 +156,7 @@ impl WalletRedbDatabase { let _ = write_txn.open_table(PROOFS_TABLE)?; let _ = write_txn.open_table(KEYSET_COUNTER)?; let _ = write_txn.open_table(TRANSACTIONS_TABLE)?; + let _ = write_txn.open_table(KEYSET_U32_MAPPING)?; table.insert("db_version", DATABASE_VERSION.to_string().as_str())?; } @@ -145,7 +166,19 @@ impl WalletRedbDatabase { drop(db); } - let db = Database::create(path)?; + // Check parent directory again for final database creation + if let Some(parent) = path.parent() { + if !parent.exists() { + return Err(Error::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("Parent directory does not exist: {parent:?}"), + ))); + } + } + + let mut db = Database::create(path)?; + + db.upgrade()?; Ok(Self { db: Arc::new(db) }) } @@ -290,20 +323,64 @@ impl WalletDatabase for WalletRedbDatabase { ) -> Result<(), Self::Err> { let write_txn = self.db.begin_write().map_err(Error::from)?; + let mut existing_u32 = false; + { let mut table = write_txn .open_multimap_table(MINT_KEYSETS_TABLE) .map_err(Error::from)?; let mut keysets_table = write_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?; + let mut u32_table = write_txn + .open_table(KEYSET_U32_MAPPING) + .map_err(Error::from)?; for keyset in keysets { - table - .insert( - mint_url.to_string().as_str(), - keyset.id.to_bytes().as_slice(), - ) + // Check if keyset already exists + let existing_keyset = { + let existing_keyset = keysets_table + .get(keyset.id.to_bytes().as_slice()) + .map_err(Error::from)?; + + existing_keyset.map(|r| r.value().to_string()) + }; + + let existing = u32_table + .insert(u32::from(keyset.id), keyset.id.to_string().as_str()) .map_err(Error::from)?; + match existing { + None => existing_u32 = false, + Some(id) => { + let id = Id::from_str(id.value())?; + + if id == keyset.id { + existing_u32 = false; + } else { + println!("Breaking here"); + existing_u32 = true; + break; + } + } + } + + let keyset = if let Some(existing_keyset) = existing_keyset { + let mut existing_keyset: KeySetInfo = serde_json::from_str(&existing_keyset)?; + + existing_keyset.active = keyset.active; + existing_keyset.input_fee_ppk = keyset.input_fee_ppk; + + existing_keyset + } else { + table + .insert( + mint_url.to_string().as_str(), + keyset.id.to_bytes().as_slice(), + ) + .map_err(Error::from)?; + + keyset + }; + keysets_table .insert( keyset.id.to_bytes().as_slice(), @@ -314,6 +391,14 @@ impl WalletDatabase for WalletRedbDatabase { .map_err(Error::from)?; } } + + if existing_u32 { + tracing::warn!("Keyset already exists for keyset id"); + write_txn.abort().map_err(Error::from)?; + + return Err(database::Error::Duplicate); + } + write_txn.commit().map_err(Error::from)?; Ok(()) @@ -477,6 +562,21 @@ impl WalletDatabase for WalletRedbDatabase { Ok(None) } + #[instrument(skip_all)] + async fn get_melt_quotes(&self) -> Result, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn + .open_table(MELT_QUOTES_TABLE) + .map_err(Error::from)?; + + Ok(table + .iter() + .map_err(Error::from)? + .flatten() + .flat_map(|(_id, quote)| serde_json::from_str(quote.value())) + .collect()) + } + #[instrument(skip_all)] async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> { let write_txn = self.db.begin_write().map_err(Error::from)?; @@ -499,16 +599,45 @@ impl WalletDatabase for WalletRedbDatabase { keyset.verify_id()?; + let existing_keys; + let existing_u32; + { let mut table = write_txn.open_table(MINT_KEYS_TABLE).map_err(Error::from)?; - table + + existing_keys = table .insert( keyset.id.to_string().as_str(), serde_json::to_string(&keyset.keys) .map_err(Error::from)? .as_str(), ) + .map_err(Error::from)? + .is_some(); + + let mut table = write_txn + .open_table(KEYSET_U32_MAPPING) + .map_err(Error::from)?; + + let existing = table + .insert(u32::from(keyset.id), keyset.id.to_string().as_str()) .map_err(Error::from)?; + + match existing { + None => existing_u32 = false, + Some(id) => { + let id = Id::from_str(id.value())?; + + existing_u32 = id != keyset.id; + } + } + } + + if existing_keys || existing_u32 { + tracing::warn!("Keys already exist for keyset id"); + write_txn.abort().map_err(Error::from)?; + + return Err(database::Error::Duplicate); } write_txn.commit().map_err(Error::from)?; @@ -612,6 +741,40 @@ impl WalletDatabase for WalletRedbDatabase { Ok(proofs) } + #[instrument(skip(self, ys))] + async fn get_proofs_by_ys(&self, ys: Vec) -> Result, Self::Err> { + if ys.is_empty() { + return Ok(Vec::new()); + } + + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn.open_table(PROOFS_TABLE).map_err(Error::from)?; + + let mut proofs = Vec::new(); + + for y in ys { + if let Some(proof) = table.get(y.to_bytes().as_slice()).map_err(Error::from)? { + let proof_info = + serde_json::from_str::(proof.value()).map_err(Error::from)?; + proofs.push(proof_info); + } + } + + Ok(proofs) + } + + async fn get_balance( + &self, + mint_url: Option, + unit: Option, + state: Option>, + ) -> Result { + // For redb, we still need to fetch all proofs and sum them + // since redb doesn't have SQL aggregation + let proofs = self.get_proofs(mint_url, unit, state, None).await?; + Ok(proofs.iter().map(|p| u64::from(p.proof.amount)).sum()) + } + async fn update_proofs_state( &self, ys: Vec, @@ -653,10 +816,11 @@ impl WalletDatabase for WalletRedbDatabase { } #[instrument(skip(self), fields(keyset_id = %keyset_id))] - async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err> { + async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result { let write_txn = self.db.begin_write().map_err(Error::from)?; let current_counter; + let new_counter; { let table = write_txn.open_table(KEYSET_COUNTER).map_err(Error::from)?; let counter = table @@ -667,11 +831,12 @@ impl WalletDatabase for WalletRedbDatabase { Some(c) => c.value(), None => 0, }; + + new_counter = current_counter + count; } { let mut table = write_txn.open_table(KEYSET_COUNTER).map_err(Error::from)?; - let new_counter = current_counter + count; table .insert(keyset_id.to_string().as_str(), new_counter) @@ -679,23 +844,13 @@ impl WalletDatabase for WalletRedbDatabase { } write_txn.commit().map_err(Error::from)?; - Ok(()) - } - - #[instrument(skip(self), fields(keyset_id = %keyset_id))] - async fn get_keyset_counter(&self, keyset_id: &Id) -> Result, Self::Err> { - let read_txn = self.db.begin_read().map_err(Error::from)?; - let table = read_txn.open_table(KEYSET_COUNTER).map_err(Error::from)?; - - let counter = table - .get(keyset_id.to_string().as_str()) - .map_err(Error::from)?; - - Ok(counter.map(|c| c.value())) + Ok(new_counter) } #[instrument(skip(self))] async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> { + let id = transaction.id(); + let write_txn = self.db.begin_write().map_err(Error::from)?; { @@ -704,7 +859,7 @@ impl WalletDatabase for WalletRedbDatabase { .map_err(Error::from)?; table .insert( - transaction.id().as_slice(), + id.as_slice(), serde_json::to_string(&transaction) .map_err(Error::from)? .as_str(), diff --git a/crates/cdk-rexie/README.md b/crates/cdk-rexie/README.md deleted file mode 100644 index 11920ba96..000000000 --- a/crates/cdk-rexie/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# CDK Rexie - -[![crates.io](https://img.shields.io/crates/v/cdk-rexie.svg)](https://crates.io/crates/cdk-rexie) -[![Documentation](https://docs.rs/cdk-rexie/badge.svg)](https://docs.rs/cdk-rexie) -[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE) - -**ALPHA** This library is in early development, the API will change and should be used with caution. - -[Rexie](https://github.com/SaltyAom/rexie) (IndexedDB) storage backend implementation for the Cashu Development Kit (CDK). This provides browser-based storage for web applications. - -## Features - -This crate provides a Rexie-based storage implementation for browser environments: -- Wallet storage -- Transaction history -- Proof tracking -- IndexedDB persistence - -## Installation - -Add this to your `Cargo.toml`: - -```toml -[dependencies] -cdk-rexie = "*" -``` - - -## WASM Support - -This crate is specifically designed for use in WebAssembly environments and requires the `wasm32-unknown-unknown` target. - -## License - -This project is licensed under the [MIT License](../../LICENSE). \ No newline at end of file diff --git a/crates/cdk-rexie/src/lib.rs b/crates/cdk-rexie/src/lib.rs deleted file mode 100644 index fca5caad1..000000000 --- a/crates/cdk-rexie/src/lib.rs +++ /dev/null @@ -1,10 +0,0 @@ -//! Rexie Indexdb database - -#![warn(missing_docs)] -#![warn(rustdoc::bare_urls)] - -#[cfg(all(feature = "wallet", target_arch = "wasm32"))] -pub mod wallet; - -#[cfg(all(feature = "wallet", target_arch = "wasm32"))] -pub use wallet::WalletRexieDatabase; diff --git a/crates/cdk-rexie/src/wallet.rs b/crates/cdk-rexie/src/wallet.rs deleted file mode 100644 index 20b01895e..000000000 --- a/crates/cdk-rexie/src/wallet.rs +++ /dev/null @@ -1,751 +0,0 @@ -//! Rexie Browser Database - -use std::collections::{HashMap, HashSet}; -use std::rc::Rc; -use std::result::Result; - -use async_trait::async_trait; -use cdk::cdk_database::{self, WalletDatabase}; -use cdk::mint_url::MintUrl; -use cdk::nuts::{ - CurrencyUnit, Id, KeySetInfo, Keys, MintInfo, PublicKey, SpendingConditions, State, -}; -use cdk::types::ProofInfo; -use cdk::util::unix_time; -use cdk::wallet::{MeltQuote, MintQuote}; -use rexie::*; -use thiserror::Error; -use tokio::sync::Mutex; - -// Tables -const MINTS: &str = "mints"; -const MINT_KEYSETS: &str = "keysets_by_mint"; -const KEYSETS: &str = "keysets"; -const MINT_KEYS: &str = "mint_keys"; -const MINT_QUOTES: &str = "mint_quotes"; -const MELT_QUOTES: &str = "melt_quotes"; -const PROOFS: &str = "proofs"; -const CONFIG: &str = "config"; -const KEYSET_COUNTER: &str = "keyset_counter"; - -const DATABASE_VERSION: u32 = 4; - -/// Rexie Database Error -#[derive(Debug, Error)] -pub enum Error { - /// CDK Database Error - #[error(transparent)] - CDKDatabase(#[from] cdk::cdk_database::Error), - /// Rexie Error - #[error(transparent)] - Rexie(#[from] rexie::Error), - /// Serde Wasm Error - #[error(transparent)] - SerdeBindgen(#[from] serde_wasm_bindgen::Error), - /// NUT00 Error - #[error(transparent)] - NUT00(cdk::nuts::nut00::Error), - #[error("Not found")] - /// Not Found - NotFound, -} -impl From for cdk::cdk_database::Error { - fn from(e: Error) -> Self { - Self::Database(Box::new(e)) - } -} - -// These are okay because we never actually send across threads in the browser -unsafe impl Send for Error {} -unsafe impl Sync for Error {} - -/// Wallet Rexie Database -#[derive(Debug, Clone)] -pub struct WalletRexieDatabase { - db: Rc>, -} - -// These are okay because we never actually send across threads in the browser -unsafe impl Send for WalletRexieDatabase {} -unsafe impl Sync for WalletRexieDatabase {} - -impl WalletRexieDatabase { - /// Create new [`WalletRexieDatabase`] - pub async fn new() -> Result { - let rexie = Rexie::builder("cdk") - .version(DATABASE_VERSION) - .add_object_store( - ObjectStore::new(PROOFS) - .add_index(Index::new("y", "y").unique(true)) - .add_index(Index::new("mint_url", "mint_url")) - .add_index(Index::new("state", "state")) - .add_index(Index::new("unit", "unit")), - ) - .add_object_store( - ObjectStore::new(MINTS).add_index(Index::new("mint_url", "mint_url").unique(true)), - ) - .add_object_store(ObjectStore::new(MINT_KEYSETS)) - .add_object_store( - ObjectStore::new(KEYSETS) - .add_index(Index::new("keyset_id", "keyset_id").unique(true)), - ) - .add_object_store( - ObjectStore::new(MINT_KEYS) - .add_index(Index::new("keyset_id", "keyset_id").unique(true)), - ) - .add_object_store(ObjectStore::new(MINT_QUOTES)) - .add_object_store(ObjectStore::new(MELT_QUOTES)) - .add_object_store(ObjectStore::new(CONFIG)) - .add_object_store(ObjectStore::new(KEYSET_COUNTER)) - // Build the database - .build() - .await - .unwrap(); - - Ok(Self { - db: Rc::new(Mutex::new(rexie)), - }) - } - - async fn set_proof_states( - &self, - ys: Vec, - state: State, - ) -> Result<(), cdk_database::Error> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[PROOFS], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let proofs_store = transaction.store(PROOFS).map_err(Error::from)?; - - for y in ys { - let y = serde_wasm_bindgen::to_value(&y).map_err(Error::from)?; - - let mut proof: ProofInfo = proofs_store - .get(y.clone()) - .await - .map_err(Error::from)? - .and_then(|p| serde_wasm_bindgen::from_value(p).ok()) - .ok_or(Error::NotFound)?; - - proof.state = state; - - let proof = serde_wasm_bindgen::to_value(&proof).map_err(Error::from)?; - - proofs_store - .put(&proof, Some(&y)) - .await - .map_err(Error::from)?; - } - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } -} - -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -impl WalletDatabase for WalletRexieDatabase { - type Err = cdk::cdk_database::Error; - - async fn add_mint( - &self, - mint_url: MintUrl, - mint_info: Option, - ) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINTS], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let mints_store = transaction.store(MINTS).map_err(Error::from)?; - - let mint_url = serde_wasm_bindgen::to_value(&mint_url).map_err(Error::from)?; - let mint_info = serde_wasm_bindgen::to_value(&mint_info).map_err(Error::from)?; - - mints_store - .put(&mint_info, Some(&mint_url)) - .await - .map_err(Error::from)?; - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINTS], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let mints_store = transaction.store(MINTS).map_err(Error::from)?; - - let mint_url = serde_wasm_bindgen::to_value(&mint_url).map_err(Error::from)?; - - mints_store.delete(mint_url).await.map_err(Error::from)?; - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn get_mint(&self, mint_url: MintUrl) -> Result, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINTS], TransactionMode::ReadOnly) - .map_err(Error::from)?; - - let mints_store = transaction.store(MINTS).map_err(Error::from)?; - - let mint_url = serde_wasm_bindgen::to_value(&mint_url).map_err(Error::from)?; - let mint_info = mints_store - .get(mint_url) - .await - .map_err(Error::from)? - .and_then(|m| serde_wasm_bindgen::from_value(m).ok()); - - Ok(mint_info) - } - - async fn get_mints(&self) -> Result>, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINTS], TransactionMode::ReadOnly) - .map_err(Error::from)?; - - let mints_store = transaction.store(MINTS).map_err(Error::from)?; - - let mints = mints_store - .scan(None, None, None, None) - .await - .map_err(Error::from)?; - - let mints: HashMap> = mints - .into_iter() - .map(|(url, info)| { - ( - serde_wasm_bindgen::from_value(url).unwrap(), - serde_wasm_bindgen::from_value(info).unwrap(), - ) - }) - .collect(); - - Ok(mints) - } - - async fn update_mint_url( - &self, - old_mint_url: MintUrl, - new_mint_url: MintUrl, - ) -> Result<(), Self::Err> { - let proofs = self - .get_proofs(Some(old_mint_url), None, None, None) - .await - .map_err(Error::from)?; - - let updated_proofs: Vec = proofs - .into_iter() - .map(|mut p| { - p.mint_url = new_mint_url.clone(); - p - }) - .collect(); - - if !updated_proofs.is_empty() { - self.update_proofs(updated_proofs, vec![]).await?; - } - - // Update mint quotes - { - let quotes = self.get_mint_quotes().await?; - - let unix_time = unix_time(); - - let quotes: Vec = quotes - .into_iter() - .filter_map(|mut q| { - if q.expiry < unix_time { - q.mint_url = new_mint_url.clone(); - Some(q) - } else { - None - } - }) - .collect(); - - for quote in quotes { - self.add_mint_quote(quote).await?; - } - } - - Ok(()) - } - - async fn add_mint_keysets( - &self, - mint_url: MintUrl, - keysets: Vec, - ) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_KEYSETS, KEYSETS], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let mint_keysets_store = transaction.store(MINT_KEYSETS).map_err(Error::from)?; - let keysets_store = transaction.store(KEYSETS).map_err(Error::from)?; - - let mint_url = serde_wasm_bindgen::to_value(&mint_url).map_err(Error::from)?; - - let mut mint_keysets = mint_keysets_store - .get(mint_url.clone()) - .await - .map_err(Error::from)? - .and_then(|m| serde_wasm_bindgen::from_value(m).ok()); - - let new_keyset_ids: Vec = keysets.iter().map(|k| k.id).collect(); - - mint_keysets - .as_mut() - .unwrap_or(&mut HashSet::new()) - .extend(new_keyset_ids); - - let mint_keysets = serde_wasm_bindgen::to_value(&mint_keysets).map_err(Error::from)?; - - mint_keysets_store - .put(&mint_keysets, Some(&mint_url)) - .await - .map_err(Error::from)?; - - for keyset in keysets { - let id = serde_wasm_bindgen::to_value(&keyset.id).map_err(Error::from)?; - let keyset = serde_wasm_bindgen::to_value(&keyset).map_err(Error::from)?; - - keysets_store - .put(&keyset, Some(&id)) - .await - .map_err(Error::from)?; - } - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn get_mint_keysets( - &self, - mint_url: MintUrl, - ) -> Result>, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_KEYSETS, KEYSETS], TransactionMode::ReadOnly) - .map_err(Error::from)?; - - let mints_store = transaction.store(MINT_KEYSETS).map_err(Error::from)?; - - let mint_url = serde_wasm_bindgen::to_value(&mint_url).map_err(Error::from)?; - let mint_keysets: Option> = mints_store - .get(mint_url) - .await - .map_err(Error::from)? - .and_then(|m| serde_wasm_bindgen::from_value(m).ok()); - - let keysets_store = transaction.store(KEYSETS).map_err(Error::from)?; - - let keysets = match mint_keysets { - Some(mint_keysets) => { - let mut keysets = vec![]; - - for mint_keyset in mint_keysets { - let id = serde_wasm_bindgen::to_value(&mint_keyset).map_err(Error::from)?; - - let keyset = keysets_store - .get(id) - .await - .map_err(Error::from)? - .and_then(|k| serde_wasm_bindgen::from_value(k).ok()); - - keysets.push(keyset); - } - - let keysets = keysets.iter().flatten().cloned().collect(); - - Some(keysets) - } - None => None, - }; - - transaction.done().await.map_err(Error::from)?; - - Ok(keysets) - } - - async fn get_keyset_by_id(&self, keyset_id: &Id) -> Result, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[KEYSETS], TransactionMode::ReadOnly) - .map_err(Error::from)?; - let keysets_store = transaction.store(KEYSETS).map_err(Error::from)?; - - let keyset_id = serde_wasm_bindgen::to_value(keyset_id).map_err(Error::from)?; - - let keyset = keysets_store - .get(keyset_id) - .await - .map_err(Error::from)? - .and_then(|k| serde_wasm_bindgen::from_value(k).ok()); - - Ok(keyset) - } - - async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_QUOTES], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let quotes_store = transaction.store(MINT_QUOTES).map_err(Error::from)?; - - let quote_id = serde_wasm_bindgen::to_value("e.id).map_err(Error::from)?; - let quote = serde_wasm_bindgen::to_value("e).map_err(Error::from)?; - - quotes_store - .put("e, Some("e_id)) - .await - .map_err(Error::from)?; - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn get_mint_quote(&self, quote_id: &str) -> Result, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_QUOTES], TransactionMode::ReadOnly) - .map_err(Error::from)?; - - let quotes_store = transaction.store(MINT_QUOTES).map_err(Error::from)?; - - let quote_id = serde_wasm_bindgen::to_value("e_id).map_err(Error::from)?; - let quote = quotes_store - .get(quote_id) - .await - .map_err(Error::from)? - .and_then(|q| serde_wasm_bindgen::from_value(q).ok()); - - Ok(quote) - } - - async fn get_mint_quotes(&self) -> Result, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_QUOTES], TransactionMode::ReadOnly) - .map_err(Error::from)?; - - let quotes_store = transaction.store(MINT_QUOTES).map_err(Error::from)?; - - let quotes = quotes_store - .scan(None, None, None, None) - .await - .map_err(Error::from)?; - - Ok(quotes - .into_iter() - .map(|(_id, q)| serde_wasm_bindgen::from_value(q)) - .collect::, serde_wasm_bindgen::Error>>() - .map_err(>::into)?) - } - - async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_QUOTES], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let quotes_store = transaction.store(MINT_QUOTES).map_err(Error::from)?; - - let quote_id = serde_wasm_bindgen::to_value("e_id).map_err(Error::from)?; - - quotes_store.delete(quote_id).await.map_err(Error::from)?; - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn add_melt_quote(&self, quote: MeltQuote) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MELT_QUOTES], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let quotes_store = transaction.store(MELT_QUOTES).map_err(Error::from)?; - - let quote_id = serde_wasm_bindgen::to_value("e.id).map_err(Error::from)?; - let quote = serde_wasm_bindgen::to_value("e).map_err(Error::from)?; - - quotes_store - .put("e, Some("e_id)) - .await - .map_err(Error::from)?; - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn get_melt_quote(&self, quote_id: &str) -> Result, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MELT_QUOTES], TransactionMode::ReadOnly) - .map_err(Error::from)?; - - let quotes_store = transaction.store(MELT_QUOTES).map_err(Error::from)?; - - let quote_id = serde_wasm_bindgen::to_value("e_id).map_err(Error::from)?; - let quote = quotes_store - .get(quote_id) - .await - .map_err(Error::from)? - .and_then(|q| serde_wasm_bindgen::from_value(q).ok()); - - Ok(quote) - } - - async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MELT_QUOTES], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let quotes_store = transaction.store(MELT_QUOTES).map_err(Error::from)?; - - let quote_id = serde_wasm_bindgen::to_value("e_id).map_err(Error::from)?; - - quotes_store.delete(quote_id).await.map_err(Error::from)?; - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> { - // Verify ID by recomputing id - keyset.verify_id()?; - - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_KEYS], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let keys_store = transaction.store(MINT_KEYS).map_err(Error::from)?; - - let keyset_id = serde_wasm_bindgen::to_value(&keyset.id).map_err(Error::from)?; - let keys = serde_wasm_bindgen::to_value(&keys).map_err(Error::from)?; - - keys_store - .put(&keys, Some(&keyset_id)) - .await - .map_err(Error::from)?; - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn get_keys(&self, id: &Id) -> Result, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_KEYS], TransactionMode::ReadOnly) - .map_err(Error::from)?; - - let keys_store = transaction.store(MINT_KEYS).map_err(Error::from)?; - - let keyset_id = serde_wasm_bindgen::to_value(id).map_err(Error::from)?; - let keys = keys_store - .get(keyset_id) - .await - .map_err(Error::from)? - .and_then(|k| serde_wasm_bindgen::from_value(k).ok()); - - Ok(keys) - } - - async fn remove_keys(&self, id: &Id) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[MINT_KEYS], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let keys_store = transaction.store(MINT_KEYS).map_err(Error::from)?; - - let keyset_id = serde_wasm_bindgen::to_value(id).map_err(Error::from)?; - keys_store.delete(keyset_id).await.map_err(Error::from)?; - - Ok(()) - } - - async fn update_proofs( - &self, - added: Vec, - removed_ys: Vec, - ) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[PROOFS], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let proofs_store = transaction.store(PROOFS).map_err(Error::from)?; - - for proof in added { - let y = serde_wasm_bindgen::to_value(&proof.y).map_err(Error::from)?; - let proof = serde_wasm_bindgen::to_value(&proof).map_err(Error::from)?; - - proofs_store - .put(&proof, Some(&y)) - .await - .map_err(Error::from)?; - } - - for y in removed_ys { - let y = serde_wasm_bindgen::to_value(&y).map_err(Error::from)?; - - proofs_store.delete(y).await.map_err(Error::from)?; - } - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn set_pending_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - self.set_proof_states(ys, State::Pending).await - } - - async fn reserve_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - self.set_proof_states(ys, State::Reserved).await - } - - async fn set_unspent_proofs(&self, ys: Vec) -> Result<(), Self::Err> { - self.set_proof_states(ys, State::Unspent).await - } - - async fn get_proofs( - &self, - mint_url: Option, - unit: Option, - state: Option>, - spending_conditions: Option>, - ) -> Result, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[PROOFS], TransactionMode::ReadOnly) - .map_err(Error::from)?; - - let proofs_store = transaction.store(PROOFS).map_err(Error::from)?; - - let proofs = proofs_store - .scan(None, None, None, None) - .await - .map_err(Error::from)?; - - let proofs: Vec = proofs - .into_iter() - .filter_map(|(_k, v)| { - let mut proof = None; - - if let Ok(proof_info) = serde_wasm_bindgen::from_value::(v) { - proof = match proof_info.matches_conditions( - &mint_url, - &unit, - &state, - &spending_conditions, - ) { - true => Some(proof_info), - false => None, - }; - } - - proof - }) - .collect(); - - transaction.done().await.map_err(Error::from)?; - - Ok(proofs) - } - - async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[KEYSET_COUNTER], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let counter_store = transaction.store(KEYSET_COUNTER).map_err(Error::from)?; - - let keyset_id = serde_wasm_bindgen::to_value(keyset_id).map_err(Error::from)?; - - let current_count: Option = counter_store - .get(keyset_id.clone()) - .await - .map_err(Error::from)? - .and_then(|c| serde_wasm_bindgen::from_value(c).ok()); - - let new_count = current_count.unwrap_or_default() + count; - - let new_count = serde_wasm_bindgen::to_value(&new_count).map_err(Error::from)?; - - counter_store - .put(&new_count, Some(&keyset_id)) - .await - .map_err(Error::from)?; - - transaction.done().await.map_err(Error::from)?; - - Ok(()) - } - - async fn get_keyset_counter(&self, keyset_id: &Id) -> Result, Self::Err> { - let rexie = self.db.lock().await; - - let transaction = rexie - .transaction(&[KEYSET_COUNTER], TransactionMode::ReadWrite) - .map_err(Error::from)?; - - let counter_store = transaction.store(KEYSET_COUNTER).map_err(Error::from)?; - - let keyset_id = serde_wasm_bindgen::to_value(keyset_id).map_err(Error::from)?; - - let current_count = counter_store - .get(keyset_id) - .await - .map_err(Error::from)? - .and_then(|c| serde_wasm_bindgen::from_value(c).ok()); - - Ok(current_count) - } -} diff --git a/crates/cdk-signatory/src/bin/cli/mod.rs b/crates/cdk-signatory/src/bin/cli/mod.rs index 1eed94265..6dc905792 100644 --- a/crates/cdk-signatory/src/bin/cli/mod.rs +++ b/crates/cdk-signatory/src/bin/cli/mod.rs @@ -17,9 +17,43 @@ use cdk_signatory::{db_signatory, start_grpc_server}; #[cfg(feature = "sqlite")] use cdk_sqlite::MintSqliteDatabase; use clap::Parser; -use tracing::Level; use tracing_subscriber::EnvFilter; +/// Common CLI arguments for CDK binaries +#[derive(Parser, Debug)] +pub struct CommonArgs { + /// Enable logging (default is false) + #[arg(long, default_value_t = false)] + pub enable_logging: bool, + + /// Logging level when enabled (default is debug) + #[arg(long, default_value = "debug")] + pub log_level: tracing::Level, +} + +/// Initialize logging based on CLI arguments +pub fn init_logging(enable_logging: bool, log_level: tracing::Level) { + if enable_logging { + let default_filter = log_level.to_string(); + + // Common filters to reduce noise + let sqlx_filter = "sqlx=warn"; + let hyper_filter = "hyper=warn"; + let h2_filter = "h2=warn"; + let rustls_filter = "rustls=warn"; + let reqwest_filter = "reqwest=warn"; + + let env_filter = EnvFilter::new(format!( + "{default_filter},{sqlx_filter},{hyper_filter},{h2_filter},{rustls_filter},{reqwest_filter}" + )); + + // Ok if successful, Err if already initialized + let _ = tracing_subscriber::fmt() + .with_env_filter(env_filter) + .try_init(); + } +} + const DEFAULT_WORK_DIR: &str = ".cdk-signatory"; const ENV_MNEMONIC: &str = "CDK_MINTD_MNEMONIC"; @@ -30,6 +64,9 @@ const ENV_MNEMONIC: &str = "CDK_MINTD_MNEMONIC"; #[command(version = "0.1.0")] #[command(author, version, about, long_about = None)] struct Cli { + #[command(flatten)] + common: CommonArgs, + /// Database engine to use (sqlite/redb) #[arg(short, long, default_value = "sqlite")] engine: String, @@ -39,9 +76,6 @@ struct Cli { /// Path to working dir #[arg(short, long)] work_dir: Option, - /// Logging level - #[arg(short, long, default_value = "debug")] - log_level: Level, #[arg(long, default_value = "127.0.0.1")] listen_addr: String, #[arg(long, default_value = "15060")] @@ -56,7 +90,10 @@ struct Cli { /// Main function for the signatory standalone binary pub async fn cli_main() -> Result<()> { let args: Cli = Cli::parse(); - let default_filter = args.log_level; + + // Initialize logging based on CLI arguments + init_logging(args.common.enable_logging, args.common.log_level); + let supported_units = args .units .into_iter() @@ -74,13 +111,6 @@ pub async fn cli_main() -> Result<()> { }) .collect::, _>>()?; - let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn"; - - let env_filter = EnvFilter::new(format!("{default_filter},{sqlx_filter}")); - - // Parse input - tracing_subscriber::fmt().with_env_filter(env_filter).init(); - let work_dir = match &args.work_dir { Some(work_dir) => work_dir.clone(), None => { @@ -108,7 +138,7 @@ pub async fn cli_main() -> Result<()> { #[cfg(feature = "sqlcipher")] let db = { match args.password { - Some(pass) => MintSqliteDatabase::new(&sql_path, pass).await?, + Some(pass) => MintSqliteDatabase::new((&sql_path, pass)).await?, None => bail!("Missing database password"), } }; diff --git a/crates/cdk-signatory/src/common.rs b/crates/cdk-signatory/src/common.rs index 6663f8816..bae0bc045 100644 --- a/crates/cdk-signatory/src/common.rs +++ b/crates/cdk-signatory/src/common.rs @@ -59,14 +59,14 @@ pub async fn init_keysets( if let Some((input_fee_ppk, max_order)) = supported_units.get(&unit) { if !keysets.is_empty() && &highest_index_keyset.input_fee_ppk == input_fee_ppk - && &highest_index_keyset.max_order == max_order + && highest_index_keyset.amounts.len() == (*max_order as usize) { tracing::debug!("Current highest index keyset matches expect fee and max order. Setting active"); let id = highest_index_keyset.id; let keyset = MintKeySet::generate_from_xpriv( secp_ctx, xpriv, - highest_index_keyset.max_order, + &highest_index_keyset.amounts, highest_index_keyset.unit.clone(), highest_index_keyset.derivation_path.clone(), highest_index_keyset.final_expiry, @@ -98,7 +98,7 @@ pub async fn init_keysets( derivation_path, Some(derivation_path_index), unit.clone(), - *max_order, + &highest_index_keyset.amounts, *input_fee_ppk, // TODO: add Mint settings for a final expiry of newly generated keysets None, @@ -128,7 +128,7 @@ pub fn create_new_keyset( derivation_path: DerivationPath, derivation_path_index: Option, unit: CurrencyUnit, - max_order: u8, + amounts: &[u64], input_fee_ppk: u64, final_expiry: Option, ) -> (MintKeySet, MintKeySetInfo) { @@ -138,7 +138,7 @@ pub fn create_new_keyset( .derive_priv(secp, &derivation_path) .expect("RNG busted"), unit, - max_order, + amounts, final_expiry, // TODO: change this to Version01 to generate keysets v2 cdk_common::nut02::KeySetVersion::Version00, @@ -151,7 +151,7 @@ pub fn create_new_keyset( final_expiry: keyset.final_expiry, derivation_path, derivation_path_index, - max_order, + amounts: amounts.to_owned(), input_fee_ppk, }; (keyset, keyset_info) diff --git a/crates/cdk-signatory/src/db_signatory.rs b/crates/cdk-signatory/src/db_signatory.rs index c1dae7f85..d446ba2b0 100644 --- a/crates/cdk-signatory/src/db_signatory.rs +++ b/crates/cdk-signatory/src/db_signatory.rs @@ -65,13 +65,17 @@ impl DbSignatory { } }; + let amounts = (0..max_order) + .map(|i| 2_u64.pow(i as u32)) + .collect::>(); + let (keyset, keyset_info) = create_new_keyset( &secp_ctx, xpriv, derivation_path, Some(0), unit.clone(), - max_order, + &amounts, fee, // TODO: add and connect settings for this None, @@ -132,7 +136,7 @@ impl DbSignatory { MintKeySet::generate_from_xpriv( &self.secp_ctx, self.xpriv, - keyset_info.max_order, + &keyset_info.amounts, keyset_info.unit.clone(), keyset_info.derivation_path.clone(), keyset_info.final_expiry, @@ -241,7 +245,7 @@ impl Signatory for DbSignatory { derivation_path, Some(path_index), args.unit.clone(), - args.max_order, + &args.amounts, args.input_fee_ppk, // TODO: add and connect settings for this None, @@ -274,7 +278,7 @@ mod test { let keyset = MintKeySet::generate_from_seed( &Secp256k1::new(), seed, - 2, + &[1, 2], CurrencyUnit::Sat, derivation_path_from_unit(CurrencyUnit::Sat, 0).unwrap(), None, @@ -320,7 +324,7 @@ mod test { let keyset = MintKeySet::generate_from_xpriv( &Secp256k1::new(), xpriv, - 2, + &[1, 2], CurrencyUnit::Sat, derivation_path_from_unit(CurrencyUnit::Sat, 0).unwrap(), None, diff --git a/crates/cdk-signatory/src/proto/convert.rs b/crates/cdk-signatory/src/proto/convert.rs index 0bb11c03f..ba5b13c21 100644 --- a/crates/cdk-signatory/src/proto/convert.rs +++ b/crates/cdk-signatory/src/proto/convert.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use cdk_common::secret::Secret; use cdk_common::util::hex; -use cdk_common::{Amount, PublicKey}; +use cdk_common::{Amount, Id, PublicKey}; use tonic::Status; use super::*; @@ -43,8 +43,16 @@ impl TryInto for KeySet { type Error = cdk_common::Error; fn try_into(self) -> Result { + let keys = self + .keys + .ok_or(cdk_common::Error::Custom(INTERNAL_ERROR.to_owned()))? + .keys + .into_iter() + .map(|(amount, pk)| PublicKey::from_slice(&pk).map(|pk| (amount.into(), pk))) + .collect::, _>>()?; + Ok(crate::signatory::SignatoryKeySet { - id: self.id.parse()?, + id: Id::from_bytes(&self.id)?, unit: self .unit .ok_or(cdk_common::Error::Custom(INTERNAL_ERROR.to_owned()))? @@ -52,14 +60,8 @@ impl TryInto for KeySet { .map_err(|_| cdk_common::Error::Custom("Invalid currency unit".to_owned()))?, active: self.active, input_fee_ppk: self.input_fee_ppk, - keys: cdk_common::Keys::new( - self.keys - .ok_or(cdk_common::Error::Custom(INTERNAL_ERROR.to_owned()))? - .keys - .into_iter() - .map(|(amount, pk)| PublicKey::from_slice(&pk).map(|pk| (amount.into(), pk))) - .collect::, _>>()?, - ), + amounts: keys.keys().map(|x| x.to_u64()).collect::>(), + keys: cdk_common::Keys::new(keys), final_expiry: self.final_expiry, }) } @@ -68,7 +70,7 @@ impl TryInto for KeySet { impl From for KeySet { fn from(keyset: crate::signatory::SignatoryKeySet) -> Self { Self { - id: keyset.id.to_string(), + id: keyset.id.to_bytes(), unit: Some(keyset.unit.into()), active: keyset.active, input_fee_ppk: keyset.input_fee_ppk, @@ -80,6 +82,7 @@ impl From for KeySet { .collect(), }), final_expiry: keyset.final_expiry, + version: Default::default(), } } } @@ -141,7 +144,7 @@ impl From for BlindSignature { BlindSignature { amount: value.amount.into(), blinded_secret: value.c.to_bytes().to_vec(), - keyset_id: value.keyset_id.to_string(), + keyset_id: value.keyset_id.to_bytes(), dleq: value.dleq.map(|x| x.into()), } } @@ -161,7 +164,7 @@ impl From for Proof { fn from(value: cdk_common::Proof) -> Self { Proof { amount: value.amount.into(), - keyset_id: value.keyset_id.to_string(), + keyset_id: value.keyset_id.to_bytes(), secret: value.secret.to_bytes(), c: value.c.to_bytes().to_vec(), } @@ -179,9 +182,7 @@ impl TryInto for Proof { Ok(cdk_common::Proof { amount: self.amount.into(), - keyset_id: self - .keyset_id - .parse() + keyset_id: Id::from_bytes(&self.keyset_id) .map_err(|e| Status::from_error(Box::new(e)))?, secret: Secret::new(secret), c: cdk_common::PublicKey::from_slice(&self.c) @@ -199,7 +200,7 @@ impl TryInto for BlindSignature { Ok(cdk_common::BlindSignature { amount: self.amount.into(), c: cdk_common::PublicKey::from_slice(&self.blinded_secret)?, - keyset_id: self.keyset_id.parse().expect("Invalid keyset id"), + keyset_id: Id::from_bytes(&self.keyset_id)?, dleq: self.dleq.map(|dleq| dleq.try_into()).transpose()?, }) } @@ -209,7 +210,7 @@ impl From for BlindedMessage { fn from(value: cdk_common::BlindedMessage) -> Self { BlindedMessage { amount: value.amount.into(), - keyset_id: value.keyset_id.to_string(), + keyset_id: value.keyset_id.to_bytes(), blinded_secret: value.blinded_secret.to_bytes().to_vec(), } } @@ -220,9 +221,7 @@ impl TryInto for BlindedMessage { fn try_into(self) -> Result { Ok(cdk_common::BlindedMessage { amount: self.amount.into(), - keyset_id: self - .keyset_id - .parse() + keyset_id: Id::from_bytes(&self.keyset_id) .map_err(|e| Status::from_error(Box::new(e)))?, blinded_secret: cdk_common::PublicKey::from_slice(&self.blinded_secret) .map_err(|e| Status::from_error(Box::new(e)))?, @@ -311,10 +310,7 @@ impl TryInto for KeySet { type Error = cdk_common::error::Error; fn try_into(self) -> Result { Ok(cdk_common::KeySet { - id: self - .id - .parse() - .map_err(|_| cdk_common::error::Error::Custom("Invalid ID".to_owned()))?, + id: Id::from_bytes(&self.id)?, unit: self .unit .ok_or(cdk_common::error::Error::Custom(INTERNAL_ERROR.to_owned()))? @@ -337,7 +333,7 @@ impl From for RotationRequest { fn from(value: crate::signatory::RotateKeyArguments) -> Self { Self { unit: Some(value.unit.into()), - max_order: value.max_order.into(), + amounts: value.amounts, input_fee_ppk: value.input_fee_ppk, } } @@ -352,10 +348,7 @@ impl TryInto for RotationRequest { .unit .ok_or(Status::invalid_argument("unit not set"))? .try_into()?, - max_order: self - .max_order - .try_into() - .map_err(|_| Status::invalid_argument("Invalid max_order"))?, + amounts: self.amounts, input_fee_ppk: self.input_fee_ppk, }) } @@ -364,12 +357,13 @@ impl TryInto for RotationRequest { impl From for KeySet { fn from(value: cdk_common::KeySetInfo) -> Self { Self { - id: value.id.into(), + id: value.id.to_bytes(), unit: Some(value.unit.into()), active: value.active, input_fee_ppk: value.input_fee_ppk, keys: Default::default(), final_expiry: value.final_expiry, + version: Default::default(), } } } @@ -379,7 +373,7 @@ impl TryInto for KeySet { fn try_into(self) -> Result { Ok(cdk_common::KeySetInfo { - id: self.id.try_into()?, + id: Id::from_bytes(&self.id)?, unit: self .unit .ok_or(cdk_common::Error::Custom(INTERNAL_ERROR.to_owned()))? diff --git a/crates/cdk-signatory/src/proto/signatory.proto b/crates/cdk-signatory/src/proto/signatory.proto index 8cb91211a..5b82408a2 100644 --- a/crates/cdk-signatory/src/proto/signatory.proto +++ b/crates/cdk-signatory/src/proto/signatory.proto @@ -32,7 +32,7 @@ message BlindedMessages { // Represents a blinded message message BlindedMessage { uint64 amount = 1; - string keyset_id = 2; + bytes keyset_id = 2; bytes blinded_secret = 3; } @@ -57,12 +57,13 @@ message SignatoryKeysets { } message KeySet { - string id = 1; + bytes id = 1; CurrencyUnit unit = 2; bool active = 3; uint64 input_fee_ppk = 4; Keys keys = 5; optional uint64 final_expiry = 6; + uint64 version = 7; } message Keys { @@ -72,7 +73,7 @@ message Keys { message RotationRequest { CurrencyUnit unit = 1; uint64 input_fee_ppk = 2; - uint32 max_order = 3; + repeated uint64 amounts = 3; } enum CurrencyUnitType { @@ -99,18 +100,19 @@ message Proofs { message Proof { uint64 amount = 1; - string keyset_id = 2; + bytes keyset_id = 2; bytes secret = 3; bytes c = 4; } + message BlindSignatures { repeated BlindSignature blind_signatures = 1; } message BlindSignature { uint64 amount = 1; - string keyset_id = 2; + bytes keyset_id = 2; bytes blinded_secret = 3; optional BlindSignatureDLEQ dleq = 4; } diff --git a/crates/cdk-signatory/src/signatory.rs b/crates/cdk-signatory/src/signatory.rs index 73a661659..dad64bf25 100644 --- a/crates/cdk-signatory/src/signatory.rs +++ b/crates/cdk-signatory/src/signatory.rs @@ -43,7 +43,7 @@ pub struct RotateKeyArguments { /// Unit pub unit: CurrencyUnit, /// Max order - pub max_order: u8, + pub amounts: Vec, /// Input fee pub input_fee_ppk: u64, } @@ -71,6 +71,8 @@ pub struct SignatoryKeySet { pub active: bool, /// The list of public keys pub keys: Keys, + /// Amounts supported by the keyset + pub amounts: Vec, /// Information about the fee per public key pub input_fee_ppk: u64, /// Final expiry of the keyset (unix timestamp in the future) @@ -109,7 +111,7 @@ impl From for MintKeySetInfo { input_fee_ppk: val.input_fee_ppk, derivation_path: Default::default(), derivation_path_index: Default::default(), - max_order: 0, + amounts: val.amounts, final_expiry: val.final_expiry, valid_from: 0, } @@ -123,6 +125,7 @@ impl From<&(MintKeySetInfo, MintKeySet)> for SignatoryKeySet { unit: key.unit.clone(), active: info.active, input_fee_ppk: info.input_fee_ppk, + amounts: info.amounts.clone(), keys: key.keys.clone().into(), final_expiry: key.final_expiry, } @@ -132,7 +135,7 @@ impl From<&(MintKeySetInfo, MintKeySet)> for SignatoryKeySet { #[async_trait::async_trait] /// Signatory trait pub trait Signatory { - /// The Signatory implementation name. This may be exposed, so being as discreet as possible is + /// The Signatory implementation name. This may be exposed, so being as discrete as possible is /// advised. fn name(&self) -> String; @@ -144,7 +147,7 @@ pub trait Signatory { blinded_messages: Vec, ) -> Result, Error>; - /// Verify [`Proof`] meets conditions and is signed + /// Verify [`Proof`] meets conditions and is signed by the mint (ignores P2PK/HTLC signatures" async fn verify_proofs(&self, proofs: Vec) -> Result<(), Error>; /// Retrieve the list of all mint keysets diff --git a/crates/cdk-sql-common/Cargo.toml b/crates/cdk-sql-common/Cargo.toml new file mode 100644 index 000000000..0e6be4f2c --- /dev/null +++ b/crates/cdk-sql-common/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cdk-sql-common" +version.workspace = true +edition.workspace = true +authors = ["CDK Developers"] +description = "Generic SQL storage backend for CDK" +license.workspace = true +homepage = "https://github.com/cashubtc/cdk" +repository = "https://github.com/cashubtc/cdk.git" +rust-version.workspace = true # MSRV +readme = "README.md" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[features] +default = ["mint", "wallet", "auth"] +mint = ["cdk-common/mint"] +wallet = ["cdk-common/wallet"] +auth = ["cdk-common/auth"] +prometheus = ["cdk-prometheus"] +[dependencies] +async-trait.workspace = true +cdk-common = { workspace = true, features = ["test"] } +cdk-prometheus = { workspace = true, optional = true } +bitcoin.workspace = true +thiserror.workspace = true +tracing.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +lightning-invoice.workspace = true +once_cell.workspace = true +uuid.workspace = true diff --git a/crates/cdk-sql-common/README.md b/crates/cdk-sql-common/README.md new file mode 100644 index 000000000..861923910 --- /dev/null +++ b/crates/cdk-sql-common/README.md @@ -0,0 +1,24 @@ +# CDK SQL Base + +This is a private crate offering a common framework to interact with SQL databases. + +This crate uses standard SQL, a generic migration framework a traits to implement blocking or +non-blocking clients. + + +**ALPHA** This library is in early development, the API will change and should be used with caution. + +## Features + +The following crate feature flags are available: + +| Feature | Default | Description | +|-------------|:-------:|------------------------------------| +| `wallet` | Yes | Enable cashu wallet features | +| `mint` | Yes | Enable cashu mint wallet features | +| `auth` | Yes | Enable cashu mint auth features | + + +## License + +This project is licensed under the [MIT License](../../LICENSE). diff --git a/crates/cdk-sql-common/build.rs b/crates/cdk-sql-common/build.rs new file mode 100644 index 000000000..46fe78927 --- /dev/null +++ b/crates/cdk-sql-common/build.rs @@ -0,0 +1,161 @@ +use std::cmp::Ordering; +use std::env; +use std::fs::{self, File}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +fn main() { + // Step 1: Find `migrations/` folder recursively + let root = Path::new("src"); + + // Get the OUT_DIR from Cargo - this is writable + let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR is set by Cargo")); + + for migration_path in find_migrations_dirs(root) { + // Step 3: Output file path to OUT_DIR instead of source directory + let parent = migration_path.parent().unwrap(); + + // Create a unique filename based on the migration path to avoid conflicts + let migration_name = parent + .strip_prefix("src") + .unwrap_or(parent) + .to_str() + .unwrap_or("default") + .replace("/", "_") + .replace("\\", "_"); + let dest_path = out_dir.join(format!("migrations_{migration_name}.rs")); + let mut out_file = File::create(&dest_path).expect("Failed to create migrations.rs"); + + let skip_name = migration_path.to_str().unwrap_or_default().len(); + + // Step 2: Collect all files inside the migrations dir + let mut files = Vec::new(); + visit_dirs(&migration_path, &mut files).expect("Failed to read migrations directory"); + files.sort_by(|path_a, path_b| { + let parts_a = path_a.to_str().unwrap().replace("\\", "/")[skip_name + 1..] + .split("/") + .map(|x| x.to_owned()) + .collect::>(); + let parts_b = path_b.to_str().unwrap().replace("\\", "/")[skip_name + 1..] + .split("/") + .map(|x| x.to_owned()) + .collect::>(); + + let prefix_a = if parts_a.len() == 2 { + parts_a.first().map(|x| x.to_owned()).unwrap_or_default() + } else { + "".to_owned() + }; + + let prefix_b = if parts_a.len() == 2 { + parts_b.first().map(|x| x.to_owned()).unwrap_or_default() + } else { + "".to_owned() + }; + + let prefix_cmp = prefix_a.cmp(&prefix_b); + + if prefix_cmp != Ordering::Equal { + return prefix_cmp; + } + + let path_a = path_a.file_name().unwrap().to_str().unwrap(); + let path_b = path_b.file_name().unwrap().to_str().unwrap(); + + let prefix_a = path_a + .split("_") + .next() + .and_then(|prefix| prefix.parse::().ok()) + .unwrap_or_default(); + let prefix_b = path_b + .split("_") + .next() + .and_then(|prefix| prefix.parse::().ok()) + .unwrap_or_default(); + + if prefix_a != 0 && prefix_b != 0 { + prefix_a.cmp(&prefix_b) + } else { + path_a.cmp(path_b) + } + }); + + writeln!(out_file, "/// @generated").unwrap(); + writeln!(out_file, "/// Auto-generated by build.rs").unwrap(); + writeln!( + out_file, + "pub static MIGRATIONS: &[(&str, &str, &str)] = &[" + ) + .unwrap(); + + for path in &files { + let parts = path.to_str().unwrap().replace("\\", "/")[skip_name + 1..] + .split("/") + .map(|x| x.to_owned()) + .collect::>(); + + let prefix = if parts.len() == 2 { + parts.first().map(|x| x.to_owned()).unwrap_or_default() + } else { + "".to_owned() + }; + + let rel_name = &path.file_name().unwrap().to_str().unwrap(); + + // Copy migration file to OUT_DIR + let relative_path = path.strip_prefix(root).unwrap(); + let dest_migration_file = out_dir.join(relative_path); + if let Some(parent) = dest_migration_file.parent() { + fs::create_dir_all(parent) + .expect("Failed to create migration directory in OUT_DIR"); + } + fs::copy(path, &dest_migration_file).expect("Failed to copy migration file to OUT_DIR"); + + // Use path relative to OUT_DIR for include_str + let relative_to_out_dir = relative_path.to_str().unwrap().replace("\\", "/"); + writeln!( + out_file, + " (\"{prefix}\", \"{rel_name}\", include_str!(r#\"{relative_to_out_dir}\"#))," + ) + .unwrap(); + println!("cargo:rerun-if-changed={}", path.display()); + } + + writeln!(out_file, "];").unwrap(); + + println!("cargo:rerun-if-changed={}", migration_path.display()); + } +} + +fn find_migrations_dirs(root: &Path) -> Vec { + let mut found = Vec::new(); + find_migrations_dirs_rec(root, &mut found); + found +} + +fn find_migrations_dirs_rec(dir: &Path, found: &mut Vec) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if path.file_name().unwrap_or_default() == "migrations" { + found.push(path.clone()); + } + find_migrations_dirs_rec(&path, found); + } + } + } +} + +fn visit_dirs(dir: &Path, files: &mut Vec) -> std::io::Result<()> { + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + visit_dirs(&path, files)?; + } else if path.is_file() { + files.push(path); + } + } + Ok(()) +} diff --git a/crates/cdk-sql-common/src/common.rs b/crates/cdk-sql-common/src/common.rs new file mode 100644 index 000000000..fb85e7653 --- /dev/null +++ b/crates/cdk-sql-common/src/common.rs @@ -0,0 +1,115 @@ +use std::fmt::Debug; +use std::future::Future; +use std::time::Instant; + +use cdk_common::database::Error; + +use crate::database::DatabaseExecutor; +use crate::stmt::query; + +const SLOW_QUERY_THRESHOLD_MS: u128 = 20; + +/// Run a database operation and log slow operations, it also converts and logs any error with a +/// given info for more context. This function is expecting a synchronous database operation +#[inline(always)] +pub fn run_db_operation_sync( + info: &str, + operation: F, + error_map: E, +) -> Result +where + F: FnOnce() -> Result, + E1: Debug, + E: FnOnce(E1) -> Error, +{ + let start = Instant::now(); + + tracing::trace!("Running db operation {}", info); + + let result = operation().map_err(|e| { + tracing::error!("Query {} failed with error {:?}", info, e); + error_map(e) + }); + + let duration = start.elapsed(); + if duration.as_millis() > SLOW_QUERY_THRESHOLD_MS { + tracing::warn!("[SLOW QUERY] Took {} ms: {}", duration.as_millis(), info); + } + + result +} + +/// Run a database operation and log slow operations, it also converts and logs any error with a +/// given info for more context +#[inline(always)] +pub async fn run_db_operation( + info: &str, + operation: Fut, + error_map: E, +) -> Result +where + Fut: Future>, + E1: Debug, + E: FnOnce(E1) -> Error, +{ + let start = Instant::now(); + + tracing::trace!("Running db operation {}", info); + + let result = operation.await.map_err(|e| { + tracing::error!("Query {} failed with error {:?}", info, e); + error_map(e) + }); + + let duration = start.elapsed(); + if duration.as_millis() > SLOW_QUERY_THRESHOLD_MS { + tracing::warn!("[SLOW QUERY] Took {} ms: {}", duration.as_millis(), info); + } + + result +} + +/// Migrates the migration generated by `build.rs` +#[inline(always)] +pub async fn migrate( + conn: &C, + db_prefix: &str, + migrations: &[(&str, &str, &str)], +) -> Result<(), Error> +where + C: DatabaseExecutor, +{ + query( + r#" + CREATE TABLE IF NOT EXISTS migrations ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + "#, + )? + .execute(conn) + .await?; + + // Apply each migration if it hasn’t been applied yet + for (prefix, name, sql) in migrations { + if !prefix.is_empty() && *prefix != db_prefix { + continue; + } + + let is_missing = query("SELECT name FROM migrations WHERE name = :name")? + .bind("name", name) + .pluck(conn) + .await? + .is_none(); + + if is_missing { + query(sql)?.batch(conn).await?; + query(r#"INSERT INTO migrations (name) VALUES (:name)"#)? + .bind("name", name) + .execute(conn) + .await?; + } + } + + Ok(()) +} diff --git a/crates/cdk-sql-common/src/database.rs b/crates/cdk-sql-common/src/database.rs new file mode 100644 index 000000000..31368fe88 --- /dev/null +++ b/crates/cdk-sql-common/src/database.rs @@ -0,0 +1,203 @@ +//! Database traits definition + +use std::fmt::Debug; +use std::marker::PhantomData; +use std::ops::{Deref, DerefMut}; + +use cdk_common::database::Error; + +use crate::stmt::{query, Column, Statement}; + +/// Database Executor +/// +/// This trait defines the expectations of a database execution +#[async_trait::async_trait] +pub trait DatabaseExecutor: Debug + Sync + Send { + /// Database driver name + fn name() -> &'static str; + + /// Executes a query and returns the affected rows + async fn execute(&self, statement: Statement) -> Result; + + /// Runs the query and returns the first row or None + async fn fetch_one(&self, statement: Statement) -> Result>, Error>; + + /// Runs the query and returns the first row or None + async fn fetch_all(&self, statement: Statement) -> Result>, Error>; + + /// Fetches the first row and column from a query + async fn pluck(&self, statement: Statement) -> Result, Error>; + + /// Batch execution + async fn batch(&self, statement: Statement) -> Result<(), Error>; +} + +/// Database transaction trait +#[async_trait::async_trait] +pub trait DatabaseTransaction +where + DB: DatabaseExecutor, +{ + /// Consumes the current transaction committing the changes + async fn commit(conn: &mut DB) -> Result<(), Error>; + + /// Begin a transaction + async fn begin(conn: &mut DB) -> Result<(), Error>; + + /// Consumes the transaction rolling back all changes + async fn rollback(conn: &mut DB) -> Result<(), Error>; +} + +/// Database connection with a transaction +#[derive(Debug)] +pub struct ConnectionWithTransaction +where + DB: DatabaseConnector + 'static, + W: Debug + Deref + DerefMut + Send + Sync + 'static, +{ + inner: Option, +} + +impl ConnectionWithTransaction +where + DB: DatabaseConnector, + W: Debug + Deref + DerefMut + Send + Sync + 'static, +{ + /// Creates a new transaction + pub async fn new(mut inner: W) -> Result { + DB::Transaction::begin(inner.deref_mut()).await?; + Ok(Self { inner: Some(inner) }) + } + + /// Commits the transaction consuming it and releasing the connection back to the pool (or + /// disconnecting) + pub async fn commit(mut self) -> Result<(), Error> { + let mut conn = self + .inner + .take() + .ok_or(Error::Internal("Missing connection".to_owned()))?; + + DB::Transaction::commit(&mut conn).await?; + + Ok(()) + } + + /// Rollback the transaction consuming it and releasing the connection back to the pool (or + /// disconnecting) + pub async fn rollback(mut self) -> Result<(), Error> { + let mut conn = self + .inner + .take() + .ok_or(Error::Internal("Missing connection".to_owned()))?; + + DB::Transaction::rollback(&mut conn).await?; + + Ok(()) + } +} + +impl Drop for ConnectionWithTransaction +where + DB: DatabaseConnector, + W: Debug + Deref + DerefMut + Send + Sync + 'static, +{ + fn drop(&mut self) { + if let Some(mut conn) = self.inner.take() { + tokio::spawn(async move { + let _ = DB::Transaction::rollback(conn.deref_mut()).await; + }); + } + } +} + +#[async_trait::async_trait] +impl DatabaseExecutor for ConnectionWithTransaction +where + DB: DatabaseConnector, + W: Debug + Deref + DerefMut + Send + Sync + 'static, +{ + fn name() -> &'static str { + "Transaction" + } + + /// Executes a query and returns the affected rows + async fn execute(&self, statement: Statement) -> Result { + self.inner + .as_ref() + .ok_or(Error::Internal("Missing internal connection".to_owned()))? + .execute(statement) + .await + } + + /// Runs the query and returns the first row or None + async fn fetch_one(&self, statement: Statement) -> Result>, Error> { + self.inner + .as_ref() + .ok_or(Error::Internal("Missing internal connection".to_owned()))? + .fetch_one(statement) + .await + } + + /// Runs the query and returns the first row or None + async fn fetch_all(&self, statement: Statement) -> Result>, Error> { + self.inner + .as_ref() + .ok_or(Error::Internal("Missing internal connection".to_owned()))? + .fetch_all(statement) + .await + } + + /// Fetches the first row and column from a query + async fn pluck(&self, statement: Statement) -> Result, Error> { + self.inner + .as_ref() + .ok_or(Error::Internal("Missing internal connection".to_owned()))? + .pluck(statement) + .await + } + + /// Batch execution + async fn batch(&self, statement: Statement) -> Result<(), Error> { + self.inner + .as_ref() + .ok_or(Error::Internal("Missing internal connection".to_owned()))? + .batch(statement) + .await + } +} + +/// Generic transaction handler for SQLite +pub struct GenericTransactionHandler(PhantomData); + +#[async_trait::async_trait] +impl DatabaseTransaction for GenericTransactionHandler +where + W: DatabaseExecutor, +{ + /// Consumes the current transaction committing the changes + async fn commit(conn: &mut W) -> Result<(), Error> { + query("COMMIT")?.execute(conn).await?; + Ok(()) + } + + /// Begin a transaction + async fn begin(conn: &mut W) -> Result<(), Error> { + query("START TRANSACTION")?.execute(conn).await?; + Ok(()) + } + + /// Consumes the transaction rolling back all changes + async fn rollback(conn: &mut W) -> Result<(), Error> { + query("ROLLBACK")?.execute(conn).await?; + Ok(()) + } +} + +/// Database connector +#[async_trait::async_trait] +pub trait DatabaseConnector: Debug + DatabaseExecutor + Send + Sync { + /// Database static trait for the database + type Transaction: DatabaseTransaction + where + Self: Sized; +} diff --git a/crates/cdk-sql-common/src/lib.rs b/crates/cdk-sql-common/src/lib.rs new file mode 100644 index 000000000..030204144 --- /dev/null +++ b/crates/cdk-sql-common/src/lib.rs @@ -0,0 +1,24 @@ +//! SQLite storage backend for cdk + +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +mod common; +pub mod database; +mod macros; +pub mod pool; +pub mod stmt; +pub mod value; + +pub use cdk_common::database::ConversionError; +pub use common::{run_db_operation, run_db_operation_sync}; + +#[cfg(feature = "mint")] +pub mod mint; +#[cfg(feature = "wallet")] +pub mod wallet; + +#[cfg(feature = "mint")] +pub use mint::SQLMintDatabase; +#[cfg(feature = "wallet")] +pub use wallet::SQLWalletDatabase; diff --git a/crates/cdk-sqlite/src/macros.rs b/crates/cdk-sql-common/src/macros.rs similarity index 62% rename from crates/cdk-sqlite/src/macros.rs rename to crates/cdk-sql-common/src/macros.rs index 7720d56be..094cb0980 100644 --- a/crates/cdk-sqlite/src/macros.rs +++ b/crates/cdk-sql-common/src/macros.rs @@ -1,4 +1,4 @@ -//! Collection of macros to generate code to digest data from SQLite +//! Collection of macros to generate code to digest data from a generic SQL databasex /// Unpacks a vector of Column, and consumes it, parsing into individual variables, checking the /// vector is big enough. @@ -10,9 +10,9 @@ macro_rules! unpack_into { vec.reverse(); let required = 0 $(+ {let _ = stringify!($var); 1})+; if vec.len() < required { - return Err(Error::MissingColumn(required, vec.len())); + Err($crate::ConversionError::MissingColumn(required, vec.len()))?; } - Ok::<_, Error>(( + Ok::<_, cdk_common::database::Error>(( $( vec.pop().expect(&format!("Checked length already for {}", stringify!($var))) ),+ @@ -21,7 +21,7 @@ macro_rules! unpack_into { }; } -/// Parses a SQLite column as a string or NULL +/// Parses a SQL column as a string or NULL #[macro_export] macro_rules! column_as_nullable_string { ($col:expr, $callback_str:expr, $callback_bytes:expr) => { @@ -29,9 +29,9 @@ macro_rules! column_as_nullable_string { $crate::stmt::Column::Text(text) => Ok(Some(text).and_then($callback_str)), $crate::stmt::Column::Blob(bytes) => Ok(Some(bytes).and_then($callback_bytes)), $crate::stmt::Column::Null => Ok(None), - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "String".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; @@ -42,9 +42,9 @@ macro_rules! column_as_nullable_string { Ok(Some(String::from_utf8_lossy(&bytes)).and_then($callback_str)) } $crate::stmt::Column::Null => Ok(None), - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "String".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; @@ -55,9 +55,9 @@ macro_rules! column_as_nullable_string { Ok(Some(String::from_utf8_lossy(&bytes).to_string())) } $crate::stmt::Column::Null => Ok(None), - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "String".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; @@ -69,15 +69,21 @@ macro_rules! column_as_nullable_number { ($col:expr) => { (match $col { $crate::stmt::Column::Text(text) => Ok(Some(text.parse().map_err(|_| { - Error::InvalidConversion(stringify!($col).to_owned(), "Number".to_owned()) + $crate::ConversionError::InvalidConversion( + stringify!($col).to_owned(), + "Number".to_owned(), + ) })?)), $crate::stmt::Column::Integer(n) => Ok(Some(n.try_into().map_err(|_| { - Error::InvalidConversion(stringify!($col).to_owned(), "Number".to_owned()) + $crate::ConversionError::InvalidConversion( + stringify!($col).to_owned(), + "Number".to_owned(), + ) })?)), $crate::stmt::Column::Null => Ok(None), - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "Number".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; @@ -89,14 +95,20 @@ macro_rules! column_as_number { ($col:expr) => { (match $col { $crate::stmt::Column::Text(text) => text.parse().map_err(|_| { - Error::InvalidConversion(stringify!($col).to_owned(), "Number".to_owned()) + $crate::ConversionError::InvalidConversion( + stringify!($col).to_owned(), + "Number".to_owned(), + ) }), $crate::stmt::Column::Integer(n) => n.try_into().map_err(|_| { - Error::InvalidConversion(stringify!($col).to_owned(), "Number".to_owned()) + $crate::ConversionError::InvalidConversion( + stringify!($col).to_owned(), + "Number".to_owned(), + ) }), - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "Number".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; @@ -110,51 +122,57 @@ macro_rules! column_as_nullable_binary { $crate::stmt::Column::Text(text) => Ok(Some(text.as_bytes().to_vec())), $crate::stmt::Column::Blob(bytes) => Ok(Some(bytes.to_owned())), $crate::stmt::Column::Null => Ok(None), - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "String".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; } -/// Parses a SQLite column as a binary +/// Parses a SQL column as a binary #[macro_export] macro_rules! column_as_binary { ($col:expr) => { (match $col { $crate::stmt::Column::Text(text) => Ok(text.as_bytes().to_vec()), $crate::stmt::Column::Blob(bytes) => Ok(bytes.to_owned()), - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "String".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; } -/// Parses a SQLite column as a string +/// Parses a SQL column as a string #[macro_export] macro_rules! column_as_string { ($col:expr, $callback_str:expr, $callback_bytes:expr) => { (match $col { - $crate::stmt::Column::Text(text) => $callback_str(&text).map_err(Error::from), - $crate::stmt::Column::Blob(bytes) => $callback_bytes(&bytes).map_err(Error::from), - other => Err(Error::InvalidType( + $crate::stmt::Column::Text(text) => { + $callback_str(&text).map_err($crate::ConversionError::from) + } + $crate::stmt::Column::Blob(bytes) => { + $callback_bytes(&bytes).map_err($crate::ConversionError::from) + } + _ => Err($crate::ConversionError::InvalidType( "String".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; ($col:expr, $callback:expr) => { (match $col { - $crate::stmt::Column::Text(text) => $callback(&text).map_err(Error::from), + $crate::stmt::Column::Text(text) => { + $callback(&text).map_err($crate::ConversionError::from) + } $crate::stmt::Column::Blob(bytes) => { - $callback(&String::from_utf8_lossy(&bytes)).map_err(Error::from) + $callback(&String::from_utf8_lossy(&bytes)).map_err($crate::ConversionError::from) } - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "String".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; @@ -162,9 +180,9 @@ macro_rules! column_as_string { (match $col { $crate::stmt::Column::Text(text) => Ok(text.to_owned()), $crate::stmt::Column::Blob(bytes) => Ok(String::from_utf8_lossy(&bytes).to_string()), - other => Err(Error::InvalidType( + _ => Err($crate::ConversionError::InvalidType( "String".to_owned(), - other.data_type().to_string(), + stringify!($col).to_owned(), )), })? }; diff --git a/crates/cdk-sql-common/src/mint/auth/migrations/postgres/1_init.sql b/crates/cdk-sql-common/src/mint/auth/migrations/postgres/1_init.sql new file mode 100644 index 000000000..9bba9e14c --- /dev/null +++ b/crates/cdk-sql-common/src/mint/auth/migrations/postgres/1_init.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS proof ( +y BYTEA PRIMARY KEY, +keyset_id TEXT NOT NULL, +secret TEXT NOT NULL, +c BYTEA NOT NULL, +state TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS state_index ON proof(state); +CREATE INDEX IF NOT EXISTS secret_index ON proof(secret); + + +-- Keysets Table + +CREATE TABLE IF NOT EXISTS keyset ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + active BOOL NOT NULL, + valid_from INTEGER NOT NULL, + valid_to INTEGER, + derivation_path TEXT NOT NULL, + max_order INTEGER NOT NULL, + derivation_path_index INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS unit_index ON keyset(unit); +CREATE INDEX IF NOT EXISTS active_index ON keyset(active); + + +CREATE TABLE IF NOT EXISTS blind_signature ( + y BYTEA PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + c BYTEA NOT NULL +); + +CREATE INDEX IF NOT EXISTS keyset_id_index ON blind_signature(keyset_id); + + +CREATE TABLE IF NOT EXISTS protected_endpoints ( + endpoint TEXT PRIMARY KEY, + auth TEXT NOT NULL +); diff --git a/crates/cdk-sql-common/src/mint/auth/migrations/postgres/20250822104351_rename_blind_message_y_to_b.sql b/crates/cdk-sql-common/src/mint/auth/migrations/postgres/20250822104351_rename_blind_message_y_to_b.sql new file mode 100644 index 000000000..b3a45f059 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/auth/migrations/postgres/20250822104351_rename_blind_message_y_to_b.sql @@ -0,0 +1,2 @@ +-- Rename column y to b +ALTER TABLE blind_signature RENAME COLUMN y TO blinded_message; \ No newline at end of file diff --git a/crates/cdk-sql-common/src/mint/auth/migrations/postgres/20251122000000_drop_max_order.sql b/crates/cdk-sql-common/src/mint/auth/migrations/postgres/20251122000000_drop_max_order.sql new file mode 100644 index 000000000..1d1ebbd39 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/auth/migrations/postgres/20251122000000_drop_max_order.sql @@ -0,0 +1,2 @@ +-- Drop max_order column from keyset table +ALTER TABLE keyset DROP COLUMN IF EXISTS max_order; diff --git a/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/1_fix_sqlx_migration.sql b/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/1_fix_sqlx_migration.sql new file mode 100644 index 000000000..9f7a0d828 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/1_fix_sqlx_migration.sql @@ -0,0 +1,20 @@ +-- Migrate `_sqlx_migrations` to our new migration system +CREATE TABLE IF NOT EXISTS _sqlx_migrations AS +SELECT + '' AS version, + '' AS description, + 0 AS execution_time +WHERE 0; + +INSERT INTO migrations +SELECT + version || '_' || REPLACE(description, ' ', '_') || '.sql', + execution_time +FROM _sqlx_migrations +WHERE EXISTS ( + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = '_sqlx_migrations' +); + +DROP TABLE _sqlx_migrations; diff --git a/crates/cdk-sqlite/src/mint/auth/migrations/20250109143347_init.sql b/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20250109143347_init.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/auth/migrations/20250109143347_init.sql rename to crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20250109143347_init.sql diff --git a/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20250822104351_rename_blind_message_y_to_b.sql b/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20250822104351_rename_blind_message_y_to_b.sql new file mode 100644 index 000000000..b3a45f059 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20250822104351_rename_blind_message_y_to_b.sql @@ -0,0 +1,2 @@ +-- Rename column y to b +ALTER TABLE blind_signature RENAME COLUMN y TO blinded_message; \ No newline at end of file diff --git a/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20251122000000_drop_max_order.sql b/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20251122000000_drop_max_order.sql new file mode 100644 index 000000000..0e862638c --- /dev/null +++ b/crates/cdk-sql-common/src/mint/auth/migrations/sqlite/20251122000000_drop_max_order.sql @@ -0,0 +1,28 @@ +-- Drop max_order column from keyset table +-- SQLite doesn't support DROP COLUMN directly, so we need to recreate the table + +-- Create new table without max_order +CREATE TABLE keyset_new ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + active BOOL NOT NULL, + valid_from INTEGER NOT NULL, + valid_to INTEGER, + derivation_path TEXT NOT NULL, + derivation_path_index INTEGER NOT NULL +); + +-- Copy data from old table to new table +INSERT INTO keyset_new (id, unit, active, valid_from, valid_to, derivation_path, derivation_path_index) +SELECT id, unit, active, valid_from, valid_to, derivation_path, derivation_path_index +FROM keyset; + +-- Drop old table +DROP TABLE keyset; + +-- Rename new table to original name +ALTER TABLE keyset_new RENAME TO keyset; + +-- Recreate indexes +CREATE INDEX IF NOT EXISTS unit_index ON keyset(unit); +CREATE INDEX IF NOT EXISTS active_index ON keyset(active); diff --git a/crates/cdk-sqlite/src/mint/auth/mod.rs b/crates/cdk-sql-common/src/mint/auth/mod.rs similarity index 62% rename from crates/cdk-sqlite/src/mint/auth/mod.rs rename to crates/cdk-sql-common/src/mint/auth/mod.rs index 4a002fa80..d998d3375 100644 --- a/crates/cdk-sqlite/src/mint/auth/mod.rs +++ b/crates/cdk-sql-common/src/mint/auth/mod.rs @@ -1,62 +1,68 @@ -//! SQLite Mint Auth +//! SQL Mint Auth use std::collections::HashMap; -use std::ops::DerefMut; -use std::path::Path; +use std::fmt::Debug; use std::str::FromStr; +use std::sync::Arc; use async_trait::async_trait; use cdk_common::database::{self, MintAuthDatabase, MintAuthTransaction}; use cdk_common::mint::MintKeySetInfo; use cdk_common::nuts::{AuthProof, BlindSignature, Id, PublicKey, State}; use cdk_common::{AuthRequired, ProtectedEndpoint}; +use migrations::MIGRATIONS; use tracing::instrument; -use super::async_rusqlite::AsyncRusqlite; -use super::{sqlite_row_to_blind_signature, sqlite_row_to_keyset_info, SqliteTransaction}; +use super::{sql_row_to_blind_signature, sql_row_to_keyset_info, SQLTransaction}; use crate::column_as_string; -use crate::common::{create_sqlite_pool, migrate}; -use crate::mint::async_rusqlite::query; +use crate::common::migrate; +use crate::database::{ConnectionWithTransaction, DatabaseExecutor}; use crate::mint::Error; +use crate::pool::{DatabasePool, Pool, PooledResource}; +use crate::stmt::query; -/// Mint SQLite Database +/// Mint SQL Database #[derive(Debug, Clone)] -pub struct MintSqliteAuthDatabase { - pool: AsyncRusqlite, +pub struct SQLMintAuthDatabase +where + RM: DatabasePool + 'static, +{ + pool: Arc>, } -#[rustfmt::skip] -mod migrations; - -impl MintSqliteAuthDatabase { - /// Create new [`MintSqliteAuthDatabase`] - #[cfg(not(feature = "sqlcipher"))] - pub async fn new>(path: P) -> Result { - let pool = create_sqlite_pool(path.as_ref().to_str().ok_or(Error::InvalidDbPath)?); - migrate(pool.get()?.deref_mut(), migrations::MIGRATIONS)?; - - Ok(Self { - pool: AsyncRusqlite::new(pool), - }) +impl SQLMintAuthDatabase +where + RM: DatabasePool + 'static, +{ + /// Creates a new instance + pub async fn new(db: X) -> Result + where + X: Into, + { + let pool = Pool::new(db.into()); + Self::migrate(pool.get().map_err(|e| Error::Database(Box::new(e)))?).await?; + Ok(Self { pool }) } - /// Create new [`MintSqliteAuthDatabase`] - #[cfg(feature = "sqlcipher")] - pub async fn new>(path: P, password: String) -> Result { - let pool = create_sqlite_pool( - path.as_ref().to_str().ok_or(Error::InvalidDbPath)?, - password, - ); - migrate(pool.get()?.deref_mut(), migrations::MIGRATIONS)?; - - Ok(Self { - pool: AsyncRusqlite::new(pool), - }) + /// Migrate + async fn migrate(conn: PooledResource) -> Result<(), Error> { + let tx = ConnectionWithTransaction::new(conn).await?; + migrate(&tx, RM::Connection::name(), MIGRATIONS).await?; + tx.commit().await?; + Ok(()) } } +#[rustfmt::skip] +mod migrations { + include!(concat!(env!("OUT_DIR"), "/migrations_mint_auth.rs")); +} + #[async_trait] -impl MintAuthTransaction for SqliteTransaction<'_> { +impl MintAuthTransaction for SQLTransaction +where + RM: DatabasePool + 'static, +{ #[instrument(skip(self))] async fn set_active_keyset(&mut self, id: Id) -> Result<(), database::Error> { tracing::info!("Setting auth keyset {id} active"); @@ -68,8 +74,8 @@ impl MintAuthTransaction for SqliteTransaction<'_> { ELSE FALSE END; "#, - ) - .bind(":id", id.to_string()) + )? + .bind("id", id.to_string()) .execute(&self.inner) .await?; @@ -82,11 +88,11 @@ impl MintAuthTransaction for SqliteTransaction<'_> { INSERT INTO keyset ( id, unit, active, valid_from, valid_to, derivation_path, - max_order, derivation_path_index + amounts, input_fee_ppk, derivation_path_index ) VALUES ( :id, :unit, :active, :valid_from, :valid_to, :derivation_path, - :max_order, :derivation_path_index + :amounts, :input_fee_ppk, :derivation_path_index ) ON CONFLICT(id) DO UPDATE SET unit = excluded.unit, @@ -94,18 +100,20 @@ impl MintAuthTransaction for SqliteTransaction<'_> { valid_from = excluded.valid_from, valid_to = excluded.valid_to, derivation_path = excluded.derivation_path, - max_order = excluded.max_order, + amounts = excluded.amounts, + input_fee_ppk = excluded.input_fee_ppk, derivation_path_index = excluded.derivation_path_index "#, - ) - .bind(":id", keyset.id.to_string()) - .bind(":unit", keyset.unit.to_string()) - .bind(":active", keyset.active) - .bind(":valid_from", keyset.valid_from as i64) - .bind(":valid_to", keyset.final_expiry.map(|v| v as i64)) - .bind(":derivation_path", keyset.derivation_path.to_string()) - .bind(":max_order", keyset.max_order) - .bind(":derivation_path_index", keyset.derivation_path_index) + )? + .bind("id", keyset.id.to_string()) + .bind("unit", keyset.unit.to_string()) + .bind("active", keyset.active) + .bind("valid_from", keyset.valid_from as i64) + .bind("valid_to", keyset.final_expiry.map(|v| v as i64)) + .bind("derivation_path", keyset.derivation_path.to_string()) + .bind("amounts", serde_json::to_string(&keyset.amounts).ok()) + .bind("input_fee_ppk", keyset.input_fee_ppk as i64) + .bind("derivation_path_index", keyset.derivation_path_index) .execute(&self.inner) .await?; @@ -120,12 +128,12 @@ impl MintAuthTransaction for SqliteTransaction<'_> { VALUES (:y, :keyset_id, :secret, :c, :state) "#, - ) - .bind(":y", proof.y()?.to_bytes().to_vec()) - .bind(":keyset_id", proof.keyset_id.to_string()) - .bind(":secret", proof.secret.to_string()) - .bind(":c", proof.c.to_bytes().to_vec()) - .bind(":state", "UNSPENT".to_string()) + )? + .bind("y", proof.y()?.to_bytes().to_vec()) + .bind("keyset_id", proof.keyset_id.to_string()) + .bind("secret", proof.secret.to_string()) + .bind("c", proof.c.to_bytes().to_vec()) + .bind("state", "UNSPENT".to_string()) .execute(&self.inner) .await { @@ -139,20 +147,20 @@ impl MintAuthTransaction for SqliteTransaction<'_> { y: &PublicKey, proofs_state: State, ) -> Result, Self::Err> { - let current_state = query(r#"SELECT state FROM proof WHERE y = :y"#) - .bind(":y", y.to_bytes().to_vec()) + let current_state = query(r#"SELECT state FROM proof WHERE y = :y FOR UPDATE"#)? + .bind("y", y.to_bytes().to_vec()) .pluck(&self.inner) .await? .map(|state| Ok::<_, Error>(column_as_string!(state, State::from_str))) .transpose()?; - query(r#"UPDATE proof SET state = :new_state WHERE state = :state AND y = :y"#) - .bind(":y", y.to_bytes().to_vec()) + query(r#"UPDATE proof SET state = :new_state WHERE state = :state AND y = :y"#)? + .bind("y", y.to_bytes().to_vec()) .bind( - ":state", + "state", current_state.as_ref().map(|state| state.to_string()), ) - .bind(":new_state", proofs_state.to_string()) + .bind("new_state", proofs_state.to_string()) .execute(&self.inner) .await?; @@ -169,15 +177,15 @@ impl MintAuthTransaction for SqliteTransaction<'_> { r#" INSERT INTO blind_signature - (y, amount, keyset_id, c) + (blinded_message, amount, keyset_id, c) VALUES - (:y, :amount, :keyset_id, :c) + (:blinded_message, :amount, :keyset_id, :c) "#, - ) - .bind(":y", message.to_bytes().to_vec()) - .bind(":amount", u64::from(signature.amount) as i64) - .bind(":keyset_id", signature.keyset_id.to_string()) - .bind(":c", signature.c.to_bytes().to_vec()) + )? + .bind("blinded_message", message.to_bytes().to_vec()) + .bind("amount", u64::from(signature.amount) as i64) + .bind("keyset_id", signature.keyset_id.to_string()) + .bind("c", signature.c.to_bytes().to_vec()) .execute(&self.inner) .await?; } @@ -192,13 +200,15 @@ impl MintAuthTransaction for SqliteTransaction<'_> { for (endpoint, auth) in protected_endpoints.iter() { if let Err(err) = query( r#" - INSERT OR REPLACE INTO protected_endpoints + INSERT INTO protected_endpoints (endpoint, auth) - VALUES (:endpoint, :auth); + VALUES (:endpoint, :auth) + ON CONFLICT (endpoint) DO UPDATE SET + auth = EXCLUDED.auth; "#, - ) - .bind(":endpoint", serde_json::to_string(endpoint)?) - .bind(":auth", serde_json::to_string(auth)?) + )? + .bind("endpoint", serde_json::to_string(endpoint)?) + .bind("auth", serde_json::to_string(auth)?) .execute(&self.inner) .await { @@ -215,9 +225,9 @@ impl MintAuthTransaction for SqliteTransaction<'_> { &mut self, protected_endpoints: Vec, ) -> Result<(), database::Error> { - query(r#"DELETE FROM protected_endpoints WHERE endpoint IN (:endpoints)"#) + query(r#"DELETE FROM protected_endpoints WHERE endpoint IN (:endpoints)"#)? .bind_vec( - ":endpoints", + "endpoints", protected_endpoints .iter() .map(serde_json::to_string) @@ -230,19 +240,26 @@ impl MintAuthTransaction for SqliteTransaction<'_> { } #[async_trait] -impl MintAuthDatabase for MintSqliteAuthDatabase { +impl MintAuthDatabase for SQLMintAuthDatabase +where + RM: DatabasePool + 'static, +{ type Err = database::Error; async fn begin_transaction<'a>( &'a self, ) -> Result + Send + Sync + 'a>, database::Error> { - Ok(Box::new(SqliteTransaction { - inner: self.pool.begin().await?, + Ok(Box::new(SQLTransaction { + inner: ConnectionWithTransaction::new( + self.pool.get().map_err(|e| Error::Database(Box::new(e)))?, + ) + .await?, })) } async fn get_active_keyset_id(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; Ok(query( r#" SELECT @@ -250,16 +267,18 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { FROM keyset WHERE - active = 1; + active = :active; "#, - ) - .pluck(&self.pool) + )? + .bind("active", true) + .pluck(&*conn) .await? .map(|id| Ok::<_, Error>(column_as_string!(id, Id::from_str, Id::from_bytes))) .transpose()?) } async fn get_keyset_info(&self, id: &Id) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; Ok(query( r#"SELECT id, @@ -269,20 +288,21 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { valid_to, derivation_path, derivation_path_index, - max_order, + amounts, input_fee_ppk FROM keyset WHERE id=:id"#, - ) - .bind(":id", id.to_string()) - .fetch_one(&self.pool) + )? + .bind("id", id.to_string()) + .fetch_one(&*conn) .await? - .map(sqlite_row_to_keyset_info) + .map(sql_row_to_keyset_info) .transpose()?) } async fn get_keyset_infos(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; Ok(query( r#"SELECT id, @@ -292,23 +312,27 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { valid_to, derivation_path, derivation_path_index, - max_order, + amounts, input_fee_ppk FROM keyset WHERE id=:id"#, - ) - .fetch_all(&self.pool) + )? + .fetch_all(&*conn) .await? .into_iter() - .map(sqlite_row_to_keyset_info) + .map(sql_row_to_keyset_info) .collect::, _>>()?) } async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err> { - let mut current_states = query(r#"SELECT y, state FROM proof WHERE y IN (:ys)"#) - .bind_vec(":ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) - .fetch_all(&self.pool) + if ys.is_empty() { + return Ok(vec![]); + } + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + let mut current_states = query(r#"SELECT y, state FROM proof WHERE y IN (:ys)"#)? + .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) + .fetch_all(&*conn) .await? .into_iter() .map(|row| { @@ -326,6 +350,7 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { &self, blinded_messages: &[PublicKey], ) -> Result>, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; let mut blinded_signatures = query( r#"SELECT keyset_id, @@ -333,20 +358,20 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { c, dleq_e, dleq_s, - y + blinded_message, FROM blind_signature - WHERE y IN (:y) + WHERE blinded_message IN (:blinded_message) "#, - ) + )? .bind_vec( - ":y", + "blinded_message", blinded_messages .iter() - .map(|y| y.to_bytes().to_vec()) + .map(|bm| bm.to_bytes().to_vec()) .collect(), ) - .fetch_all(&self.pool) + .fetch_all(&*conn) .await? .into_iter() .map(|mut row| { @@ -356,13 +381,13 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { PublicKey::from_hex, PublicKey::from_slice ), - sqlite_row_to_blind_signature(row)?, + sql_row_to_blind_signature(row)?, )) }) .collect::, Error>>()?; Ok(blinded_messages .iter() - .map(|y| blinded_signatures.remove(y)) + .map(|bm| blinded_signatures.remove(bm)) .collect()) } @@ -370,10 +395,11 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { &self, protected_endpoint: ProtectedEndpoint, ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; Ok( - query(r#"SELECT auth FROM protected_endpoints WHERE endpoint = :endpoint"#) - .bind(":endpoint", serde_json::to_string(&protected_endpoint)?) - .pluck(&self.pool) + query(r#"SELECT auth FROM protected_endpoints WHERE endpoint = :endpoint"#)? + .bind("endpoint", serde_json::to_string(&protected_endpoint)?) + .pluck(&*conn) .await? .map(|auth| { Ok::<_, Error>(column_as_string!( @@ -389,8 +415,9 @@ impl MintAuthDatabase for MintSqliteAuthDatabase { async fn get_auth_for_endpoints( &self, ) -> Result>, Self::Err> { - Ok(query(r#"SELECT endpoint, auth FROM protected_endpoints"#) - .fetch_all(&self.pool) + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query(r#"SELECT endpoint, auth FROM protected_endpoints"#)? + .fetch_all(&*conn) .await? .into_iter() .map(|row| { diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/1_initial.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/1_initial.sql new file mode 100644 index 000000000..56d0f6452 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/1_initial.sql @@ -0,0 +1,100 @@ +CREATE TABLE keyset ( + id TEXT PRIMARY KEY, unit TEXT NOT NULL, + active BOOL NOT NULL, valid_from INTEGER NOT NULL, + valid_to INTEGER, derivation_path TEXT NOT NULL, + max_order INTEGER NOT NULL, input_fee_ppk INTEGER, + derivation_path_index INTEGER +); +CREATE INDEX unit_index ON keyset(unit); +CREATE INDEX active_index ON keyset(active); +CREATE TABLE melt_quote ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + request TEXT NOT NULL, + fee_reserve INTEGER NOT NULL, + expiry INTEGER NOT NULL, + state TEXT CHECK ( + state IN ('UNPAID', 'PENDING', 'PAID') + ) NOT NULL DEFAULT 'UNPAID', + payment_preimage TEXT, + request_lookup_id TEXT, + created_time INTEGER NOT NULL DEFAULT 0, + paid_time INTEGER, + payment_method TEXT NOT NULL DEFAULT 'bolt11', + options TEXT, + request_lookup_id_kind TEXT NOT NULL DEFAULT 'payment_hash' +); +CREATE INDEX melt_quote_state_index ON melt_quote(state); +CREATE UNIQUE INDEX unique_request_lookup_id_melt ON melt_quote(request_lookup_id); +CREATE TABLE melt_request ( + id TEXT PRIMARY KEY, inputs TEXT NOT NULL, + outputs TEXT, method TEXT NOT NULL, + unit TEXT NOT NULL +); +CREATE TABLE config ( + id TEXT PRIMARY KEY, value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS "proof" ( + y BYTEA PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + secret TEXT NOT NULL, + c BYTEA NOT NULL, + witness TEXT, + state TEXT CHECK ( + state IN ( + 'SPENT', 'PENDING', 'UNSPENT', 'RESERVED', + 'UNKNOWN' + ) + ) NOT NULL, + quote_id TEXT, + created_time INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS "blind_signature" ( + blinded_message BYTEA PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + c BYTEA NOT NULL, + dleq_e TEXT, + dleq_s TEXT, + quote_id TEXT, + created_time INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS "mint_quote" ( + id TEXT PRIMARY KEY, amount INTEGER, + unit TEXT NOT NULL, request TEXT NOT NULL, + expiry INTEGER NOT NULL, request_lookup_id TEXT UNIQUE, + pubkey TEXT, created_time INTEGER NOT NULL DEFAULT 0, + amount_paid INTEGER NOT NULL DEFAULT 0, + amount_issued INTEGER NOT NULL DEFAULT 0, + payment_method TEXT NOT NULL DEFAULT 'BOLT11', + request_lookup_id_kind TEXT NOT NULL DEFAULT 'payment_hash' +); +CREATE INDEX idx_mint_quote_created_time ON mint_quote(created_time); +CREATE INDEX idx_mint_quote_expiry ON mint_quote(expiry); +CREATE INDEX idx_mint_quote_request_lookup_id ON mint_quote(request_lookup_id); +CREATE INDEX idx_mint_quote_request_lookup_id_and_kind ON mint_quote( + request_lookup_id, request_lookup_id_kind +); +CREATE TABLE mint_quote_payments ( + id SERIAL PRIMARY KEY, + quote_id TEXT NOT NULL, + payment_id TEXT NOT NULL UNIQUE, + timestamp INTEGER NOT NULL, + amount INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES mint_quote(id) +); +CREATE INDEX idx_mint_quote_payments_payment_id ON mint_quote_payments(payment_id); +CREATE INDEX idx_mint_quote_payments_quote_id ON mint_quote_payments(quote_id); +CREATE TABLE mint_quote_issued ( + id SERIAL PRIMARY KEY, + quote_id TEXT NOT NULL, + amount INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES mint_quote(id) +); +CREATE INDEX idx_mint_quote_issued_quote_id ON mint_quote_issued(quote_id); +CREATE INDEX idx_melt_quote_request_lookup_id_and_kind ON mint_quote( + request_lookup_id, request_lookup_id_kind +); diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20250901090000_add_kv_store.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20250901090000_add_kv_store.sql new file mode 100644 index 000000000..a46ef9f25 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20250901090000_add_kv_store.sql @@ -0,0 +1,18 @@ +-- Add kv_store table for generic key-value storage +CREATE TABLE IF NOT EXISTS kv_store ( + primary_namespace TEXT NOT NULL, + secondary_namespace TEXT NOT NULL, + key TEXT NOT NULL, + value BYTEA NOT NULL, + created_time BIGINT NOT NULL, + updated_time BIGINT NOT NULL, + PRIMARY KEY (primary_namespace, secondary_namespace, key) +); + +-- Index for efficient listing of keys by namespace +CREATE INDEX IF NOT EXISTS idx_kv_store_namespaces +ON kv_store (primary_namespace, secondary_namespace); + +-- Index for efficient querying by update time +CREATE INDEX IF NOT EXISTS idx_kv_store_updated_time +ON kv_store (updated_time); diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20250902140000_add_melt_request_and_blinded_messages.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20250902140000_add_melt_request_and_blinded_messages.sql new file mode 100644 index 000000000..af2324399 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20250902140000_add_melt_request_and_blinded_messages.sql @@ -0,0 +1,20 @@ +-- Drop existing melt_request table and recreate with new schema +DROP TABLE IF EXISTS melt_request; +CREATE TABLE melt_request ( + quote_id TEXT PRIMARY KEY, + inputs_amount INTEGER NOT NULL, + inputs_fee INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES melt_quote(id) +); + +-- Add blinded_messages table +CREATE TABLE blinded_messages ( + quote_id TEXT NOT NULL, + blinded_message BYTEA NOT NULL, + keyset_id TEXT NOT NULL, + amount INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES melt_request(quote_id) ON DELETE CASCADE +); + +-- Add index for faster lookups on blinded_messages +CREATE INDEX blinded_messages_quote_id_index ON blinded_messages(quote_id); diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20250903200000_add_signatory_amounts.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20250903200000_add_signatory_amounts.sql new file mode 100644 index 000000000..cef8c730a --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20250903200000_add_signatory_amounts.sql @@ -0,0 +1 @@ +ALTER TABLE keyset ADD COLUMN amounts TEXT DEFAULT NULL; diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20250916221000_drop_config_table.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20250916221000_drop_config_table.sql new file mode 100644 index 000000000..2efbbee0f --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20250916221000_drop_config_table.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS config; diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql new file mode 100644 index 000000000..8543cd492 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20250924215800_migrate_blinded_messages_to_blind_signatures.sql @@ -0,0 +1,23 @@ +-- Remove NOT NULL constraint from c column in blind_signature table +ALTER TABLE blind_signature ALTER COLUMN c DROP NOT NULL; + +-- Add signed_time column to blind_signature table +ALTER TABLE blind_signature ADD COLUMN signed_time INTEGER NULL; + +-- Update existing records to set signed_time equal to created_time for existing signatures +UPDATE blind_signature SET signed_time = created_time WHERE c IS NOT NULL; + +-- Insert data from blinded_messages table into blind_signature table with NULL c column +INSERT INTO blind_signature (blinded_message, amount, keyset_id, c, quote_id, created_time, signed_time) +SELECT blinded_message, amount, keyset_id, NULL as c, quote_id, 0 as created_time, NULL as signed_time +FROM blinded_messages +WHERE NOT EXISTS ( + SELECT 1 FROM blind_signature + WHERE blind_signature.blinded_message = blinded_messages.blinded_message +); + +-- Create index on quote_id if it does not exist +CREATE INDEX IF NOT EXISTS blind_signature_quote_id_index ON blind_signature(quote_id); + +-- Drop the blinded_messages table as data has been migrated +DROP TABLE IF EXISTS blinded_messages; diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql new file mode 100644 index 000000000..114937f3b --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20251010144317_add_saga_support.sql @@ -0,0 +1,26 @@ +-- 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, + quote_id TEXT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_saga_state_operation_kind ON saga_state(operation_kind); +CREATE INDEX IF NOT EXISTS idx_saga_state_quote_id ON saga_state(quote_id); diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20251102000000_create_keyset_amounts.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20251102000000_create_keyset_amounts.sql new file mode 100644 index 000000000..302d7149b --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20251102000000_create_keyset_amounts.sql @@ -0,0 +1,25 @@ +-- Create keyset_amounts table with total_issued and total_redeemed columns +CREATE TABLE IF NOT EXISTS keyset_amounts ( + keyset_id TEXT PRIMARY KEY NOT NULL, + total_issued BIGINT NOT NULL DEFAULT 0, + total_redeemed BIGINT NOT NULL DEFAULT 0 +); + +-- Prefill with issued and redeemed amounts using FULL OUTER JOIN +INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed) +SELECT + COALESCE(bs.keyset_id, p.keyset_id) as keyset_id, + COALESCE(bs.total_issued, 0) as total_issued, + COALESCE(p.total_redeemed, 0) as total_redeemed +FROM ( + SELECT keyset_id, SUM(amount) as total_issued + FROM blind_signature + WHERE c IS NOT NULL + GROUP BY keyset_id +) bs +FULL OUTER JOIN ( + SELECT keyset_id, SUM(amount) as total_redeemed + FROM proof + WHERE state = 'SPENT' + GROUP BY keyset_id +) p ON bs.keyset_id = p.keyset_id; diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20251122000000_drop_max_order.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20251122000000_drop_max_order.sql new file mode 100644 index 000000000..1d1ebbd39 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20251122000000_drop_max_order.sql @@ -0,0 +1,2 @@ +-- Drop max_order column from keyset table +ALTER TABLE keyset DROP COLUMN IF EXISTS max_order; diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/20251127000000_allow_duplicate_melt_request_lookup_id.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/20251127000000_allow_duplicate_melt_request_lookup_id.sql new file mode 100644 index 000000000..e13332c70 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/20251127000000_allow_duplicate_melt_request_lookup_id.sql @@ -0,0 +1,15 @@ +-- Remove unique constraint on request_lookup_id for melt_quote +-- This allows multiple melt quotes for the same payment request +-- The constraint that only one can be PENDING or PAID at a time is enforced by a partial unique index + +-- Drop the unique index on request_lookup_id +DROP INDEX IF EXISTS unique_request_lookup_id_melt; + +-- Create a non-unique index for lookup performance +CREATE INDEX IF NOT EXISTS idx_melt_quote_request_lookup_id ON melt_quote(request_lookup_id); + +-- Create a partial unique index to enforce that only one quote per lookup_id can be PENDING or PAID +-- This provides database-level enforcement of the constraint +CREATE UNIQUE INDEX IF NOT EXISTS unique_pending_paid_lookup_id +ON melt_quote(request_lookup_id) +WHERE state IN ('PENDING', 'PAID'); diff --git a/crates/cdk-sql-common/src/mint/migrations/postgres/2_remove_request_lookup_kind_constraints.sql b/crates/cdk-sql-common/src/mint/migrations/postgres/2_remove_request_lookup_kind_constraints.sql new file mode 100644 index 000000000..786743c0c --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/postgres/2_remove_request_lookup_kind_constraints.sql @@ -0,0 +1,6 @@ +-- Set existing NULL or empty request_lookup_id_kind values to 'payment_hash' in melt_quote +UPDATE melt_quote SET request_lookup_id_kind = 'payment_hash' WHERE request_lookup_id_kind IS NULL OR request_lookup_id_kind = ''; + +-- Remove NOT NULL constraint and default value from request_lookup_id_kind in melt_quote table +ALTER TABLE melt_quote ALTER COLUMN request_lookup_id_kind DROP NOT NULL; +ALTER TABLE melt_quote ALTER COLUMN request_lookup_id_kind DROP DEFAULT; diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/1_fix_sqlx_migration.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/1_fix_sqlx_migration.sql new file mode 100644 index 000000000..9f7a0d828 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/1_fix_sqlx_migration.sql @@ -0,0 +1,20 @@ +-- Migrate `_sqlx_migrations` to our new migration system +CREATE TABLE IF NOT EXISTS _sqlx_migrations AS +SELECT + '' AS version, + '' AS description, + 0 AS execution_time +WHERE 0; + +INSERT INTO migrations +SELECT + version || '_' || REPLACE(description, ' ', '_') || '.sql', + execution_time +FROM _sqlx_migrations +WHERE EXISTS ( + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = '_sqlx_migrations' +); + +DROP TABLE _sqlx_migrations; diff --git a/crates/cdk-sqlite/src/mint/migrations/20240612124932_init.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240612124932_init.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240612124932_init.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240612124932_init.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240618195700_quote_state.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240618195700_quote_state.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240618195700_quote_state.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240618195700_quote_state.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240626092101_nut04_state.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240626092101_nut04_state.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240626092101_nut04_state.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240626092101_nut04_state.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240703122347_request_lookup_id.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240703122347_request_lookup_id.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240703122347_request_lookup_id.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240703122347_request_lookup_id.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240710145043_input_fee.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240710145043_input_fee.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240710145043_input_fee.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240710145043_input_fee.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240711183109_derivation_path_index.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240711183109_derivation_path_index.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240711183109_derivation_path_index.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240711183109_derivation_path_index.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240718203721_allow_unspent.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240718203721_allow_unspent.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240718203721_allow_unspent.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240718203721_allow_unspent.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240811031111_update_mint_url.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240811031111_update_mint_url.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240811031111_update_mint_url.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240811031111_update_mint_url.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240919103407_proofs_quote_id.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240919103407_proofs_quote_id.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240919103407_proofs_quote_id.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240919103407_proofs_quote_id.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240923153640_melt_requests.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240923153640_melt_requests.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240923153640_melt_requests.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240923153640_melt_requests.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20240930101140_dleq_for_sigs.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20240930101140_dleq_for_sigs.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20240930101140_dleq_for_sigs.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20240930101140_dleq_for_sigs.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20241108093102_mint_mint_quote_pubkey.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20241108093102_mint_mint_quote_pubkey.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20241108093102_mint_mint_quote_pubkey.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20241108093102_mint_mint_quote_pubkey.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20250103201327_amount_to_pay_msats.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250103201327_amount_to_pay_msats.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20250103201327_amount_to_pay_msats.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20250103201327_amount_to_pay_msats.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20250129200912_remove_mint_url.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250129200912_remove_mint_url.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20250129200912_remove_mint_url.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20250129200912_remove_mint_url.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20250129230326_add_config_table.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250129230326_add_config_table.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20250129230326_add_config_table.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20250129230326_add_config_table.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20250307213652_keyset_id_as_foreign_key.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250307213652_keyset_id_as_foreign_key.sql similarity index 96% rename from crates/cdk-sqlite/src/mint/migrations/20250307213652_keyset_id_as_foreign_key.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20250307213652_keyset_id_as_foreign_key.sql index 1dfdc77fe..c18669742 100644 --- a/crates/cdk-sqlite/src/mint/migrations/20250307213652_keyset_id_as_foreign_key.sql +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250307213652_keyset_id_as_foreign_key.sql @@ -1,5 +1,5 @@ -- Add foreign key constraints for keyset_id in SQLite --- SQLite requires recreating tables to add foreign keys +-- SQL requires recreating tables to add foreign keys -- First, ensure we have the right schema information PRAGMA foreign_keys = OFF; diff --git a/crates/cdk-sqlite/src/mint/migrations/20250406091754_mint_time_of_quotes.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250406091754_mint_time_of_quotes.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20250406091754_mint_time_of_quotes.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20250406091754_mint_time_of_quotes.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20250406093755_mint_created_time_signature.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250406093755_mint_created_time_signature.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20250406093755_mint_created_time_signature.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20250406093755_mint_created_time_signature.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20250415093121_drop_keystore_foreign.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250415093121_drop_keystore_foreign.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20250415093121_drop_keystore_foreign.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20250415093121_drop_keystore_foreign.sql diff --git a/crates/cdk-sqlite/src/mint/migrations/20250626120251_rename_blind_message_y_to_b.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250626120251_rename_blind_message_y_to_b.sql similarity index 100% rename from crates/cdk-sqlite/src/mint/migrations/20250626120251_rename_blind_message_y_to_b.sql rename to crates/cdk-sql-common/src/mint/migrations/sqlite/20250626120251_rename_blind_message_y_to_b.sql diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250706101057_bolt12.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250706101057_bolt12.sql new file mode 100644 index 000000000..94d690a65 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250706101057_bolt12.sql @@ -0,0 +1,81 @@ +-- Add new columns to mint_quote table +ALTER TABLE mint_quote ADD COLUMN amount_paid INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mint_quote ADD COLUMN amount_issued INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mint_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'BOLT11'; +ALTER TABLE mint_quote DROP COLUMN issued_time; +ALTER TABLE mint_quote DROP COLUMN paid_time; + +-- Set amount_paid equal to amount for quotes with PAID or ISSUED state +UPDATE mint_quote SET amount_paid = amount WHERE state = 'PAID' OR state = 'ISSUED'; + +-- Set amount_issued equal to amount for quotes with ISSUED state +UPDATE mint_quote SET amount_issued = amount WHERE state = 'ISSUED'; + +DROP INDEX IF EXISTS mint_quote_state_index; + +-- Remove the state column from mint_quote table +ALTER TABLE mint_quote DROP COLUMN state; + +-- Remove NOT NULL constraint from amount column +CREATE TABLE mint_quote_temp ( + id TEXT PRIMARY KEY, + amount INTEGER, + unit TEXT NOT NULL, + request TEXT NOT NULL, + expiry INTEGER NOT NULL, + request_lookup_id TEXT UNIQUE, + pubkey TEXT, + created_time INTEGER NOT NULL DEFAULT 0, + amount_paid INTEGER NOT NULL DEFAULT 0, + amount_issued INTEGER NOT NULL DEFAULT 0, + payment_method TEXT NOT NULL DEFAULT 'BOLT11' +); + +INSERT INTO mint_quote_temp (id, amount, unit, request, expiry, request_lookup_id, pubkey, created_time, amount_paid, amount_issued, payment_method) +SELECT id, amount, unit, request, expiry, request_lookup_id, pubkey, created_time, amount_paid, amount_issued, payment_method +FROM mint_quote; + +DROP TABLE mint_quote; +ALTER TABLE mint_quote_temp RENAME TO mint_quote; + +ALTER TABLE mint_quote ADD COLUMN request_lookup_id_kind TEXT NOT NULL DEFAULT 'payment_hash'; + +CREATE INDEX IF NOT EXISTS idx_mint_quote_created_time ON mint_quote(created_time); +CREATE INDEX IF NOT EXISTS idx_mint_quote_expiry ON mint_quote(expiry); +CREATE INDEX IF NOT EXISTS idx_mint_quote_request_lookup_id ON mint_quote(request_lookup_id); +CREATE INDEX IF NOT EXISTS idx_mint_quote_request_lookup_id_and_kind ON mint_quote(request_lookup_id, request_lookup_id_kind); + +-- Create mint_quote_payments table +CREATE TABLE mint_quote_payments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_id TEXT NOT NULL, + payment_id TEXT NOT NULL UNIQUE, + timestamp INTEGER NOT NULL, + amount INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES mint_quote(id) +); + +-- Create index on payment_id for faster lookups +CREATE INDEX idx_mint_quote_payments_payment_id ON mint_quote_payments(payment_id); +CREATE INDEX idx_mint_quote_payments_quote_id ON mint_quote_payments(quote_id); + +-- Create mint_quote_issued table +CREATE TABLE mint_quote_issued ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + quote_id TEXT NOT NULL, + amount INTEGER NOT NULL, + timestamp INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES mint_quote(id) +); + +-- Create index on quote_id for faster lookups +CREATE INDEX idx_mint_quote_issued_quote_id ON mint_quote_issued(quote_id); + +-- Add new columns to melt_quote table +ALTER TABLE melt_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'bolt11'; +ALTER TABLE melt_quote ADD COLUMN options TEXT; +ALTER TABLE melt_quote ADD COLUMN request_lookup_id_kind TEXT NOT NULL DEFAULT 'payment_hash'; + +CREATE INDEX IF NOT EXISTS idx_melt_quote_request_lookup_id_and_kind ON mint_quote(request_lookup_id, request_lookup_id_kind); + +ALTER TABLE melt_quote DROP COLUMN msat_to_pay; diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250812132015_drop_melt_request.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250812132015_drop_melt_request.sql new file mode 100644 index 000000000..c6f7b1e5a --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250812132015_drop_melt_request.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS melt_request; diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250819200000_remove_request_lookup_kind_constraints.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250819200000_remove_request_lookup_kind_constraints.sql new file mode 100644 index 000000000..ec0bb7886 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250819200000_remove_request_lookup_kind_constraints.sql @@ -0,0 +1,35 @@ + +-- Set existing NULL or empty request_lookup_id_kind values to 'payment_hash' in melt_quote +UPDATE melt_quote SET request_lookup_id_kind = 'payment_hash' WHERE request_lookup_id_kind IS NULL OR request_lookup_id_kind = ''; + +-- Remove NOT NULL constraint and default value from request_lookup_id_kind in melt_quote table +CREATE TABLE melt_quote_temp ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + request TEXT NOT NULL, + fee_reserve INTEGER NOT NULL, + expiry INTEGER NOT NULL, + state TEXT CHECK ( + state IN ('UNPAID', 'PENDING', 'PAID') + ) NOT NULL DEFAULT 'UNPAID', + payment_preimage TEXT, + request_lookup_id TEXT, + created_time INTEGER NOT NULL DEFAULT 0, + paid_time INTEGER, + payment_method TEXT NOT NULL DEFAULT 'bolt11', + options TEXT, + request_lookup_id_kind TEXT +); + +INSERT INTO melt_quote_temp (id, unit, amount, request, fee_reserve, expiry, state, payment_preimage, request_lookup_id, created_time, paid_time, payment_method, options, request_lookup_id_kind) +SELECT id, unit, amount, request, fee_reserve, expiry, state, payment_preimage, request_lookup_id, created_time, paid_time, payment_method, options, request_lookup_id_kind +FROM melt_quote; + +DROP TABLE melt_quote; +ALTER TABLE melt_quote_temp RENAME TO melt_quote; + +-- Recreate indexes for melt_quote +CREATE INDEX IF NOT EXISTS melt_quote_state_index ON melt_quote(state); +CREATE UNIQUE INDEX IF NOT EXISTS unique_request_lookup_id_melt ON melt_quote(request_lookup_id); +CREATE INDEX IF NOT EXISTS idx_melt_quote_request_lookup_id_and_kind ON melt_quote(request_lookup_id, request_lookup_id_kind); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250901090000_add_kv_store.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250901090000_add_kv_store.sql new file mode 100644 index 000000000..f073826c8 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250901090000_add_kv_store.sql @@ -0,0 +1,18 @@ +-- Add kv_store table for generic key-value storage +CREATE TABLE IF NOT EXISTS kv_store ( + primary_namespace TEXT NOT NULL, + secondary_namespace TEXT NOT NULL, + key TEXT NOT NULL, + value BLOB NOT NULL, + created_time INTEGER NOT NULL, + updated_time INTEGER NOT NULL, + PRIMARY KEY (primary_namespace, secondary_namespace, key) +); + +-- Index for efficient listing of keys by namespace +CREATE INDEX IF NOT EXISTS idx_kv_store_namespaces +ON kv_store (primary_namespace, secondary_namespace); + +-- Index for efficient querying by update time +CREATE INDEX IF NOT EXISTS idx_kv_store_updated_time +ON kv_store (updated_time); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250902140000_add_melt_request_and_blinded_messages.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250902140000_add_melt_request_and_blinded_messages.sql new file mode 100644 index 000000000..9c8b56327 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250902140000_add_melt_request_and_blinded_messages.sql @@ -0,0 +1,23 @@ + +-- Drop existing melt_request table and recreate with new schema +DROP TABLE IF EXISTS melt_request; +CREATE TABLE melt_request ( + quote_id TEXT PRIMARY KEY, + inputs_amount INTEGER NOT NULL, + inputs_fee INTEGER NOT NULL, + FOREIGN KEY (quote_id) REFERENCES melt_quote(id) +); + +-- Add blinded_messages table +CREATE TABLE blinded_messages ( + quote_id TEXT NOT NULL, + blinded_message BLOB NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + keyset_id TEXT NOT NULL, + FOREIGN KEY (quote_id) REFERENCES melt_request(quote_id) ON DELETE CASCADE +); + +-- Add index for faster lookups on blinded_messages +CREATE INDEX blinded_messages_quote_id_index ON blinded_messages(quote_id); +-- Create an index on keyset_id for better query performance +CREATE INDEX blinded_messages_keyset_id_index ON blinded_messages(keyset_id); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250903200000_add_signatory_amounts.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250903200000_add_signatory_amounts.sql new file mode 100644 index 000000000..348591f91 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250903200000_add_signatory_amounts.sql @@ -0,0 +1,33 @@ +CREATE TABLE keyset_new ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + active BOOL NOT NULL, + valid_from INTEGER NOT NULL, + valid_to INTEGER, + max_order INTEGER NOT NULL, + amounts TEXT DEFAULT NULL, + input_fee_ppk INTEGER, + derivation_path TEXT NOT NULL, + derivation_path_index INTEGER +); + + +INSERT INTO keyset_new SELECT + id, + unit, + active, + valid_from, + valid_to, + max_order, + NULL, + input_fee_ppk, + derivation_path, + derivation_path_index +FROM keyset; + +DROP TABLE keyset; + +ALTER TABLE keyset_new RENAME TO keyset; + +CREATE INDEX unit_index ON keyset(unit); +CREATE INDEX active_index ON keyset(active); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250916221000_drop_config_table.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250916221000_drop_config_table.sql new file mode 100644 index 000000000..2efbbee0f --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250916221000_drop_config_table.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS config; diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql new file mode 100644 index 000000000..42c0fc936 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20250924215800_migrate_blinded_messages_to_blind_signatures.sql @@ -0,0 +1,40 @@ +-- Remove NOT NULL constraint from c column in blind_signature table +-- SQLite does not support ALTER COLUMN directly, so we need to recreate the table + +-- Step 1 - Create new table with nullable c column and signed_time column +CREATE TABLE blind_signature_new ( + blinded_message BLOB PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + c BLOB NULL, + dleq_e TEXT, + dleq_s TEXT, + quote_id TEXT, + created_time INTEGER NOT NULL DEFAULT 0, + signed_time INTEGER +); + +-- Step 2 - Copy existing data from old blind_signature table +INSERT INTO blind_signature_new (blinded_message, amount, keyset_id, c, dleq_e, dleq_s, quote_id, created_time) +SELECT blinded_message, amount, keyset_id, c, dleq_e, dleq_s, quote_id, created_time +FROM blind_signature; + +-- Step 3 - Insert data from blinded_messages table with NULL c column +INSERT INTO blind_signature_new (blinded_message, amount, keyset_id, c, quote_id, created_time) +SELECT blinded_message, amount, keyset_id, NULL as c, quote_id, 0 as created_time +FROM blinded_messages +WHERE NOT EXISTS ( + SELECT 1 FROM blind_signature_new + WHERE blind_signature_new.blinded_message = blinded_messages.blinded_message +); + +-- Step 4 - Drop old table and rename new table +DROP TABLE blind_signature; +ALTER TABLE blind_signature_new RENAME TO blind_signature; + +-- Step 5 - Recreate indexes +CREATE INDEX IF NOT EXISTS keyset_id_index ON blind_signature(keyset_id); +CREATE INDEX IF NOT EXISTS blind_signature_quote_id_index ON blind_signature(quote_id); + +-- Step 6 - Drop the blinded_messages table as data has been migrated +DROP TABLE IF EXISTS blinded_messages; diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql new file mode 100644 index 000000000..2ff0902f6 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20251010144317_add_saga_support.sql @@ -0,0 +1,26 @@ +-- 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, + quote_id TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_saga_state_operation_kind ON saga_state(operation_kind); +CREATE INDEX IF NOT EXISTS idx_saga_state_quote_id ON saga_state(quote_id); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20251102000000_create_keyset_amounts.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20251102000000_create_keyset_amounts.sql new file mode 100644 index 000000000..64c631eca --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20251102000000_create_keyset_amounts.sql @@ -0,0 +1,30 @@ +-- Create keyset_amounts table with total_issued and total_redeemed columns +CREATE TABLE IF NOT EXISTS keyset_amounts ( + keyset_id TEXT PRIMARY KEY NOT NULL, + total_issued INTEGER NOT NULL DEFAULT 0, + total_redeemed INTEGER NOT NULL DEFAULT 0 +); + +-- Prefill with issued amounts +INSERT OR IGNORE INTO keyset_amounts (keyset_id, total_issued, total_redeemed) +SELECT keyset_id, SUM(amount) as total_issued, 0 as total_redeemed +FROM blind_signature +WHERE c IS NOT NULL +GROUP BY keyset_id; + +-- Update with redeemed amounts +UPDATE keyset_amounts +SET total_redeemed = ( + SELECT COALESCE(SUM(amount), 0) + FROM proof + WHERE proof.keyset_id = keyset_amounts.keyset_id + AND proof.state = 'SPENT' +); + +-- Insert keysets that only have redeemed amounts (no issued) +INSERT OR IGNORE INTO keyset_amounts (keyset_id, total_issued, total_redeemed) +SELECT keyset_id, 0 as total_issued, SUM(amount) as total_redeemed +FROM proof +WHERE state = 'SPENT' +AND keyset_id NOT IN (SELECT keyset_id FROM keyset_amounts) +GROUP BY keyset_id; diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20251122000000_drop_max_order.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20251122000000_drop_max_order.sql new file mode 100644 index 000000000..e03cdd7dc --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20251122000000_drop_max_order.sql @@ -0,0 +1,30 @@ +-- Drop max_order column from keyset table +-- SQLite doesn't support DROP COLUMN directly, so we need to recreate the table + +-- Create new table without max_order +CREATE TABLE keyset_new ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + active BOOL NOT NULL, + valid_from INTEGER NOT NULL, + valid_to INTEGER, + derivation_path TEXT NOT NULL, + input_fee_ppk INTEGER, + derivation_path_index INTEGER, + amounts TEXT +); + +-- Copy data from old table to new table +INSERT INTO keyset_new (id, unit, active, valid_from, valid_to, derivation_path, input_fee_ppk, derivation_path_index, amounts) +SELECT id, unit, active, valid_from, valid_to, derivation_path, input_fee_ppk, derivation_path_index, amounts +FROM keyset; + +-- Drop old table +DROP TABLE keyset; + +-- Rename new table to original name +ALTER TABLE keyset_new RENAME TO keyset; + +-- Recreate indexes +CREATE INDEX IF NOT EXISTS unit_index ON keyset(unit); +CREATE INDEX IF NOT EXISTS active_index ON keyset(active); diff --git a/crates/cdk-sql-common/src/mint/migrations/sqlite/20251127000000_allow_duplicate_melt_request_lookup_id.sql b/crates/cdk-sql-common/src/mint/migrations/sqlite/20251127000000_allow_duplicate_melt_request_lookup_id.sql new file mode 100644 index 000000000..c50d1a260 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/migrations/sqlite/20251127000000_allow_duplicate_melt_request_lookup_id.sql @@ -0,0 +1,9 @@ +-- Remove unique constraint on request_lookup_id for melt_quote +-- This allows multiple melt quotes for the same payment request +-- The constraint that only one can be pending at a time is enforced in application logic + +-- Drop the unique index on request_lookup_id +DROP INDEX IF EXISTS unique_request_lookup_id_melt; + +-- Create a non-unique index for lookup performance +CREATE INDEX IF NOT EXISTS idx_melt_quote_request_lookup_id ON melt_quote(request_lookup_id); diff --git a/crates/cdk-sql-common/src/mint/mod.rs b/crates/cdk-sql-common/src/mint/mod.rs new file mode 100644 index 000000000..ac0bd7513 --- /dev/null +++ b/crates/cdk-sql-common/src/mint/mod.rs @@ -0,0 +1,2686 @@ +//! SQL database implementation of the Mint +//! +//! This is a generic SQL implementation for the mint storage layer. Any database can be plugged in +//! as long as standard ANSI SQL is used, as Postgres and SQLite would understand it. +//! +//! This implementation also has a rudimentary but standard migration and versioning system. +//! +//! The trait expects an asynchronous interaction, but it also provides tools to spawn blocking +//! clients in a pool and expose them to an asynchronous environment, making them compatible with +//! Mint. +use std::collections::HashMap; +use std::fmt::Debug; +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use bitcoin::bip32::DerivationPath; +use cdk_common::database::mint::{validate_kvstore_params, SagaDatabase, SagaTransaction}; +use cdk_common::database::{ + self, ConversionError, Error, MintDatabase, MintDbWriterFinalizer, MintKeyDatabaseTransaction, + MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, MintQuotesTransaction, + MintSignatureTransaction, MintSignaturesDatabase, +}; +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_melt_quote_state_transition, check_state_transition}; +use cdk_common::util::unix_time; +use cdk_common::{ + Amount, BlindSignature, BlindSignatureDleq, BlindedMessage, CurrencyUnit, Id, MeltQuoteState, + PaymentMethod, Proof, Proofs, PublicKey, SecretKey, State, +}; +use lightning_invoice::Bolt11Invoice; +use migrations::MIGRATIONS; +use tracing::instrument; + +use crate::common::migrate; +use crate::database::{ConnectionWithTransaction, DatabaseExecutor}; +use crate::pool::{DatabasePool, Pool, PooledResource}; +use crate::stmt::{query, Column}; +use crate::{ + column_as_nullable_number, column_as_nullable_string, column_as_number, column_as_string, + unpack_into, +}; + +#[cfg(feature = "auth")] +mod auth; + +#[rustfmt::skip] +mod migrations { + include!(concat!(env!("OUT_DIR"), "/migrations_mint.rs")); +} + +#[cfg(feature = "auth")] +pub use auth::SQLMintAuthDatabase; +#[cfg(feature = "prometheus")] +use cdk_prometheus::METRICS; + +/// Mint SQL Database +#[derive(Debug, Clone)] +pub struct SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + pool: Arc>, +} + +/// SQL Transaction Writer +pub struct SQLTransaction +where + RM: DatabasePool + 'static, +{ + inner: ConnectionWithTransaction>, +} + +#[inline(always)] +async fn get_current_states( + conn: &C, + ys: &[PublicKey], +) -> Result, Error> +where + C: DatabaseExecutor + Send + Sync, +{ + if ys.is_empty() { + return Ok(Default::default()); + } + query(r#"SELECT y, state FROM proof WHERE y IN (:ys)"#)? + .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) + .fetch_all(conn) + .await? + .into_iter() + .map(|row| { + Ok(( + column_as_string!(&row[0], PublicKey::from_hex, PublicKey::from_slice), + column_as_string!(&row[1], State::from_str), + )) + }) + .collect::, _>>() +} + +impl SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + /// Creates a new instance + pub async fn new(db: X) -> Result + where + X: Into, + { + let pool = Pool::new(db.into()); + + Self::migrate(pool.get().map_err(|e| Error::Database(Box::new(e)))?).await?; + + Ok(Self { pool }) + } + + /// Migrate + async fn migrate(conn: PooledResource) -> Result<(), Error> { + let tx = ConnectionWithTransaction::new(conn).await?; + migrate(&tx, RM::Connection::name(), MIGRATIONS).await?; + tx.commit().await?; + Ok(()) + } +} + +#[async_trait] +impl database::MintProofsTransaction<'_> for SQLTransaction +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn add_proofs( + &mut self, + proofs: Proofs, + quote_id: Option, + operation: &Operation, + ) -> Result<(), Self::Err> { + let current_time = unix_time(); + + // Check any previous proof, this query should return None in order to proceed storing + // Any result here would error + match query(r#"SELECT state FROM proof WHERE y IN (:ys) LIMIT 1 FOR UPDATE"#)? + .bind_vec( + "ys", + proofs + .iter() + .map(|y| y.y().map(|y| y.to_bytes().to_vec())) + .collect::>()?, + ) + .pluck(&self.inner) + .await? + .map(|state| Ok::<_, Error>(column_as_string!(&state, State::from_str))) + .transpose()? + { + Some(State::Spent) => Err(database::Error::AttemptUpdateSpentProof), + Some(_) => Err(database::Error::Duplicate), + None => Ok(()), // no previous record + }?; + + for proof in proofs { + query( + r#" + INSERT INTO proof + (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, :operation_kind, :operation_id) + "#, + )? + .bind("y", proof.y()?.to_bytes().to_vec()) + .bind("amount", proof.amount.to_i64()) + .bind("keyset_id", proof.keyset_id.to_string()) + .bind("secret", proof.secret.to_string()) + .bind("c", proof.c.to_bytes().to_vec()) + .bind( + "witness", + proof.witness.map(|w| serde_json::to_string(&w).unwrap()), + ) + .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?; + } + + Ok(()) + } + + async fn update_proofs_states( + &mut self, + ys: &[PublicKey], + new_state: State, + ) -> Result>, Self::Err> { + let mut current_states = get_current_states(&self.inner, ys).await?; + + if current_states.len() != ys.len() { + tracing::warn!( + "Attempted to update state of non-existent proof {} {}", + current_states.len(), + ys.len() + ); + return Err(database::Error::ProofNotFound); + } + + for state in current_states.values() { + check_state_transition(*state, new_state)?; + } + + query(r#"UPDATE proof SET state = :new_state WHERE y IN (:ys)"#)? + .bind("new_state", new_state.to_string()) + .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) + .execute(&self.inner) + .await?; + + if new_state == State::Spent { + query( + r#" + INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed) + SELECT keyset_id, 0, COALESCE(SUM(amount), 0) + FROM proof + WHERE y IN (:ys) + GROUP BY keyset_id + ON CONFLICT (keyset_id) + DO UPDATE SET total_redeemed = keyset_amounts.total_redeemed + EXCLUDED.total_redeemed + "#, + )? + .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) + .execute(&self.inner) + .await?; + } + + Ok(ys.iter().map(|y| current_states.remove(y)).collect()) + } + + async fn remove_proofs( + &mut self, + ys: &[PublicKey], + _quote_id: Option, + ) -> Result<(), Self::Err> { + if ys.is_empty() { + return Ok(()); + } + let total_deleted = query( + r#" + DELETE FROM proof WHERE y IN (:ys) AND state NOT IN (:exclude_state) + "#, + )? + .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) + .bind_vec("exclude_state", vec![State::Spent.to_string()]) + .execute(&self.inner) + .await?; + + if total_deleted != ys.len() { + return Err(Self::Err::AttemptRemoveSpentProof); + } + + Ok(()) + } + + async fn get_proof_ys_by_quote_id( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err> { + Ok(query( + r#" + SELECT + amount, + keyset_id, + secret, + c, + witness + FROM + proof + WHERE + quote_id = :quote_id + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_all(&self.inner) + .await? + .into_iter() + .map(sql_row_to_proof) + .collect::, _>>()? + .ys()?) + } +} + +#[async_trait] +impl database::MintTransaction<'_, Error> for SQLTransaction where RM: DatabasePool + 'static +{} + +#[async_trait] +impl MintDbWriterFinalizer for SQLTransaction +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn commit(self: Box) -> Result<(), Error> { + let result = self.inner.commit().await; + #[cfg(feature = "prometheus")] + { + let success = result.is_ok(); + METRICS.record_mint_operation("transaction_commit", success); + METRICS.record_mint_operation_histogram("transaction_commit", success, 1.0); + } + + Ok(result?) + } + + async fn rollback(self: Box) -> Result<(), Error> { + let result = self.inner.rollback().await; + + #[cfg(feature = "prometheus")] + { + let success = result.is_ok(); + METRICS.record_mint_operation("transaction_rollback", success); + METRICS.record_mint_operation_histogram("transaction_rollback", success, 1.0); + } + Ok(result?) + } +} + +#[inline(always)] +async fn get_mint_quote_payments( + conn: &C, + quote_id: &QuoteId, +) -> Result, Error> +where + C: DatabaseExecutor + Send + Sync, +{ + // Get payment IDs and timestamps from the mint_quote_payments table + query( + r#" + SELECT + payment_id, + timestamp, + amount + FROM + mint_quote_payments + WHERE + quote_id=:quote_id + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_all(conn) + .await? + .into_iter() + .map(|row| { + let amount: u64 = column_as_number!(row[2].clone()); + let time: u64 = column_as_number!(row[1].clone()); + Ok(IncomingPayment::new( + amount.into(), + column_as_string!(&row[0]), + time, + )) + }) + .collect() +} + +#[inline(always)] +async fn get_mint_quote_issuance(conn: &C, quote_id: &QuoteId) -> Result, Error> +where + C: DatabaseExecutor + Send + Sync, +{ + // Get payment IDs and timestamps from the mint_quote_payments table + query( + r#" +SELECT amount, timestamp +FROM mint_quote_issued +WHERE quote_id=:quote_id + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_all(conn) + .await? + .into_iter() + .map(|row| { + let time: u64 = column_as_number!(row[1].clone()); + Ok(Issuance::new( + Amount::from_i64(column_as_number!(row[0].clone())) + .expect("Is amount when put into db"), + time, + )) + }) + .collect() +} + +// Inline helper functions that work with both connections and transactions +#[inline] +async fn get_mint_quote_inner( + executor: &T, + quote_id: &QuoteId, + for_update: bool, +) -> Result, Error> +where + T: DatabaseExecutor, +{ + let payments = get_mint_quote_payments(executor, quote_id).await?; + let issuance = get_mint_quote_issuance(executor, quote_id).await?; + + let for_update_clause = if for_update { "FOR UPDATE" } else { "" }; + let query_str = format!( + r#" + SELECT + id, + amount, + unit, + request, + expiry, + request_lookup_id, + pubkey, + created_time, + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind + FROM + mint_quote + WHERE id = :id + {for_update_clause} + "# + ); + + query(&query_str)? + .bind("id", quote_id.to_string()) + .fetch_one(executor) + .await? + .map(|row| sql_row_to_mint_quote(row, payments, issuance)) + .transpose() +} + +#[inline] +async fn get_mint_quote_by_request_inner( + executor: &T, + request: &str, + for_update: bool, +) -> Result, Error> +where + T: DatabaseExecutor, +{ + let for_update_clause = if for_update { "FOR UPDATE" } else { "" }; + let query_str = format!( + r#" + SELECT + id, + amount, + unit, + request, + expiry, + request_lookup_id, + pubkey, + created_time, + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind + FROM + mint_quote + WHERE request = :request + {for_update_clause} + "# + ); + + let mut mint_quote = query(&query_str)? + .bind("request", request.to_string()) + .fetch_one(executor) + .await? + .map(|row| sql_row_to_mint_quote(row, vec![], vec![])) + .transpose()?; + + if let Some(quote) = mint_quote.as_mut() { + let payments = get_mint_quote_payments(executor, "e.id).await?; + let issuance = get_mint_quote_issuance(executor, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quote) +} + +#[inline] +async fn get_mint_quote_by_request_lookup_id_inner( + executor: &T, + request_lookup_id: &PaymentIdentifier, + for_update: bool, +) -> Result, Error> +where + T: DatabaseExecutor, +{ + let for_update_clause = if for_update { "FOR UPDATE" } else { "" }; + let query_str = format!( + r#" + SELECT + id, + amount, + unit, + request, + expiry, + request_lookup_id, + pubkey, + created_time, + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind + FROM + mint_quote + WHERE request_lookup_id = :request_lookup_id + AND request_lookup_id_kind = :request_lookup_id_kind + {for_update_clause} + "# + ); + + let mut mint_quote = query(&query_str)? + .bind("request_lookup_id", request_lookup_id.to_string()) + .bind("request_lookup_id_kind", request_lookup_id.kind()) + .fetch_one(executor) + .await? + .map(|row| sql_row_to_mint_quote(row, vec![], vec![])) + .transpose()?; + + if let Some(quote) = mint_quote.as_mut() { + let payments = get_mint_quote_payments(executor, "e.id).await?; + let issuance = get_mint_quote_issuance(executor, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quote) +} + +#[inline] +async fn get_melt_quote_inner( + executor: &T, + quote_id: &QuoteId, + for_update: bool, +) -> Result, Error> +where + T: DatabaseExecutor, +{ + let for_update_clause = if for_update { "FOR UPDATE" } else { "" }; + let query_str = format!( + r#" + SELECT + id, + unit, + amount, + request, + fee_reserve, + expiry, + state, + payment_preimage, + request_lookup_id, + created_time, + paid_time, + payment_method, + options, + request_lookup_id_kind + FROM + melt_quote + WHERE + id=:id + {for_update_clause} + "# + ); + + query(&query_str)? + .bind("id", quote_id.to_string()) + .fetch_one(executor) + .await? + .map(sql_row_to_melt_quote) + .transpose() +} + +#[async_trait] +impl MintKeyDatabaseTransaction<'_, Error> for SQLTransaction +where + RM: DatabasePool + 'static, +{ + async fn add_keyset_info(&mut self, keyset: MintKeySetInfo) -> Result<(), Error> { + query( + r#" + INSERT INTO + keyset ( + id, unit, active, valid_from, valid_to, derivation_path, + amounts, input_fee_ppk, derivation_path_index + ) + VALUES ( + :id, :unit, :active, :valid_from, :valid_to, :derivation_path, + :amounts, :input_fee_ppk, :derivation_path_index + ) + ON CONFLICT(id) DO UPDATE SET + unit = excluded.unit, + active = excluded.active, + valid_from = excluded.valid_from, + valid_to = excluded.valid_to, + derivation_path = excluded.derivation_path, + amounts = excluded.amounts, + input_fee_ppk = excluded.input_fee_ppk, + derivation_path_index = excluded.derivation_path_index + "#, + )? + .bind("id", keyset.id.to_string()) + .bind("unit", keyset.unit.to_string()) + .bind("active", keyset.active) + .bind("valid_from", keyset.valid_from as i64) + .bind("valid_to", keyset.final_expiry.map(|v| v as i64)) + .bind("derivation_path", keyset.derivation_path.to_string()) + .bind("amounts", serde_json::to_string(&keyset.amounts).ok()) + .bind("input_fee_ppk", keyset.input_fee_ppk as i64) + .bind("derivation_path_index", keyset.derivation_path_index) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn set_active_keyset(&mut self, unit: CurrencyUnit, id: Id) -> Result<(), Error> { + query(r#"UPDATE keyset SET active=FALSE WHERE unit = :unit"#)? + .bind("unit", unit.to_string()) + .execute(&self.inner) + .await?; + + query(r#"UPDATE keyset SET active=TRUE WHERE unit = :unit AND id = :id"#)? + .bind("unit", unit.to_string()) + .bind("id", id.to_string()) + .execute(&self.inner) + .await?; + + Ok(()) + } +} + +#[async_trait] +impl MintKeysDatabase for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn begin_transaction<'a>( + &'a self, + ) -> Result + Send + Sync + 'a>, Error> { + let tx = SQLTransaction { + inner: ConnectionWithTransaction::new( + self.pool.get().map_err(|e| Error::Database(Box::new(e)))?, + ) + .await?, + }; + + Ok(Box::new(tx)) + } + + async fn get_active_keyset_id(&self, unit: &CurrencyUnit) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok( + query(r#" SELECT id FROM keyset WHERE active = :active AND unit = :unit"#)? + .bind("active", true) + .bind("unit", unit.to_string()) + .pluck(&*conn) + .await? + .map(|id| match id { + Column::Text(text) => Ok(Id::from_str(&text)?), + Column::Blob(id) => Ok(Id::from_bytes(&id)?), + _ => Err(Error::InvalidKeysetId), + }) + .transpose()?, + ) + } + + async fn get_active_keysets(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok( + query(r#"SELECT id, unit FROM keyset WHERE active = :active"#)? + .bind("active", true) + .fetch_all(&*conn) + .await? + .into_iter() + .map(|row| { + Ok(( + column_as_string!(&row[1], CurrencyUnit::from_str), + column_as_string!(&row[0], Id::from_str, Id::from_bytes), + )) + }) + .collect::, Error>>()?, + ) + } + + async fn get_keyset_info(&self, id: &Id) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#"SELECT + id, + unit, + active, + valid_from, + valid_to, + derivation_path, + derivation_path_index, + amounts, + input_fee_ppk + FROM + keyset + WHERE id=:id"#, + )? + .bind("id", id.to_string()) + .fetch_one(&*conn) + .await? + .map(sql_row_to_keyset_info) + .transpose()?) + } + + async fn get_keyset_infos(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#"SELECT + id, + unit, + active, + valid_from, + valid_to, + derivation_path, + derivation_path_index, + amounts, + input_fee_ppk + FROM + keyset + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_keyset_info) + .collect::, _>>()?) + } +} + +#[async_trait] +impl MintQuotesTransaction<'_> for SQLTransaction +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn add_melt_request( + &mut self, + quote_id: &QuoteId, + inputs_amount: Amount, + inputs_fee: Amount, + ) -> Result<(), Self::Err> { + // Insert melt_request + query( + r#" + INSERT INTO melt_request + (quote_id, inputs_amount, inputs_fee) + VALUES + (:quote_id, :inputs_amount, :inputs_fee) + "#, + )? + .bind("quote_id", quote_id.to_string()) + .bind("inputs_amount", inputs_amount.to_i64()) + .bind("inputs_fee", inputs_fee.to_i64()) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn add_blinded_messages( + &mut self, + quote_id: Option<&QuoteId>, + blinded_messages: &[BlindedMessage], + operation: &Operation, + ) -> Result<(), Self::Err> { + let current_time = unix_time(); + + // Insert blinded_messages directly into blind_signature with c = NULL + // Let the database constraint handle duplicate detection + for message in blinded_messages { + match query( + r#" + INSERT INTO blind_signature + (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, :operation_kind, :operation_id) + "#, + )? + .bind( + "blinded_message", + message.blinded_secret.to_bytes().to_vec(), + ) + .bind("amount", message.amount.to_i64()) + .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 + { + Ok(_) => continue, + Err(database::Error::Duplicate) => { + // Primary key constraint violation - blinded message already exists + // This could be either: + // 1. Already signed (c IS NOT NULL) - definitely an error + // 2. Already pending (c IS NULL) - also an error + return Err(database::Error::Duplicate); + } + Err(err) => return Err(err), + } + } + + Ok(()) + } + + async fn delete_blinded_messages( + &mut self, + blinded_secrets: &[PublicKey], + ) -> Result<(), Self::Err> { + if blinded_secrets.is_empty() { + return Ok(()); + } + + // Delete blinded messages from blind_signature table where c IS NULL + // (only delete unsigned blinded messages) + query( + r#" + DELETE FROM blind_signature + WHERE blinded_message IN (:blinded_secrets) AND c IS NULL + "#, + )? + .bind_vec( + "blinded_secrets", + blinded_secrets + .iter() + .map(|secret| secret.to_bytes().to_vec()) + .collect(), + ) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn get_melt_request_and_blinded_messages( + &mut self, + quote_id: &QuoteId, + ) -> Result, Self::Err> { + let melt_request_row = query( + r#" + SELECT inputs_amount, inputs_fee + FROM melt_request + WHERE quote_id = :quote_id + FOR UPDATE + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_one(&self.inner) + .await?; + + if let Some(row) = melt_request_row { + let inputs_amount: u64 = column_as_number!(row[0].clone()); + let inputs_fee: u64 = column_as_number!(row[1].clone()); + + // Get blinded messages from blind_signature table where c IS NULL + let blinded_messages_rows = query( + r#" + SELECT blinded_message, keyset_id, amount + FROM blind_signature + WHERE quote_id = :quote_id AND c IS NULL + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_all(&self.inner) + .await?; + + let blinded_messages: Result, Error> = blinded_messages_rows + .into_iter() + .map(|row| -> Result { + let blinded_message_key = + column_as_string!(&row[0], PublicKey::from_hex, PublicKey::from_slice); + let keyset_id = column_as_string!(&row[1], Id::from_str, Id::from_bytes); + let amount: u64 = column_as_number!(row[2].clone()); + + Ok(BlindedMessage { + blinded_secret: blinded_message_key, + keyset_id, + amount: Amount::from(amount), + witness: None, // Not storing witness in database currently + }) + }) + .collect(); + let blinded_messages = blinded_messages?; + + Ok(Some(database::mint::MeltRequestInfo { + inputs_amount: Amount::from(inputs_amount), + inputs_fee: Amount::from(inputs_fee), + change_outputs: blinded_messages, + })) + } else { + Ok(None) + } + } + + async fn delete_melt_request(&mut self, quote_id: &QuoteId) -> Result<(), Self::Err> { + // Delete from melt_request table + query( + r#" + DELETE FROM melt_request + WHERE quote_id = :quote_id + "#, + )? + .bind("quote_id", quote_id.to_string()) + .execute(&self.inner) + .await?; + + // Also delete blinded messages (where c IS NULL) from blind_signature table + query( + r#" + DELETE FROM blind_signature + WHERE quote_id = :quote_id AND c IS NULL + "#, + )? + .bind("quote_id", quote_id.to_string()) + .execute(&self.inner) + .await?; + + Ok(()) + } + + #[instrument(skip(self))] + async fn increment_mint_quote_amount_paid( + &mut self, + quote_id: &QuoteId, + amount_paid: Amount, + payment_id: String, + ) -> Result { + if amount_paid == Amount::ZERO { + tracing::warn!("Amount payments of zero amount should not be recorded."); + return Err(Error::Duplicate); + } + + // Check if payment_id already exists in mint_quote_payments + let exists = query( + r#" + SELECT payment_id + FROM mint_quote_payments + WHERE payment_id = :payment_id + FOR UPDATE + "#, + )? + .bind("payment_id", payment_id.clone()) + .fetch_one(&self.inner) + .await?; + + if exists.is_some() { + tracing::error!("Payment ID already exists: {}", payment_id); + return Err(database::Error::Duplicate); + } + + // Get current amount_paid from quote + let current_amount = query( + r#" + SELECT amount_paid + FROM mint_quote + WHERE id = :quote_id + FOR UPDATE + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_one(&self.inner) + .await + .inspect_err(|err| { + tracing::error!("SQLite could not get mint quote amount_paid: {}", err); + })?; + + let current_amount_paid = if let Some(current_amount) = current_amount { + let amount: u64 = column_as_number!(current_amount[0].clone()); + Amount::from(amount) + } else { + Amount::ZERO + }; + + // Calculate new amount_paid with overflow check + let new_amount_paid = current_amount_paid + .checked_add(amount_paid) + .ok_or_else(|| database::Error::AmountOverflow)?; + + tracing::debug!( + "Mint quote {} amount paid was {} is now {}.", + quote_id, + current_amount_paid, + new_amount_paid + ); + + // Update the amount_paid + query( + r#" + UPDATE mint_quote + SET amount_paid = :amount_paid + WHERE id = :quote_id + "#, + )? + .bind("amount_paid", new_amount_paid.to_i64()) + .bind("quote_id", quote_id.to_string()) + .execute(&self.inner) + .await + .inspect_err(|err| { + tracing::error!("SQLite could not update mint quote amount_paid: {}", err); + })?; + + // Add payment_id to mint_quote_payments table + query( + r#" + INSERT INTO mint_quote_payments + (quote_id, payment_id, amount, timestamp) + VALUES (:quote_id, :payment_id, :amount, :timestamp) + "#, + )? + .bind("quote_id", quote_id.to_string()) + .bind("payment_id", payment_id) + .bind("amount", amount_paid.to_i64()) + .bind("timestamp", unix_time() as i64) + .execute(&self.inner) + .await + .map_err(|err| { + tracing::error!("SQLite could not insert payment ID: {}", err); + err + })?; + + Ok(new_amount_paid) + } + + #[instrument(skip_all)] + async fn increment_mint_quote_amount_issued( + &mut self, + quote_id: &QuoteId, + amount_issued: Amount, + ) -> Result { + // Get current amount_issued from quote + let current_amounts = query( + r#" + SELECT amount_issued, amount_paid + FROM mint_quote + WHERE id = :quote_id + FOR UPDATE + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_one(&self.inner) + .await + .inspect_err(|err| { + tracing::error!("SQLite could not get mint quote amount_issued: {}", err); + })? + .ok_or(Error::QuoteNotFound)?; + + let new_amount_issued = { + // Make sure the db protects issuing not paid quotes + unpack_into!( + let (current_amount_issued, current_amount_paid) = current_amounts + ); + + let current_amount_issued: u64 = column_as_number!(current_amount_issued); + let current_amount_paid: u64 = column_as_number!(current_amount_paid); + + let current_amount_issued = Amount::from(current_amount_issued); + let current_amount_paid = Amount::from(current_amount_paid); + + // Calculate new amount_issued with overflow check + let new_amount_issued = current_amount_issued + .checked_add(amount_issued) + .ok_or_else(|| database::Error::AmountOverflow)?; + + current_amount_paid + .checked_sub(new_amount_issued) + .ok_or(Error::Internal("Over-issued not allowed".to_owned()))?; + + new_amount_issued + }; + + // Update the amount_issued + query( + r#" + UPDATE mint_quote + SET amount_issued = :amount_issued + WHERE id = :quote_id + "#, + )? + .bind("amount_issued", new_amount_issued.to_i64()) + .bind("quote_id", quote_id.to_string()) + .execute(&self.inner) + .await + .inspect_err(|err| { + tracing::error!("SQLite could not update mint quote amount_issued: {}", err); + })?; + + let current_time = unix_time(); + + query( + r#" +INSERT INTO mint_quote_issued +(quote_id, amount, timestamp) +VALUES (:quote_id, :amount, :timestamp); + "#, + )? + .bind("quote_id", quote_id.to_string()) + .bind("amount", amount_issued.to_i64()) + .bind("timestamp", current_time as i64) + .execute(&self.inner) + .await?; + + Ok(new_amount_issued) + } + + #[instrument(skip_all)] + async fn add_mint_quote(&mut self, quote: MintQuote) -> Result<(), Self::Err> { + query( + r#" + INSERT INTO mint_quote ( + id, amount, unit, request, expiry, request_lookup_id, pubkey, created_time, payment_method, request_lookup_id_kind + ) + VALUES ( + :id, :amount, :unit, :request, :expiry, :request_lookup_id, :pubkey, :created_time, :payment_method, :request_lookup_id_kind + ) + "#, + )? + .bind("id", quote.id.to_string()) + .bind("amount", quote.amount.map(|a| a.to_i64())) + .bind("unit", quote.unit.to_string()) + .bind("request", quote.request) + .bind("expiry", quote.expiry as i64) + .bind( + "request_lookup_id", + quote.request_lookup_id.to_string(), + ) + .bind("pubkey", quote.pubkey.map(|p| p.to_string())) + .bind("created_time", quote.created_time as i64) + .bind("payment_method", quote.payment_method.to_string()) + .bind("request_lookup_id_kind", quote.request_lookup_id.kind()) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err> { + // Now insert the new quote + query( + r#" + INSERT INTO melt_quote + ( + id, unit, amount, request, fee_reserve, state, + expiry, payment_preimage, request_lookup_id, + created_time, paid_time, options, request_lookup_id_kind, payment_method + ) + VALUES + ( + :id, :unit, :amount, :request, :fee_reserve, :state, + :expiry, :payment_preimage, :request_lookup_id, + :created_time, :paid_time, :options, :request_lookup_id_kind, :payment_method + ) + "#, + )? + .bind("id", quote.id.to_string()) + .bind("unit", quote.unit.to_string()) + .bind("amount", quote.amount.to_i64()) + .bind("request", serde_json::to_string("e.request)?) + .bind("fee_reserve", quote.fee_reserve.to_i64()) + .bind("state", quote.state.to_string()) + .bind("expiry", quote.expiry as i64) + .bind("payment_preimage", quote.payment_preimage) + .bind( + "request_lookup_id", + quote.request_lookup_id.as_ref().map(|id| id.to_string()), + ) + .bind("created_time", quote.created_time as i64) + .bind("paid_time", quote.paid_time.map(|t| t as i64)) + .bind( + "options", + quote.options.map(|o| serde_json::to_string(&o).ok()), + ) + .bind( + "request_lookup_id_kind", + quote.request_lookup_id.map(|id| id.kind()), + ) + .bind("payment_method", quote.payment_method.to_string()) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn update_melt_quote_request_lookup_id( + &mut self, + quote_id: &QuoteId, + new_request_lookup_id: &PaymentIdentifier, + ) -> Result<(), Self::Err> { + query(r#"UPDATE melt_quote SET request_lookup_id = :new_req_id, request_lookup_id_kind = :new_kind WHERE id = :id"#)? + .bind("new_req_id", new_request_lookup_id.to_string()) + .bind("new_kind",new_request_lookup_id.kind() ) + .bind("id", quote_id.to_string()) + .execute(&self.inner) + .await?; + Ok(()) + } + + async fn update_melt_quote_state( + &mut self, + quote_id: &QuoteId, + state: MeltQuoteState, + payment_proof: Option, + ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err> { + let mut quote = query( + r#" + SELECT + id, + unit, + amount, + request, + fee_reserve, + expiry, + state, + payment_preimage, + request_lookup_id, + created_time, + paid_time, + payment_method, + options, + request_lookup_id_kind + FROM + melt_quote + WHERE + id=:id + "#, + )? + .bind("id", quote_id.to_string()) + .fetch_one(&self.inner) + .await? + .map(sql_row_to_melt_quote) + .transpose()? + .ok_or(Error::QuoteNotFound)?; + + check_melt_quote_state_transition(quote.state, state)?; + + // When transitioning to Pending, lock all quotes with the same lookup_id + // and check if any are already pending or paid + if state == MeltQuoteState::Pending { + if let Some(ref lookup_id) = quote.request_lookup_id { + // Lock all quotes with the same lookup_id to prevent race conditions + let locked_quotes: Vec<(String, String)> = query( + r#" + SELECT id, state + FROM melt_quote + WHERE request_lookup_id = :lookup_id + FOR UPDATE + "#, + )? + .bind("lookup_id", lookup_id.to_string()) + .fetch_all(&self.inner) + .await? + .into_iter() + .map(|row| { + unpack_into!(let (id, state) = row); + Ok((column_as_string!(id), column_as_string!(state))) + }) + .collect::, Error>>()?; + + // Check if any other quote with the same lookup_id is pending or paid + let has_conflict = locked_quotes.iter().any(|(id, state)| { + id != "e_id.to_string() + && (state == &MeltQuoteState::Pending.to_string() + || state == &MeltQuoteState::Paid.to_string()) + }); + + if has_conflict { + tracing::warn!( + "Cannot transition quote {} to Pending: another quote with lookup_id {} is already pending or paid", + quote_id, + lookup_id + ); + return Err(Error::Duplicate); + } + } + } + + 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"#)? + .bind("state", state.to_string()) + .bind("paid_time", current_time as i64) + .bind("payment_preimage", payment_proof) + .bind("id", quote_id.to_string()) + .execute(&self.inner) + .await + } else { + query(r#"UPDATE melt_quote SET state = :state WHERE id = :id"#)? + .bind("state", state.to_string()) + .bind("id", quote_id.to_string()) + .execute(&self.inner) + .await + }; + + match rec { + Ok(_) => {} + Err(err) => { + tracing::error!("SQLite Could not update melt quote"); + return Err(err); + } + }; + + let old_state = quote.state; + quote.state = state; + + if state == MeltQuoteState::Unpaid || state == MeltQuoteState::Failed { + self.delete_melt_request(quote_id).await?; + } + + Ok((old_state, quote)) + } + + async fn get_mint_quote(&mut self, quote_id: &QuoteId) -> Result, Self::Err> { + get_mint_quote_inner(&self.inner, quote_id, true).await + } + + async fn get_melt_quote( + &mut self, + quote_id: &QuoteId, + ) -> Result, Self::Err> { + get_melt_quote_inner(&self.inner, quote_id, true).await + } + + async fn get_mint_quote_by_request( + &mut self, + request: &str, + ) -> Result, Self::Err> { + get_mint_quote_by_request_inner(&self.inner, request, true).await + } + + async fn get_mint_quote_by_request_lookup_id( + &mut self, + request_lookup_id: &PaymentIdentifier, + ) -> Result, Self::Err> { + get_mint_quote_by_request_lookup_id_inner(&self.inner, request_lookup_id, true).await + } +} + +#[async_trait] +impl MintQuotesDatabase for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn get_mint_quote(&self, quote_id: &QuoteId) -> Result, Self::Err> { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("get_mint_quote"); + + #[cfg(feature = "prometheus")] + let start_time = std::time::Instant::now(); + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + let result = get_mint_quote_inner(&*conn, quote_id, false).await; + + #[cfg(feature = "prometheus")] + { + let success = result.is_ok(); + + METRICS.record_mint_operation("get_mint_quote", success); + METRICS.record_mint_operation_histogram( + "get_mint_quote", + success, + start_time.elapsed().as_secs_f64(), + ); + METRICS.dec_in_flight_requests("get_mint_quote"); + } + + result + } + + async fn get_mint_quote_by_request( + &self, + request: &str, + ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + get_mint_quote_by_request_inner(&*conn, request, false).await + } + + async fn get_mint_quote_by_request_lookup_id( + &self, + request_lookup_id: &PaymentIdentifier, + ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + get_mint_quote_by_request_lookup_id_inner(&*conn, request_lookup_id, false).await + } + + async fn get_mint_quotes(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + let mut mint_quotes = query( + r#" + SELECT + id, + amount, + unit, + request, + expiry, + request_lookup_id, + pubkey, + created_time, + amount_paid, + amount_issued, + payment_method, + request_lookup_id_kind + FROM + mint_quote + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(|row| sql_row_to_mint_quote(row, vec![], vec![])) + .collect::, _>>()?; + + for quote in mint_quotes.as_mut_slice() { + let payments = get_mint_quote_payments(&*conn, "e.id).await?; + let issuance = get_mint_quote_issuance(&*conn, "e.id).await?; + quote.issuance = issuance; + quote.payments = payments; + } + + Ok(mint_quotes) + } + + async fn get_melt_quote( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err> { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("get_melt_quote"); + + #[cfg(feature = "prometheus")] + let start_time = std::time::Instant::now(); + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + let result = get_melt_quote_inner(&*conn, quote_id, false).await; + + #[cfg(feature = "prometheus")] + { + let success = result.is_ok(); + + METRICS.record_mint_operation("get_melt_quote", success); + METRICS.record_mint_operation_histogram( + "get_melt_quote", + success, + start_time.elapsed().as_secs_f64(), + ); + METRICS.dec_in_flight_requests("get_melt_quote"); + } + + result + } + + async fn get_melt_quotes(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + id, + unit, + amount, + request, + fee_reserve, + expiry, + state, + payment_preimage, + request_lookup_id, + created_time, + paid_time, + payment_method, + options, + request_lookup_id_kind + FROM + melt_quote + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_melt_quote) + .collect::, _>>()?) + } +} + +#[async_trait] +impl MintProofsDatabase for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn get_proofs_by_ys(&self, ys: &[PublicKey]) -> Result>, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + let mut proofs = query( + r#" + SELECT + amount, + keyset_id, + secret, + c, + witness, + y + FROM + proof + WHERE + y IN (:ys) + "#, + )? + .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) + .fetch_all(&*conn) + .await? + .into_iter() + .map(|mut row| { + Ok(( + column_as_string!( + row.pop().ok_or(Error::InvalidDbResponse)?, + PublicKey::from_hex, + PublicKey::from_slice + ), + sql_row_to_proof(row)?, + )) + }) + .collect::, Error>>()?; + + Ok(ys.iter().map(|y| proofs.remove(y)).collect()) + } + + async fn get_proof_ys_by_quote_id( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + amount, + keyset_id, + secret, + c, + witness + FROM + proof + WHERE + quote_id = :quote_id + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_proof) + .collect::, _>>()? + .ys()?) + } + + async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + let mut current_states = get_current_states(&*conn, ys).await?; + + Ok(ys.iter().map(|y| current_states.remove(y)).collect()) + } + + async fn get_proofs_by_keyset_id( + &self, + keyset_id: &Id, + ) -> Result<(Proofs, Vec>), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + keyset_id, + amount, + secret, + c, + witness, + state + FROM + proof + WHERE + keyset_id=:keyset_id + "#, + )? + .bind("keyset_id", keyset_id.to_string()) + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_proof_with_state) + .collect::, _>>()? + .into_iter() + .unzip()) + } + + /// Get total proofs redeemed by keyset id + async fn get_total_redeemed(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + query( + r#" + SELECT + keyset_id, + total_redeemed as amount + FROM + keyset_amounts + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_hashmap_amount) + .collect() + } +} + +#[async_trait] +impl MintSignatureTransaction<'_> for SQLTransaction +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn add_blind_signatures( + &mut self, + blinded_messages: &[PublicKey], + blind_signatures: &[BlindSignature], + quote_id: Option, + ) -> Result<(), Self::Err> { + let current_time = unix_time(); + + if blinded_messages.len() != blind_signatures.len() { + return Err(database::Error::Internal( + "Mismatched array lengths for blinded messages and blind signatures".to_string(), + )); + } + + // Select all existing rows for the given blinded messages at once + let mut existing_rows = query( + r#" + SELECT blinded_message, c, dleq_e, dleq_s + FROM blind_signature + WHERE blinded_message IN (:blinded_messages) + FOR UPDATE + "#, + )? + .bind_vec( + "blinded_messages", + blinded_messages + .iter() + .map(|message| message.to_bytes().to_vec()) + .collect(), + ) + .fetch_all(&self.inner) + .await? + .into_iter() + .map(|mut row| { + Ok(( + column_as_string!(&row.remove(0), PublicKey::from_hex, PublicKey::from_slice), + (row[0].clone(), row[1].clone(), row[2].clone()), + )) + }) + .collect::, Error>>()?; + + // Iterate over the provided blinded messages and signatures + for (message, signature) in blinded_messages.iter().zip(blind_signatures) { + match existing_rows.remove(message) { + None => { + // Unknown blind message: Insert new row with all columns + query( + r#" + INSERT INTO blind_signature + (blinded_message, amount, keyset_id, c, quote_id, dleq_e, dleq_s, created_time, signed_time) + VALUES + (:blinded_message, :amount, :keyset_id, :c, :quote_id, :dleq_e, :dleq_s, :created_time, :signed_time) + "#, + )? + .bind("blinded_message", message.to_bytes().to_vec()) + .bind("amount", u64::from(signature.amount) as i64) + .bind("keyset_id", signature.keyset_id.to_string()) + .bind("c", signature.c.to_bytes().to_vec()) + .bind("quote_id", quote_id.as_ref().map(|q| q.to_string())) + .bind( + "dleq_e", + signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()), + ) + .bind( + "dleq_s", + signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()), + ) + .bind("created_time", current_time as i64) + .bind("signed_time", current_time as i64) + .execute(&self.inner) + .await?; + + query( + r#" + INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed) + VALUES (:keyset_id, :amount, 0) + ON CONFLICT (keyset_id) + DO UPDATE SET total_issued = keyset_amounts.total_issued + EXCLUDED.total_issued + "#, + )? + .bind("amount", u64::from(signature.amount) as i64) + .bind("keyset_id", signature.keyset_id.to_string()) + .execute(&self.inner) + .await?; + } + Some((c, _dleq_e, _dleq_s)) => { + // Blind message exists: check if c is NULL + match c { + Column::Null => { + // Blind message with no c: Update with missing columns c, dleq_e, dleq_s + query( + r#" + UPDATE blind_signature + SET c = :c, dleq_e = :dleq_e, dleq_s = :dleq_s, signed_time = :signed_time, amount = :amount + WHERE blinded_message = :blinded_message + "#, + )? + .bind("c", signature.c.to_bytes().to_vec()) + .bind( + "dleq_e", + signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()), + ) + .bind( + "dleq_s", + signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()), + ) + .bind("blinded_message", message.to_bytes().to_vec()) + .bind("signed_time", current_time as i64) + .bind("amount", u64::from(signature.amount) as i64) + .execute(&self.inner) + .await?; + + query( + r#" + INSERT INTO keyset_amounts (keyset_id, total_issued, total_redeemed) + VALUES (:keyset_id, :amount, 0) + ON CONFLICT (keyset_id) + DO UPDATE SET total_issued = keyset_amounts.total_issued + EXCLUDED.total_issued + "#, + )? + .bind("amount", u64::from(signature.amount) as i64) + .bind("keyset_id", signature.keyset_id.to_string()) + .execute(&self.inner) + .await?; + } + _ => { + // Blind message already has c: Error + tracing::error!( + "Attempting to add signature to message already signed {}", + message + ); + + return Err(database::Error::Duplicate); + } + } + } + } + } + + debug_assert!( + existing_rows.is_empty(), + "Unexpected existing rows remain: {:?}", + existing_rows.keys().collect::>() + ); + + if !existing_rows.is_empty() { + tracing::error!("Did not check all existing rows"); + return Err(Error::Internal( + "Did not check all existing rows".to_string(), + )); + } + + Ok(()) + } + + async fn get_blind_signatures( + &mut self, + blinded_messages: &[PublicKey], + ) -> Result>, Self::Err> { + let mut blinded_signatures = query( + r#"SELECT + keyset_id, + amount, + c, + dleq_e, + dleq_s, + blinded_message + FROM + blind_signature + WHERE blinded_message IN (:b) AND c IS NOT NULL + "#, + )? + .bind_vec( + "b", + blinded_messages + .iter() + .map(|b| b.to_bytes().to_vec()) + .collect(), + ) + .fetch_all(&self.inner) + .await? + .into_iter() + .map(|mut row| { + Ok(( + column_as_string!( + &row.pop().ok_or(Error::InvalidDbResponse)?, + PublicKey::from_hex, + PublicKey::from_slice + ), + sql_row_to_blind_signature(row)?, + )) + }) + .collect::, Error>>()?; + Ok(blinded_messages + .iter() + .map(|y| blinded_signatures.remove(y)) + .collect()) + } +} + +#[async_trait] +impl MintSignaturesDatabase for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn get_blind_signatures( + &self, + blinded_messages: &[PublicKey], + ) -> Result>, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + let mut blinded_signatures = query( + r#"SELECT + keyset_id, + amount, + c, + dleq_e, + dleq_s, + blinded_message + FROM + blind_signature + WHERE blinded_message IN (:b) AND c IS NOT NULL + "#, + )? + .bind_vec( + "b", + blinded_messages + .iter() + .map(|b_| b_.to_bytes().to_vec()) + .collect(), + ) + .fetch_all(&*conn) + .await? + .into_iter() + .map(|mut row| { + Ok(( + column_as_string!( + &row.pop().ok_or(Error::InvalidDbResponse)?, + PublicKey::from_hex, + PublicKey::from_slice + ), + sql_row_to_blind_signature(row)?, + )) + }) + .collect::, Error>>()?; + Ok(blinded_messages + .iter() + .map(|y| blinded_signatures.remove(y)) + .collect()) + } + + async fn get_blind_signatures_for_keyset( + &self, + keyset_id: &Id, + ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + keyset_id, + amount, + c, + dleq_e, + dleq_s + FROM + blind_signature + WHERE + keyset_id=:keyset_id AND c IS NOT NULL + "#, + )? + .bind("keyset_id", keyset_id.to_string()) + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_blind_signature) + .collect::, _>>()?) + } + + /// Get [`BlindSignature`]s for quote + async fn get_blind_signatures_for_quote( + &self, + quote_id: &QuoteId, + ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + keyset_id, + amount, + c, + dleq_e, + dleq_s + FROM + blind_signature + WHERE + quote_id=:quote_id AND c IS NOT NULL + "#, + )? + .bind("quote_id", quote_id.to_string()) + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_blind_signature) + .collect::, _>>()?) + } + + /// Get total proofs redeemed by keyset id + async fn get_total_issued(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + query( + r#" + SELECT + keyset_id, + total_issued as amount + FROM + keyset_amounts + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_hashmap_amount) + .collect() + } +} + +#[async_trait] +impl database::MintKVStoreTransaction<'_, Error> for SQLTransaction +where + RM: DatabasePool + 'static, +{ + async fn kv_read( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Error> { + // Validate parameters according to KV store requirements + validate_kvstore_params(primary_namespace, secondary_namespace, key)?; + Ok(query( + r#" + SELECT value + FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + AND key = :key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("key", key.to_owned()) + .pluck(&self.inner) + .await? + .and_then(|col| match col { + Column::Blob(data) => Some(data), + _ => None, + })) + } + + async fn kv_write( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + value: &[u8], + ) -> Result<(), Error> { + // Validate parameters according to KV store requirements + validate_kvstore_params(primary_namespace, secondary_namespace, key)?; + + let current_time = unix_time(); + + query( + r#" + INSERT INTO kv_store + (primary_namespace, secondary_namespace, key, value, created_time, updated_time) + VALUES (:primary_namespace, :secondary_namespace, :key, :value, :created_time, :updated_time) + ON CONFLICT(primary_namespace, secondary_namespace, key) + DO UPDATE SET + value = excluded.value, + updated_time = excluded.updated_time + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("key", key.to_owned()) + .bind("value", value.to_vec()) + .bind("created_time", current_time as i64) + .bind("updated_time", current_time as i64) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn kv_remove( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result<(), Error> { + // Validate parameters according to KV store requirements + validate_kvstore_params(primary_namespace, secondary_namespace, key)?; + query( + r#" + DELETE FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + AND key = :key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("key", key.to_owned()) + .execute(&self.inner) + .await?; + + Ok(()) + } + + async fn kv_list( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + ) -> Result, Error> { + // Validate namespace parameters according to KV store requirements + cdk_common::database::mint::validate_kvstore_string(primary_namespace)?; + cdk_common::database::mint::validate_kvstore_string(secondary_namespace)?; + + // Check empty namespace rules + if primary_namespace.is_empty() && !secondary_namespace.is_empty() { + return Err(Error::KVStoreInvalidKey( + "If primary_namespace is empty, secondary_namespace must also be empty".to_string(), + )); + } + Ok(query( + r#" + SELECT key + FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + ORDER BY key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .fetch_all(&self.inner) + .await? + .into_iter() + .map(|row| Ok(column_as_string!(&row[0]))) + .collect::, Error>>()?) + } +} + +#[async_trait] +impl database::MintKVStoreDatabase for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn kv_read( + &self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Error> { + // Validate parameters according to KV store requirements + validate_kvstore_params(primary_namespace, secondary_namespace, key)?; + + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT value + FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + AND key = :key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("key", key.to_owned()) + .pluck(&*conn) + .await? + .and_then(|col| match col { + Column::Blob(data) => Some(data), + _ => None, + })) + } + + async fn kv_list( + &self, + primary_namespace: &str, + secondary_namespace: &str, + ) -> Result, Error> { + // Validate namespace parameters according to KV store requirements + cdk_common::database::mint::validate_kvstore_string(primary_namespace)?; + cdk_common::database::mint::validate_kvstore_string(secondary_namespace)?; + + // Check empty namespace rules + if primary_namespace.is_empty() && !secondary_namespace.is_empty() { + return Err(Error::KVStoreInvalidKey( + "If primary_namespace is empty, secondary_namespace must also be empty".to_string(), + )); + } + + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT key + FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + ORDER BY key + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .fetch_all(&*conn) + .await? + .into_iter() + .map(|row| Ok(column_as_string!(&row[0]))) + .collect::, Error>>()?) + } +} + +#[async_trait] +impl database::MintKVStore for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + async fn begin_transaction<'a>( + &'a self, + ) -> Result + Send + Sync + 'a>, Error> + { + Ok(Box::new(SQLTransaction { + inner: ConnectionWithTransaction::new( + self.pool.get().map_err(|e| Error::Database(Box::new(e)))?, + ) + .await?, + })) + } +} + +#[async_trait] +impl SagaTransaction<'_> for SQLTransaction +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn get_saga( + &mut self, + operation_id: &uuid::Uuid, + ) -> Result, Self::Err> { + Ok(query( + r#" + SELECT + operation_id, + operation_kind, + state, + blinded_secrets, + input_ys, + quote_id, + 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, quote_id, created_at, updated_at) + VALUES + (:operation_id, :operation_kind, :state, :blinded_secrets, :input_ys, :quote_id, :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("quote_id", saga.quote_id.as_deref()) + .bind("created_at", saga.created_at as i64) + .bind("updated_at", current_time as i64) + .execute(&self.inner) + .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 SagaDatabase for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + type Err = Error; + + async fn get_incomplete_sagas( + &self, + operation_kind: mint::OperationKind, + ) -> Result, 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, + quote_id, + 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::, _>>()?) + } +} + +#[async_trait] +impl MintDatabase for SQLMintDatabase +where + RM: DatabasePool + 'static, +{ + async fn begin_transaction<'a>( + &'a self, + ) -> Result + Send + Sync + 'a>, Error> { + let tx = SQLTransaction { + inner: ConnectionWithTransaction::new( + self.pool.get().map_err(|e| Error::Database(Box::new(e)))?, + ) + .await?, + }; + + Ok(Box::new(tx)) + } +} + +fn sql_row_to_keyset_info(row: Vec) -> Result { + unpack_into!( + let ( + id, + unit, + active, + valid_from, + valid_to, + derivation_path, + derivation_path_index, + amounts, + row_keyset_ppk + ) = row + ); + + let amounts = column_as_nullable_string!(amounts) + .and_then(|str| serde_json::from_str(&str).ok()) + .ok_or_else(|| Error::Database("amounts field is required".to_string().into()))?; + + Ok(MintKeySetInfo { + id: column_as_string!(id, Id::from_str, Id::from_bytes), + unit: column_as_string!(unit, CurrencyUnit::from_str), + active: matches!(active, Column::Integer(1)), + valid_from: column_as_number!(valid_from), + derivation_path: column_as_string!(derivation_path, DerivationPath::from_str), + derivation_path_index: column_as_nullable_number!(derivation_path_index), + amounts, + input_fee_ppk: column_as_number!(row_keyset_ppk), + final_expiry: column_as_nullable_number!(valid_to), + }) +} + +#[instrument(skip_all)] +fn sql_row_to_mint_quote( + row: Vec, + payments: Vec, + issueances: Vec, +) -> Result { + unpack_into!( + let ( + id, amount, unit, request, expiry, request_lookup_id, + pubkey, created_time, amount_paid, amount_issued, payment_method, request_lookup_id_kind + ) = row + ); + + let request_str = column_as_string!(&request); + let request_lookup_id = column_as_nullable_string!(&request_lookup_id).unwrap_or_else(|| { + Bolt11Invoice::from_str(&request_str) + .map(|invoice| invoice.payment_hash().to_string()) + .unwrap_or_else(|_| request_str.clone()) + }); + let request_lookup_id_kind = column_as_string!(request_lookup_id_kind); + + let pubkey = column_as_nullable_string!(&pubkey) + .map(|pk| PublicKey::from_hex(&pk)) + .transpose()?; + + let id = column_as_string!(id); + let amount: Option = column_as_nullable_number!(amount); + let amount_paid: u64 = column_as_number!(amount_paid); + let amount_issued: u64 = column_as_number!(amount_issued); + let payment_method = column_as_string!(payment_method, PaymentMethod::from_str); + + Ok(MintQuote::new( + Some(QuoteId::from_str(&id)?), + request_str, + column_as_string!(unit, CurrencyUnit::from_str), + amount.map(Amount::from), + column_as_number!(expiry), + PaymentIdentifier::new(&request_lookup_id_kind, &request_lookup_id) + .map_err(|_| ConversionError::MissingParameter("Payment id".to_string()))?, + pubkey, + amount_paid.into(), + amount_issued.into(), + payment_method, + column_as_number!(created_time), + payments, + issueances, + )) +} + +fn sql_row_to_melt_quote(row: Vec) -> Result { + unpack_into!( + let ( + id, + unit, + amount, + request, + fee_reserve, + expiry, + state, + payment_preimage, + request_lookup_id, + created_time, + paid_time, + payment_method, + options, + request_lookup_id_kind + ) = row + ); + + let id = column_as_string!(id); + let amount: u64 = column_as_number!(amount); + let fee_reserve: u64 = column_as_number!(fee_reserve); + + let expiry = column_as_number!(expiry); + let payment_preimage = column_as_nullable_string!(payment_preimage); + let options = column_as_nullable_string!(options); + let options = options.and_then(|o| serde_json::from_str(&o).ok()); + let created_time: i64 = column_as_number!(created_time); + let paid_time = column_as_nullable_number!(paid_time); + let payment_method = PaymentMethod::from_str(&column_as_string!(payment_method))?; + + let state = + MeltQuoteState::from_str(&column_as_string!(&state)).map_err(ConversionError::from)?; + + let unit = column_as_string!(unit); + let request = column_as_string!(request); + + let request_lookup_id_kind = column_as_nullable_string!(request_lookup_id_kind); + + let request_lookup_id = column_as_nullable_string!(&request_lookup_id).or_else(|| { + Bolt11Invoice::from_str(&request) + .ok() + .map(|invoice| invoice.payment_hash().to_string()) + }); + + let request_lookup_id = if let (Some(id_kind), Some(request_lookup_id)) = + (request_lookup_id_kind, request_lookup_id) + { + Some( + PaymentIdentifier::new(&id_kind, &request_lookup_id) + .map_err(|_| ConversionError::MissingParameter("Payment id".to_string()))?, + ) + } else { + None + }; + + let request = match serde_json::from_str(&request) { + Ok(req) => req, + Err(err) => { + tracing::debug!( + "Melt quote from pre migrations defaulting to bolt11 {}.", + err + ); + let bolt11 = Bolt11Invoice::from_str(&request).unwrap(); + MeltPaymentRequest::Bolt11 { bolt11 } + } + }; + + Ok(MeltQuote { + id: QuoteId::from_str(&id)?, + unit: CurrencyUnit::from_str(&unit)?, + amount: Amount::from(amount), + request, + fee_reserve: Amount::from(fee_reserve), + state, + expiry, + payment_preimage, + request_lookup_id, + options, + created_time: created_time as u64, + paid_time, + payment_method, + }) +} + +fn sql_row_to_proof(row: Vec) -> Result { + unpack_into!( + let ( + amount, + keyset_id, + secret, + c, + witness + ) = row + ); + + let amount: u64 = column_as_number!(amount); + Ok(Proof { + amount: Amount::from(amount), + keyset_id: column_as_string!(keyset_id, Id::from_str), + secret: column_as_string!(secret, Secret::from_str), + c: column_as_string!(c, PublicKey::from_hex, PublicKey::from_slice), + witness: column_as_nullable_string!(witness).and_then(|w| serde_json::from_str(&w).ok()), + dleq: None, + }) +} + +fn sql_row_to_hashmap_amount(row: Vec) -> Result<(Id, Amount), Error> { + unpack_into!( + let ( + keyset_id, amount + ) = row + ); + + let amount: u64 = column_as_number!(amount); + Ok(( + column_as_string!(keyset_id, Id::from_str, Id::from_bytes), + Amount::from(amount), + )) +} + +fn sql_row_to_proof_with_state(row: Vec) -> Result<(Proof, Option), Error> { + unpack_into!( + let ( + keyset_id, amount, secret, c, witness, state + ) = row + ); + + let amount: u64 = column_as_number!(amount); + let state = column_as_nullable_string!(state).and_then(|s| State::from_str(&s).ok()); + + Ok(( + Proof { + amount: Amount::from(amount), + keyset_id: column_as_string!(keyset_id, Id::from_str, Id::from_bytes), + secret: column_as_string!(secret, Secret::from_str), + c: column_as_string!(c, PublicKey::from_hex, PublicKey::from_slice), + witness: column_as_nullable_string!(witness) + .and_then(|w| serde_json::from_str(&w).ok()), + dleq: None, + }, + state, + )) +} + +fn sql_row_to_blind_signature(row: Vec) -> Result { + unpack_into!( + let ( + keyset_id, amount, c, dleq_e, dleq_s + ) = row + ); + + let dleq = match ( + column_as_nullable_string!(dleq_e), + column_as_nullable_string!(dleq_s), + ) { + (Some(e), Some(s)) => Some(BlindSignatureDleq { + e: SecretKey::from_hex(e)?, + s: SecretKey::from_hex(s)?, + }), + _ => None, + }; + + let amount: u64 = column_as_number!(amount); + + Ok(BlindSignature { + amount: Amount::from(amount), + keyset_id: column_as_string!(keyset_id, Id::from_str, Id::from_bytes), + c: column_as_string!(c, PublicKey::from_hex, PublicKey::from_slice), + dleq, + }) +} + +fn sql_row_to_saga(row: Vec) -> Result { + unpack_into!( + let ( + operation_id, + operation_kind, + state, + blinded_secrets, + input_ys, + quote_id, + 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 = 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 = serde_json::from_str(&input_ys_str) + .map_err(|e| Error::Internal(format!("Failed to deserialize input_ys: {e}")))?; + + let quote_id = match "e_id { + Column::Text(s) => { + if s.is_empty() { + None + } else { + Some(s.clone()) + } + } + Column::Null => None, + _ => None, + }; + + let created_at: u64 = column_as_number!(created_at); + let updated_at: u64 = column_as_number!(updated_at); + + Ok(mint::Saga { + operation_id, + operation_kind, + state, + blinded_secrets, + input_ys, + quote_id, + created_at, + updated_at, + }) +} + +#[cfg(test)] +mod test { + use super::*; + + mod keyset_amounts_tests { + use super::*; + + #[test] + fn keyset_with_amounts() { + let amounts = (0..32).map(|x| 2u64.pow(x)).collect::>(); + let result = sql_row_to_keyset_info(vec![ + Column::Text("0083a60439303340".to_owned()), + Column::Text("sat".to_owned()), + Column::Integer(1), + Column::Integer(1749844864), + Column::Null, + Column::Text("0'/0'/0'".to_owned()), + Column::Integer(0), + Column::Text(serde_json::to_string(&amounts).expect("valid json")), + Column::Integer(0), + ]); + assert!(result.is_ok()); + let keyset = result.unwrap(); + assert_eq!(keyset.amounts.len(), 32); + } + } +} diff --git a/crates/cdk-sql-common/src/pool.rs b/crates/cdk-sql-common/src/pool.rs new file mode 100644 index 000000000..451772697 --- /dev/null +++ b/crates/cdk-sql-common/src/pool.rs @@ -0,0 +1,266 @@ +//! Very simple connection pool, to avoid an external dependency on r2d2 and other crates. If this +//! endup work it can be re-used in other parts of the project and may be promoted to its own +//! generic crate + +use std::fmt::Debug; +use std::ops::{Deref, DerefMut}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +#[cfg(feature = "prometheus")] +use cdk_prometheus::metrics::METRICS; + +use crate::database::DatabaseConnector; + +/// Pool error +#[derive(Debug, thiserror::Error)] +pub enum Error +where + E: std::error::Error + Send + Sync + 'static, +{ + /// Mutex Poison Error + #[error("Internal: PoisonError")] + Poison, + + /// Timeout error + #[error("Timed out waiting for a resource")] + Timeout, + + /// Internal database error + #[error(transparent)] + Resource(#[from] E), +} + +/// Configuration +pub trait DatabaseConfig: Clone + Debug + Send + Sync { + /// Max resource sizes + fn max_size(&self) -> usize; + + /// Default timeout + fn default_timeout(&self) -> Duration; +} + +/// Trait to manage resources +pub trait DatabasePool: Debug { + /// The resource to be pooled + type Connection: DatabaseConnector; + + /// The configuration that is needed in order to create the resource + type Config: DatabaseConfig; + + /// The error the resource may return when creating a new instance + type Error: Debug + std::error::Error + Send + Sync + 'static; + + /// Creates a new resource with a given config. + /// + /// If `stale` is ever set to TRUE it is assumed the resource is no longer valid and it will be + /// dropped. + fn new_resource( + config: &Self::Config, + stale: Arc, + timeout: Duration, + ) -> Result>; + + /// The object is dropped + fn drop(_resource: Self::Connection) {} +} + +/// Generic connection pool of resources R +#[derive(Debug)] +pub struct Pool +where + RM: DatabasePool, +{ + config: RM::Config, + queue: Mutex, RM::Connection)>>, + in_use: AtomicUsize, + max_size: usize, + default_timeout: Duration, + waiter: Condvar, +} + +/// The pooled resource +pub struct PooledResource +where + RM: DatabasePool, +{ + resource: Option<(Arc, RM::Connection)>, + pool: Arc>, + #[cfg(feature = "prometheus")] + start_time: Instant, +} + +impl Debug for PooledResource +where + RM: DatabasePool, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Resource: {:?}", self.resource) + } +} + +impl Drop for PooledResource +where + RM: DatabasePool, +{ + fn drop(&mut self) { + if let Some(resource) = self.resource.take() { + let mut active_resource = self.pool.queue.lock().expect("active_resource"); + active_resource.push(resource); + let _in_use = self.pool.in_use.fetch_sub(1, Ordering::AcqRel); + + #[cfg(feature = "prometheus")] + { + METRICS.set_db_connections_active(_in_use as i64); + + let duration = self.start_time.elapsed().as_secs_f64(); + + METRICS.record_db_operation(duration, "drop"); + } + + // Notify a waiting thread + self.pool.waiter.notify_one(); + } + } +} + +impl Deref for PooledResource +where + RM: DatabasePool, +{ + type Target = RM::Connection; + + fn deref(&self) -> &Self::Target { + &self.resource.as_ref().expect("resource already dropped").1 + } +} + +impl DerefMut for PooledResource +where + RM: DatabasePool, +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.resource.as_mut().expect("resource already dropped").1 + } +} + +impl Pool +where + RM: DatabasePool, +{ + /// Creates a new pool + pub fn new(config: RM::Config) -> Arc { + Arc::new(Self { + default_timeout: config.default_timeout(), + max_size: config.max_size(), + config, + queue: Default::default(), + in_use: Default::default(), + waiter: Default::default(), + }) + } + + /// Similar to get_timeout but uses the default timeout value. + #[inline(always)] + pub fn get(self: &Arc) -> Result, Error> { + self.get_timeout(self.default_timeout) + } + + /// Increments the in_use connection counter and updates the metric + fn increment_connection_counter(&self) -> usize { + let in_use = self.in_use.fetch_add(1, Ordering::AcqRel); + + #[cfg(feature = "prometheus")] + { + METRICS.set_db_connections_active(in_use as i64); + } + + in_use + } + + /// Get a new resource or fail after timeout is reached. + /// + /// This function will return a free resource or create a new one if there is still room for it; + /// otherwise, it will wait for a resource to be released for reuse. + #[inline(always)] + pub fn get_timeout( + self: &Arc, + timeout: Duration, + ) -> Result, Error> { + let mut resources = self.queue.lock().map_err(|_| Error::Poison)?; + let time = Instant::now(); + + loop { + if let Some((stale, resource)) = resources.pop() { + if !stale.load(Ordering::SeqCst) { + drop(resources); + self.increment_connection_counter(); + + return Ok(PooledResource { + resource: Some((stale, resource)), + pool: self.clone(), + #[cfg(feature = "prometheus")] + start_time: Instant::now(), + }); + } + } + + if self.in_use.load(Ordering::Relaxed) < self.max_size { + drop(resources); + let stale: Arc = Arc::new(false.into()); + let new_resource = RM::new_resource(&self.config, stale.clone(), timeout)?; + self.increment_connection_counter(); + + return Ok(PooledResource { + resource: Some((stale, new_resource)), + pool: self.clone(), + #[cfg(feature = "prometheus")] + start_time: Instant::now(), + }); + } + + resources = self + .waiter + .wait_timeout(resources, timeout) + .map_err(|_| Error::Poison) + .and_then(|(lock, timeout_result)| { + if timeout_result.timed_out() { + tracing::warn!( + "Timeout waiting for the resource (pool size: {}). Waited {} ms", + self.max_size, + time.elapsed().as_millis() + ); + Err(Error::Timeout) + } else { + Ok(lock) + } + })?; + } + } +} + +impl Drop for Pool +where + RM: DatabasePool, +{ + fn drop(&mut self) { + if let Ok(mut resources) = self.queue.lock() { + loop { + while let Some(resource) = resources.pop() { + RM::drop(resource.1); + } + + if self.in_use.load(Ordering::Relaxed) == 0 { + break; + } + + resources = if let Ok(resources) = self.waiter.wait(resources) { + resources + } else { + break; + }; + } + } + } +} diff --git a/crates/cdk-sql-common/src/stmt.rs b/crates/cdk-sql-common/src/stmt.rs new file mode 100644 index 000000000..6ad1d0947 --- /dev/null +++ b/crates/cdk-sql-common/src/stmt.rs @@ -0,0 +1,371 @@ +//! Stataments mod +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use cdk_common::database::Error; +use once_cell::sync::Lazy; + +use crate::database::DatabaseExecutor; +use crate::value::Value; + +/// The Column type +pub type Column = Value; + +/// Expected response type for a given SQL statement +#[derive(Debug, Clone, Copy, Default)] +pub enum ExpectedSqlResponse { + /// A single row + SingleRow, + /// All the rows that matches a query + #[default] + ManyRows, + /// How many rows were affected by the query + AffectedRows, + /// Return the first column of the first row + Pluck, + /// Batch + Batch, +} + +/// Part value +#[derive(Debug, Clone)] +pub enum PlaceholderValue { + /// Value + Value(Value), + /// Set + Set(Vec), +} + +impl From for PlaceholderValue { + fn from(value: Value) -> Self { + PlaceholderValue::Value(value) + } +} + +impl From> for PlaceholderValue { + fn from(value: Vec) -> Self { + PlaceholderValue::Set(value) + } +} + +/// SQL Part +#[derive(Debug, Clone)] +pub enum SqlPart { + /// Raw SQL statement + Raw(Arc), + /// Placeholder + Placeholder(Arc, Option), +} + +/// SQL parser error +#[derive(Debug, PartialEq, thiserror::Error)] +pub enum SqlParseError { + /// Invalid SQL + #[error("Unterminated String literal")] + UnterminatedStringLiteral, + /// Invalid placeholder name + #[error("Invalid placeholder name")] + InvalidPlaceholder, +} + +/// Rudimentary SQL parser. +/// +/// This function does not validate the SQL statement, it only extracts the placeholder to be +/// database agnostic. +pub fn split_sql_parts(input: &str) -> Result, SqlParseError> { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars().peekable(); + + while let Some(&c) = chars.peek() { + match c { + '\'' | '"' => { + // Start of string literal + let quote = c; + current.push(chars.next().unwrap()); + + let mut closed = false; + while let Some(&next) = chars.peek() { + current.push(chars.next().unwrap()); + + if next == quote { + if chars.peek() == Some("e) { + // Escaped quote (e.g. '' inside strings) + current.push(chars.next().unwrap()); + } else { + closed = true; + break; + } + } + } + + if !closed { + return Err(SqlParseError::UnterminatedStringLiteral); + } + } + + '-' => { + current.push(chars.next().unwrap()); + if chars.peek() == Some(&'-') { + while let Some(&next) = chars.peek() { + current.push(chars.next().unwrap()); + if next == '\n' { + break; + } + } + } + } + + ':' => { + // Flush current raw SQL + if !current.is_empty() { + parts.push(SqlPart::Raw(current.clone().into())); + current.clear(); + } + + chars.next(); // consume ':' + let mut name = String::new(); + + while let Some(&next) = chars.peek() { + if next.is_alphanumeric() || next == '_' { + name.push(chars.next().unwrap()); + } else { + break; + } + } + + if name.is_empty() { + return Err(SqlParseError::InvalidPlaceholder); + } + + parts.push(SqlPart::Placeholder(name.into(), None)); + } + + _ => { + current.push(chars.next().unwrap()); + } + } + } + + if !current.is_empty() { + parts.push(SqlPart::Raw(current.into())); + } + + Ok(parts) +} + +type Cache = HashMap, Option>)>; + +/// Sql message +#[derive(Debug, Default)] +pub struct Statement { + cache: Arc>, + cached_sql: Option>, + sql: Option, + /// The SQL statement + pub parts: Vec, + /// The expected response type + pub expected_response: ExpectedSqlResponse, +} + +impl Statement { + /// Creates a new statement + fn new(sql: &str, cache: Arc>) -> Result { + let parsed = cache + .read() + .map(|cache| cache.get(sql).cloned()) + .ok() + .flatten(); + + if let Some((parts, cached_sql)) = parsed { + Ok(Self { + parts, + cached_sql, + sql: None, + cache, + ..Default::default() + }) + } else { + let parts = split_sql_parts(sql)?; + + if let Ok(mut cache) = cache.write() { + cache.insert(sql.to_owned(), (parts.clone(), None)); + } else { + tracing::warn!("Failed to acquire write lock for SQL statement cache"); + } + + Ok(Self { + parts, + sql: Some(sql.to_owned()), + cache, + ..Default::default() + }) + } + } + + /// Convert Statement into a SQL statement and the list of placeholders + /// + /// By default it converts the statement into placeholder using $1..$n placeholders which seems + /// to be more widely supported, although it can be reimplemented with other formats since part + /// is public + pub fn to_sql(self) -> Result<(String, Vec), Error> { + if let Some(cached_sql) = self.cached_sql { + let sql = cached_sql.to_string(); + let values = self + .parts + .into_iter() + .map(|x| match x { + SqlPart::Placeholder(name, value) => { + match value.ok_or(Error::MissingPlaceholder(name.to_string()))? { + PlaceholderValue::Value(value) => Ok(vec![value]), + PlaceholderValue::Set(values) => Ok(values), + } + } + SqlPart::Raw(_) => Ok(vec![]), + }) + .collect::, Error>>()? + .into_iter() + .flatten() + .collect::>(); + return Ok((sql, values)); + } + + let mut placeholder_values = Vec::new(); + let mut can_be_cached = true; + let sql = self + .parts + .into_iter() + .map(|x| match x { + SqlPart::Placeholder(name, value) => { + match value.ok_or(Error::MissingPlaceholder(name.to_string()))? { + PlaceholderValue::Value(value) => { + placeholder_values.push(value); + Ok::<_, Error>(format!("${}", placeholder_values.len())) + } + PlaceholderValue::Set(mut values) => { + can_be_cached = false; + let start_size = placeholder_values.len(); + placeholder_values.append(&mut values); + let placeholders = (start_size + 1..=placeholder_values.len()) + .map(|i| format!("${i}")) + .collect::>() + .join(", "); + Ok(placeholders) + } + } + } + SqlPart::Raw(raw) => Ok(raw.trim().to_string()), + }) + .collect::, _>>()? + .join(" "); + + if can_be_cached { + if let Some(original_sql) = self.sql { + let _ = self.cache.write().map(|mut cache| { + if let Some((_, cached_sql)) = cache.get_mut(&original_sql) { + *cached_sql = Some(sql.clone().into()); + } + }); + } + } + + Ok((sql, placeholder_values)) + } + + /// Binds a given placeholder to a value. + #[inline] + pub fn bind(mut self, name: C, value: V) -> Self + where + C: ToString, + V: Into, + { + let name = name.to_string(); + let value = value.into(); + let value: PlaceholderValue = value.into(); + + for part in self.parts.iter_mut() { + if let SqlPart::Placeholder(part_name, part_value) = part { + if **part_name == *name.as_str() { + *part_value = Some(value.clone()); + } + } + } + + self + } + + /// Binds a single variable with a vector. + /// + /// This will rewrite the function from `:foo` (where value is vec![1, 2, 3]) to `:foo0, :foo1, + /// :foo2` and binds each value from the value vector accordingly. + #[inline] + pub fn bind_vec(mut self, name: C, value: Vec) -> Self + where + C: ToString, + V: Into, + { + let name = name.to_string(); + let value: PlaceholderValue = value + .into_iter() + .map(|x| x.into()) + .collect::>() + .into(); + + for part in self.parts.iter_mut() { + if let SqlPart::Placeholder(part_name, part_value) = part { + if **part_name == *name.as_str() { + *part_value = Some(value.clone()); + } + } + } + + self + } + + /// Executes a query and returns the affected rows + pub async fn pluck(self, conn: &C) -> Result, Error> + where + C: DatabaseExecutor, + { + conn.pluck(self).await + } + + /// Executes a query and returns the affected rows + pub async fn batch(self, conn: &C) -> Result<(), Error> + where + C: DatabaseExecutor, + { + conn.batch(self).await + } + + /// Executes a query and returns the affected rows + pub async fn execute(self, conn: &C) -> Result + where + C: DatabaseExecutor, + { + conn.execute(self).await + } + + /// Runs the query and returns the first row or None + pub async fn fetch_one(self, conn: &C) -> Result>, Error> + where + C: DatabaseExecutor, + { + conn.fetch_one(self).await + } + + /// Runs the query and returns the first row or None + pub async fn fetch_all(self, conn: &C) -> Result>, Error> + where + C: DatabaseExecutor, + { + conn.fetch_all(self).await + } +} + +/// Creates a new query statement +#[inline(always)] +pub fn query(sql: &str) -> Result { + static CACHE: Lazy>> = Lazy::new(|| Arc::new(RwLock::new(HashMap::new()))); + Statement::new(sql, CACHE.clone()).map_err(|e| Error::Database(Box::new(e))) +} diff --git a/crates/cdk-sql-common/src/value.rs b/crates/cdk-sql-common/src/value.rs new file mode 100644 index 000000000..4fd0e77ee --- /dev/null +++ b/crates/cdk-sql-common/src/value.rs @@ -0,0 +1,82 @@ +//! Generic Rust value representation for data from the database + +/// Generic Value representation of data from the any database +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + /// The value is a `NULL` value. + Null, + /// The value is a signed integer. + Integer(i64), + /// The value is a floating point number. + Real(f64), + /// The value is a text string. + Text(String), + /// The value is a blob of data + Blob(Vec), +} + +impl From for Value { + fn from(value: String) -> Self { + Self::Text(value) + } +} + +impl From<&str> for Value { + fn from(value: &str) -> Self { + Self::Text(value.to_owned()) + } +} + +impl From<&&str> for Value { + fn from(value: &&str) -> Self { + Self::Text(value.to_string()) + } +} + +impl From> for Value { + fn from(value: Vec) -> Self { + Self::Blob(value) + } +} + +impl From<&[u8]> for Value { + fn from(value: &[u8]) -> Self { + Self::Blob(value.to_owned()) + } +} + +impl From for Value { + fn from(value: u8) -> Self { + Self::Integer(value.into()) + } +} + +impl From for Value { + fn from(value: i64) -> Self { + Self::Integer(value) + } +} + +impl From for Value { + fn from(value: u32) -> Self { + Self::Integer(value.into()) + } +} + +impl From for Value { + fn from(value: bool) -> Self { + Self::Integer(if value { 1 } else { 0 }) + } +} + +impl From> for Value +where + T: Into, +{ + fn from(value: Option) -> Self { + match value { + Some(v) => v.into(), + None => Value::Null, + } + } +} diff --git a/crates/cdk-sqlite/src/wallet/error.rs b/crates/cdk-sql-common/src/wallet/error.rs similarity index 98% rename from crates/cdk-sqlite/src/wallet/error.rs rename to crates/cdk-sql-common/src/wallet/error.rs index e0886c27b..8c5ae4c55 100644 --- a/crates/cdk-sqlite/src/wallet/error.rs +++ b/crates/cdk-sql-common/src/wallet/error.rs @@ -2,7 +2,7 @@ use thiserror::Error; -/// SQLite Wallet Error +/// SQL Wallet Error #[derive(Debug, Error)] pub enum Error { /// SQLX Error diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/1_initial.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/1_initial.sql new file mode 100644 index 000000000..04fc659ed --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/1_initial.sql @@ -0,0 +1,80 @@ +CREATE TABLE mint ( + mint_url TEXT PRIMARY KEY, name TEXT, + pubkey BYTEA, version TEXT, description TEXT, + description_long TEXT, contact TEXT, + nuts TEXT, motd TEXT, icon_url TEXT, + mint_time INTEGER, urls TEXT, tos_url TEXT +); +CREATE TABLE keyset ( + id TEXT PRIMARY KEY, + mint_url TEXT NOT NULL, + unit TEXT NOT NULL, + active BOOL NOT NULL, + counter INTEGER NOT NULL DEFAULT 0, + input_fee_ppk INTEGER, + final_expiry INTEGER DEFAULT NULL, + FOREIGN KEY(mint_url) REFERENCES mint(mint_url) ON UPDATE CASCADE ON DELETE CASCADE +); +CREATE TABLE melt_quote ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + request TEXT NOT NULL, + fee_reserve INTEGER NOT NULL, + expiry INTEGER NOT NULL, + state TEXT CHECK ( + state IN ('UNPAID', 'PENDING', 'PAID') + ) NOT NULL DEFAULT 'UNPAID', + payment_preimage TEXT +); +CREATE TABLE key ( + id TEXT PRIMARY KEY, keys TEXT NOT NULL +); +CREATE INDEX melt_quote_state_index ON melt_quote(state); +CREATE TABLE IF NOT EXISTS "proof" ( + y BYTEA PRIMARY KEY, + mint_url TEXT NOT NULL, + state TEXT CHECK ( + state IN ( + 'SPENT', 'UNSPENT', 'PENDING', 'RESERVED', + 'PENDING_SPENT' + ) + ) NOT NULL, + spending_condition TEXT, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + secret TEXT NOT NULL, + c BYTEA NOT NULL, + witness TEXT, + dleq_e BYTEA, + dleq_s BYTEA, + dleq_r BYTEA +); +CREATE TABLE transactions ( + id BYTEA PRIMARY KEY, + mint_url TEXT NOT NULL, + direction TEXT CHECK ( + direction IN ('Incoming', 'Outgoing') + ) NOT NULL, + amount INTEGER NOT NULL, + fee INTEGER NOT NULL, + unit TEXT NOT NULL, + ys BYTEA NOT NULL, + timestamp INTEGER NOT NULL, + memo TEXT, + metadata TEXT +); +CREATE INDEX mint_url_index ON transactions(mint_url); +CREATE INDEX direction_index ON transactions(direction); +CREATE INDEX unit_index ON transactions(unit); +CREATE INDEX timestamp_index ON transactions(timestamp); +CREATE TABLE IF NOT EXISTS "mint_quote" ( + id TEXT PRIMARY KEY, mint_url TEXT NOT NULL, + payment_method TEXT NOT NULL DEFAULT 'bolt11', + amount INTEGER, unit TEXT NOT NULL, + request TEXT NOT NULL, state TEXT NOT NULL, + expiry INTEGER NOT NULL, amount_paid INTEGER NOT NULL DEFAULT 0, + amount_issued INTEGER NOT NULL DEFAULT 0, + secret_key TEXT +); diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql new file mode 100644 index 000000000..2192f415e --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250729111701_keyset_v2_u32.sql @@ -0,0 +1,11 @@ +-- Add u32 representation column to key table with unique constraint +ALTER TABLE key ADD COLUMN keyset_u32 INTEGER; + +-- Add unique constraint on the new column +CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique ON key(keyset_u32); + +-- Add u32 representation column to keyset table with unique constraint +ALTER TABLE keyset ADD COLUMN keyset_u32 INTEGER; + +-- Add unique constraint on the new column +CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique_keyset ON keyset(keyset_u32); diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20250831215438_melt_quote_method.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250831215438_melt_quote_method.sql new file mode 100644 index 000000000..effd9d258 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250831215438_melt_quote_method.sql @@ -0,0 +1 @@ +ALTER TABLE melt_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'bolt11'; diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20250906200000_add_transaction_quote_id.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250906200000_add_transaction_quote_id.sql new file mode 100644 index 000000000..8d9fb71c6 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20250906200000_add_transaction_quote_id.sql @@ -0,0 +1 @@ +ALTER TABLE transactions ADD COLUMN quote_id TEXT; diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20251005120000_add_payment_info_to_transactions.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20251005120000_add_payment_info_to_transactions.sql new file mode 100644 index 000000000..1b3b933de --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20251005120000_add_payment_info_to_transactions.sql @@ -0,0 +1,3 @@ +-- Add payment_request and payment_proof to transactions table +ALTER TABLE transactions ADD COLUMN payment_request TEXT; +ALTER TABLE transactions ADD COLUMN payment_proof TEXT; diff --git a/crates/cdk-sql-common/src/wallet/migrations/postgres/20251111000000_keyset_counter_table.sql b/crates/cdk-sql-common/src/wallet/migrations/postgres/20251111000000_keyset_counter_table.sql new file mode 100644 index 000000000..d1fce3a55 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/postgres/20251111000000_keyset_counter_table.sql @@ -0,0 +1,15 @@ +-- Create dedicated keyset_counter table without foreign keys +-- This table tracks the counter for each keyset independently +CREATE TABLE IF NOT EXISTS keyset_counter ( + keyset_id TEXT PRIMARY KEY, + counter INTEGER NOT NULL DEFAULT 0 +); + +-- Migrate existing counter values from keyset table +INSERT INTO keyset_counter (keyset_id, counter) +SELECT id, counter +FROM keyset +WHERE counter > 0; + +-- Drop the counter column from keyset table +ALTER TABLE keyset DROP COLUMN counter; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/1_fix_sqlx_migration.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/1_fix_sqlx_migration.sql new file mode 100644 index 000000000..9f7a0d828 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/1_fix_sqlx_migration.sql @@ -0,0 +1,20 @@ +-- Migrate `_sqlx_migrations` to our new migration system +CREATE TABLE IF NOT EXISTS _sqlx_migrations AS +SELECT + '' AS version, + '' AS description, + 0 AS execution_time +WHERE 0; + +INSERT INTO migrations +SELECT + version || '_' || REPLACE(description, ' ', '_') || '.sql', + execution_time +FROM _sqlx_migrations +WHERE EXISTS ( + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = '_sqlx_migrations' +); + +DROP TABLE _sqlx_migrations; diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240612132920_init.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20240612132920_init.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20240612132920_init.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20240612132920_init.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240618200350_quote_state.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20240618200350_quote_state.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20240618200350_quote_state.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20240618200350_quote_state.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240626091921_nut04_state.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20240626091921_nut04_state.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20240626091921_nut04_state.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20240626091921_nut04_state.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240710144711_input_fee.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20240710144711_input_fee.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20240710144711_input_fee.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20240710144711_input_fee.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240810214105_mint_icon_url.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20240810214105_mint_icon_url.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20240810214105_mint_icon_url.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20240810214105_mint_icon_url.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240810233905_update_mint_url.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20240810233905_update_mint_url.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20240810233905_update_mint_url.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20240810233905_update_mint_url.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240902151515_icon_url.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20240902151515_icon_url.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20240902151515_icon_url.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20240902151515_icon_url.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20240902210905_mint_time.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20240902210905_mint_time.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20240902210905_mint_time.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20240902210905_mint_time.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20241011125207_mint_urls.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20241011125207_mint_urls.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20241011125207_mint_urls.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20241011125207_mint_urls.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20241108092756_wallet_mint_quote_secretkey.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20241108092756_wallet_mint_quote_secretkey.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20241108092756_wallet_mint_quote_secretkey.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20241108092756_wallet_mint_quote_secretkey.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250214135017_mint_tos.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250214135017_mint_tos.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20250214135017_mint_tos.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20250214135017_mint_tos.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250310111513_drop_nostr_last_checked.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250310111513_drop_nostr_last_checked.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20250310111513_drop_nostr_last_checked.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20250310111513_drop_nostr_last_checked.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250314082116_allow_pending_spent.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250314082116_allow_pending_spent.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20250314082116_allow_pending_spent.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20250314082116_allow_pending_spent.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250323152040_wallet_dleq_proofs.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250323152040_wallet_dleq_proofs.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20250323152040_wallet_dleq_proofs.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20250323152040_wallet_dleq_proofs.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250401120000_add_transactions_table.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250401120000_add_transactions_table.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20250401120000_add_transactions_table.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20250401120000_add_transactions_table.sql diff --git a/crates/cdk-sqlite/src/wallet/migrations/20250616144830_add_keyset_expiry.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250616144830_add_keyset_expiry.sql similarity index 100% rename from crates/cdk-sqlite/src/wallet/migrations/20250616144830_add_keyset_expiry.sql rename to crates/cdk-sql-common/src/wallet/migrations/sqlite/20250616144830_add_keyset_expiry.sql diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250707093445_bolt12.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250707093445_bolt12.sql new file mode 100644 index 000000000..c191ee593 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250707093445_bolt12.sql @@ -0,0 +1,58 @@ +ALTER TABLE mint_quote ADD COLUMN amount_paid INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mint_quote ADD COLUMN amount_minted INTEGER NOT NULL DEFAULT 0; +ALTER TABLE mint_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'BOLT11'; + +-- Remove NOT NULL constraint from amount column +PRAGMA foreign_keys=off; +CREATE TABLE mint_quote_new ( + id TEXT PRIMARY KEY, + mint_url TEXT NOT NULL, + payment_method TEXT NOT NULL DEFAULT 'bolt11', + amount INTEGER, + unit TEXT NOT NULL, + request TEXT NOT NULL, + state TEXT NOT NULL, + expiry INTEGER NOT NULL, + amount_paid INTEGER NOT NULL DEFAULT 0, + amount_issued INTEGER NOT NULL DEFAULT 0, + secret_key TEXT +); + +-- Explicitly specify columns for proper mapping +INSERT INTO mint_quote_new ( + id, + mint_url, + payment_method, + amount, + unit, + request, + state, + expiry, + amount_paid, + amount_issued, + secret_key +) +SELECT + id, + mint_url, + 'bolt11', -- Default value for the new payment_method column + amount, + unit, + request, + state, + expiry, + 0, -- Default value for amount_paid + 0, -- Default value for amount_minted + secret_key +FROM mint_quote; + +DROP TABLE mint_quote; +ALTER TABLE mint_quote_new RENAME TO mint_quote; +PRAGMA foreign_keys=on; + + +-- Set amount_paid equal to amount for quotes with PAID or ISSUED state +UPDATE mint_quote SET amount_paid = amount WHERE state = 'PAID' OR state = 'ISSUED'; + +-- Set amount_issued equal to amount for quotes with ISSUED state +UPDATE mint_quote SET amount_issued = amount WHERE state = 'ISSUED'; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250729111701_keyset_v2_u32.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250729111701_keyset_v2_u32.sql new file mode 100644 index 000000000..2192f415e --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250729111701_keyset_v2_u32.sql @@ -0,0 +1,11 @@ +-- Add u32 representation column to key table with unique constraint +ALTER TABLE key ADD COLUMN keyset_u32 INTEGER; + +-- Add unique constraint on the new column +CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique ON key(keyset_u32); + +-- Add u32 representation column to keyset table with unique constraint +ALTER TABLE keyset ADD COLUMN keyset_u32 INTEGER; + +-- Add unique constraint on the new column +CREATE UNIQUE INDEX IF NOT EXISTS keyset_u32_unique_keyset ON keyset(keyset_u32); diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250812084621_keyset_plus_one.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250812084621_keyset_plus_one.sql new file mode 100644 index 000000000..bd980b661 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250812084621_keyset_plus_one.sql @@ -0,0 +1,2 @@ +-- Increment keyset counter by 1 where counter > 0 +UPDATE keyset SET counter = counter + 1 WHERE counter > 0; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250831215438_melt_quote_method.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250831215438_melt_quote_method.sql new file mode 100644 index 000000000..effd9d258 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250831215438_melt_quote_method.sql @@ -0,0 +1 @@ +ALTER TABLE melt_quote ADD COLUMN payment_method TEXT NOT NULL DEFAULT 'bolt11'; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250906200000_add_transaction_quote_id.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250906200000_add_transaction_quote_id.sql new file mode 100644 index 000000000..8d9fb71c6 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20250906200000_add_transaction_quote_id.sql @@ -0,0 +1 @@ +ALTER TABLE transactions ADD COLUMN quote_id TEXT; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20251005120000_add_payment_info_to_transactions.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20251005120000_add_payment_info_to_transactions.sql new file mode 100644 index 000000000..1b3b933de --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20251005120000_add_payment_info_to_transactions.sql @@ -0,0 +1,3 @@ +-- Add payment_request and payment_proof to transactions table +ALTER TABLE transactions ADD COLUMN payment_request TEXT; +ALTER TABLE transactions ADD COLUMN payment_proof TEXT; diff --git a/crates/cdk-sql-common/src/wallet/migrations/sqlite/20251111000000_keyset_counter_table.sql b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20251111000000_keyset_counter_table.sql new file mode 100644 index 000000000..1a29dcf1c --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/migrations/sqlite/20251111000000_keyset_counter_table.sql @@ -0,0 +1,36 @@ +-- Create dedicated keyset_counter table without foreign keys +-- This table tracks the counter for each keyset independently +CREATE TABLE IF NOT EXISTS keyset_counter ( + keyset_id TEXT PRIMARY KEY, + counter INTEGER NOT NULL DEFAULT 0 +); + +-- Migrate existing counter values from keyset table +INSERT INTO keyset_counter (keyset_id, counter) +SELECT id, counter +FROM keyset +WHERE counter > 0; + +-- Drop the counter column from keyset table (SQLite requires table recreation) +-- Step 1: Create new keyset table without counter column +CREATE TABLE keyset_new ( + id TEXT PRIMARY KEY, + mint_url TEXT NOT NULL, + keyset_u32 INTEGER, + unit TEXT NOT NULL, + active BOOL NOT NULL, + input_fee_ppk INTEGER, + final_expiry INTEGER DEFAULT NULL, + FOREIGN KEY(mint_url) REFERENCES mint(mint_url) ON UPDATE CASCADE ON DELETE CASCADE +); + +-- Step 2: Copy data from old keyset table (excluding counter) +INSERT INTO keyset_new (id, keyset_u32, mint_url, unit, active, input_fee_ppk, final_expiry) +SELECT id, keyset_u32, mint_url, unit, active, input_fee_ppk, final_expiry +FROM keyset; + +-- Step 3: Drop old keyset table +DROP TABLE keyset; + +-- Step 4: Rename new table to keyset +ALTER TABLE keyset_new RENAME TO keyset; diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs new file mode 100644 index 000000000..b894713a8 --- /dev/null +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -0,0 +1,1376 @@ +//! SQLite Wallet Database + +use std::collections::HashMap; +use std::fmt::Debug; +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use cdk_common::common::ProofInfo; +use cdk_common::database::{ConversionError, Error, WalletDatabase}; +use cdk_common::mint_url::MintUrl; +use cdk_common::nuts::{MeltQuoteState, MintQuoteState}; +use cdk_common::secret::Secret; +use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId}; +use cdk_common::{ + database, Amount, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, PaymentMethod, Proof, + ProofDleq, PublicKey, SecretKey, SpendingConditions, State, +}; +use tracing::instrument; + +use crate::common::migrate; +use crate::database::{ConnectionWithTransaction, DatabaseExecutor}; +use crate::pool::{DatabasePool, Pool, PooledResource}; +use crate::stmt::{query, Column}; +use crate::{ + column_as_binary, column_as_nullable_binary, column_as_nullable_number, + column_as_nullable_string, column_as_number, column_as_string, unpack_into, +}; + +#[rustfmt::skip] +mod migrations { + include!(concat!(env!("OUT_DIR"), "/migrations_wallet.rs")); +} + +/// Wallet SQLite Database +#[derive(Debug, Clone)] +pub struct SQLWalletDatabase +where + RM: DatabasePool + 'static, +{ + pool: Arc>, +} + +impl SQLWalletDatabase +where + RM: DatabasePool + 'static, +{ + /// Creates a new instance + pub async fn new(db: X) -> Result + where + X: Into, + { + let pool = Pool::new(db.into()); + Self::migrate(pool.get().map_err(|e| Error::Database(Box::new(e)))?).await?; + + Ok(Self { pool }) + } + + /// Migrate [`WalletSqliteDatabase`] + async fn migrate(conn: PooledResource) -> Result<(), Error> { + let tx = ConnectionWithTransaction::new(conn).await?; + migrate(&tx, RM::Connection::name(), migrations::MIGRATIONS).await?; + // Update any existing keys with missing keyset_u32 values + Self::add_keyset_u32(&tx).await?; + tx.commit().await?; + + Ok(()) + } + + async fn add_keyset_u32(conn: &T) -> Result<(), Error> + where + T: DatabaseExecutor, + { + // First get the keysets where keyset_u32 on key is null + let keys_without_u32: Vec> = query( + r#" + SELECT + id + FROM key + WHERE keyset_u32 IS NULL + "#, + )? + .fetch_all(conn) + .await?; + + for id in keys_without_u32 { + let id = column_as_string!(id.first().unwrap()); + + if let Ok(id) = Id::from_str(&id) { + query( + r#" + UPDATE + key + SET keyset_u32 = :u32_keyset + WHERE id = :keyset_id + "#, + )? + .bind("u32_keyset", u32::from(id)) + .bind("keyset_id", id.to_string()) + .execute(conn) + .await?; + } + } + + // Also update keysets where keyset_u32 is null + let keysets_without_u32: Vec> = query( + r#" + SELECT + id + FROM keyset + WHERE keyset_u32 IS NULL + "#, + )? + .fetch_all(conn) + .await?; + + for id in keysets_without_u32 { + let id = column_as_string!(id.first().unwrap()); + + if let Ok(id) = Id::from_str(&id) { + query( + r#" + UPDATE + keyset + SET keyset_u32 = :u32_keyset + WHERE id = :keyset_id + "#, + )? + .bind("u32_keyset", u32::from(id)) + .bind("keyset_id", id.to_string()) + .execute(conn) + .await?; + } + } + + Ok(()) + } +} + +#[async_trait] +impl WalletDatabase for SQLWalletDatabase +where + RM: DatabasePool + 'static, +{ + type Err = database::Error; + + #[instrument(skip(self))] + async fn get_melt_quotes(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + Ok(query( + r#" + SELECT + id, + unit, + amount, + request, + fee_reserve, + state, + expiry, + payment_preimage, + payment_method + FROM + melt_quote + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_melt_quote) + .collect::>()?) + } + + #[instrument(skip(self, mint_info))] + async fn add_mint( + &self, + mint_url: MintUrl, + mint_info: Option, + ) -> Result<(), Self::Err> { + let ( + name, + pubkey, + version, + description, + description_long, + contact, + nuts, + icon_url, + urls, + motd, + time, + tos_url, + ) = match mint_info { + Some(mint_info) => { + let MintInfo { + name, + pubkey, + version, + description, + description_long, + contact, + nuts, + icon_url, + urls, + motd, + time, + tos_url, + } = mint_info; + + ( + name, + pubkey.map(|p| p.to_bytes().to_vec()), + version.map(|v| serde_json::to_string(&v).ok()), + description, + description_long, + contact.map(|c| serde_json::to_string(&c).ok()), + serde_json::to_string(&nuts).ok(), + icon_url, + urls.map(|c| serde_json::to_string(&c).ok()), + motd, + time, + tos_url, + ) + } + None => ( + None, None, None, None, None, None, None, None, None, None, None, None, + ), + }; + + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + query( + r#" +INSERT INTO mint +( + mint_url, name, pubkey, version, description, description_long, + contact, nuts, icon_url, urls, motd, mint_time, tos_url +) +VALUES +( + :mint_url, :name, :pubkey, :version, :description, :description_long, + :contact, :nuts, :icon_url, :urls, :motd, :mint_time, :tos_url +) +ON CONFLICT(mint_url) DO UPDATE SET + name = excluded.name, + pubkey = excluded.pubkey, + version = excluded.version, + description = excluded.description, + description_long = excluded.description_long, + contact = excluded.contact, + nuts = excluded.nuts, + icon_url = excluded.icon_url, + urls = excluded.urls, + motd = excluded.motd, + mint_time = excluded.mint_time, + tos_url = excluded.tos_url +; + "#, + )? + .bind("mint_url", mint_url.to_string()) + .bind("name", name) + .bind("pubkey", pubkey) + .bind("version", version) + .bind("description", description) + .bind("description_long", description_long) + .bind("contact", contact) + .bind("nuts", nuts) + .bind("icon_url", icon_url) + .bind("urls", urls) + .bind("motd", motd) + .bind("mint_time", time.map(|v| v as i64)) + .bind("tos_url", tos_url) + .execute(&*conn) + .await?; + + Ok(()) + } + + #[instrument(skip(self))] + async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + query(r#"DELETE FROM mint WHERE mint_url=:mint_url"#)? + .bind("mint_url", mint_url.to_string()) + .execute(&*conn) + .await?; + + Ok(()) + } + + #[instrument(skip(self))] + async fn get_mint(&self, mint_url: MintUrl) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + name, + pubkey, + version, + description, + description_long, + contact, + nuts, + icon_url, + motd, + urls, + mint_time, + tos_url + FROM + mint + WHERE mint_url = :mint_url + "#, + )? + .bind("mint_url", mint_url.to_string()) + .fetch_one(&*conn) + .await? + .map(sql_row_to_mint_info) + .transpose()?) + } + + #[instrument(skip(self))] + async fn get_mints(&self) -> Result>, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + name, + pubkey, + version, + description, + description_long, + contact, + nuts, + icon_url, + motd, + urls, + mint_time, + tos_url, + mint_url + FROM + mint + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(|mut row| { + let url = column_as_string!( + row.pop().ok_or(ConversionError::MissingColumn(0, 1))?, + MintUrl::from_str + ); + + Ok((url, sql_row_to_mint_info(row).ok())) + }) + .collect::, Error>>()?) + } + + #[instrument(skip(self))] + async fn update_mint_url( + &self, + old_mint_url: MintUrl, + new_mint_url: MintUrl, + ) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + let tables = ["mint_quote", "proof"]; + + for table in &tables { + query(&format!( + r#" + UPDATE {table} + SET mint_url = :new_mint_url + WHERE mint_url = :old_mint_url + "# + ))? + .bind("new_mint_url", new_mint_url.to_string()) + .bind("old_mint_url", old_mint_url.to_string()) + .execute(&*conn) + .await?; + } + + Ok(()) + } + + #[instrument(skip(self, keysets))] + async fn add_mint_keysets( + &self, + mint_url: MintUrl, + keysets: Vec, + ) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + for keyset in keysets { + query( + r#" + INSERT INTO keyset + (mint_url, id, unit, active, input_fee_ppk, final_expiry, keyset_u32) + VALUES + (:mint_url, :id, :unit, :active, :input_fee_ppk, :final_expiry, :keyset_u32) + ON CONFLICT(id) DO UPDATE SET + active = excluded.active, + input_fee_ppk = excluded.input_fee_ppk + "#, + )? + .bind("mint_url", mint_url.to_string()) + .bind("id", keyset.id.to_string()) + .bind("unit", keyset.unit.to_string()) + .bind("active", keyset.active) + .bind("input_fee_ppk", keyset.input_fee_ppk as i64) + .bind("final_expiry", keyset.final_expiry.map(|v| v as i64)) + .bind("keyset_u32", u32::from(keyset.id)) + .execute(&*conn) + .await?; + } + + Ok(()) + } + + #[instrument(skip(self))] + async fn get_mint_keysets( + &self, + mint_url: MintUrl, + ) -> Result>, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + let keysets = query( + r#" + SELECT + id, + unit, + active, + input_fee_ppk, + final_expiry + FROM + keyset + WHERE mint_url = :mint_url + "#, + )? + .bind("mint_url", mint_url.to_string()) + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_keyset) + .collect::, Error>>()?; + + match keysets.is_empty() { + false => Ok(Some(keysets)), + true => Ok(None), + } + } + + #[instrument(skip(self), fields(keyset_id = %keyset_id))] + async fn get_keyset_by_id(&self, keyset_id: &Id) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + id, + unit, + active, + input_fee_ppk, + final_expiry + FROM + keyset + WHERE id = :id + "#, + )? + .bind("id", keyset_id.to_string()) + .fetch_one(&*conn) + .await? + .map(sql_row_to_keyset) + .transpose()?) + } + + #[instrument(skip_all)] + async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + query( + r#" +INSERT INTO mint_quote +(id, mint_url, amount, unit, request, state, expiry, secret_key, payment_method, amount_issued, amount_paid) +VALUES +(:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key, :payment_method, :amount_issued, :amount_paid) +ON CONFLICT(id) DO UPDATE SET + mint_url = excluded.mint_url, + amount = excluded.amount, + unit = excluded.unit, + request = excluded.request, + state = excluded.state, + expiry = excluded.expiry, + secret_key = excluded.secret_key, + payment_method = excluded.payment_method, + amount_issued = excluded.amount_issued, + amount_paid = excluded.amount_paid +; + "#, + )? + .bind("id", quote.id.to_string()) + .bind("mint_url", quote.mint_url.to_string()) + .bind("amount", quote.amount.map(|a| a.to_i64())) + .bind("unit", quote.unit.to_string()) + .bind("request", quote.request) + .bind("state", quote.state.to_string()) + .bind("expiry", quote.expiry as i64) + .bind("secret_key", quote.secret_key.map(|p| p.to_string())) + .bind("payment_method", quote.payment_method.to_string()) + .bind("amount_issued", quote.amount_issued.to_i64()) + .bind("amount_paid", quote.amount_paid.to_i64()) + .execute(&*conn).await?; + + Ok(()) + } + + #[instrument(skip(self))] + async fn get_mint_quote(&self, quote_id: &str) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + id, + mint_url, + amount, + unit, + request, + state, + expiry, + secret_key, + payment_method, + amount_issued, + amount_paid + FROM + mint_quote + WHERE + id = :id + "#, + )? + .bind("id", quote_id.to_string()) + .fetch_one(&*conn) + .await? + .map(sql_row_to_mint_quote) + .transpose()?) + } + + #[instrument(skip(self))] + async fn get_mint_quotes(&self) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + id, + mint_url, + amount, + unit, + request, + state, + expiry, + secret_key, + payment_method, + amount_issued, + amount_paid + FROM + mint_quote + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .map(sql_row_to_mint_quote) + .collect::>()?) + } + + #[instrument(skip(self))] + async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + query(r#"DELETE FROM mint_quote WHERE id=:id"#)? + .bind("id", quote_id.to_string()) + .execute(&*conn) + .await?; + + Ok(()) + } + + #[instrument(skip_all)] + async fn add_melt_quote(&self, quote: wallet::MeltQuote) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + query( + r#" +INSERT INTO melt_quote +(id, unit, amount, request, fee_reserve, state, expiry, payment_method) +VALUES +(:id, :unit, :amount, :request, :fee_reserve, :state, :expiry, :payment_method) +ON CONFLICT(id) DO UPDATE SET + unit = excluded.unit, + amount = excluded.amount, + request = excluded.request, + fee_reserve = excluded.fee_reserve, + state = excluded.state, + expiry = excluded.expiry, + payment_method = excluded.payment_method +; + "#, + )? + .bind("id", quote.id.to_string()) + .bind("unit", quote.unit.to_string()) + .bind("amount", u64::from(quote.amount) as i64) + .bind("request", quote.request) + .bind("fee_reserve", u64::from(quote.fee_reserve) as i64) + .bind("state", quote.state.to_string()) + .bind("expiry", quote.expiry as i64) + .bind("payment_method", quote.payment_method.to_string()) + .execute(&*conn) + .await?; + + Ok(()) + } + + #[instrument(skip(self))] + async fn get_melt_quote(&self, quote_id: &str) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + id, + unit, + amount, + request, + fee_reserve, + state, + expiry, + payment_preimage, + payment_method + FROM + melt_quote + WHERE + id=:id + "#, + )? + .bind("id", quote_id.to_owned()) + .fetch_one(&*conn) + .await? + .map(sql_row_to_melt_quote) + .transpose()?) + } + + #[instrument(skip(self))] + async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + query(r#"DELETE FROM melt_quote WHERE id=:id"#)? + .bind("id", quote_id.to_owned()) + .execute(&*conn) + .await?; + + Ok(()) + } + + #[instrument(skip_all)] + async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + // Recompute ID for verification + keyset.verify_id()?; + + query( + r#" + INSERT INTO key + (id, keys, keyset_u32) + VALUES + (:id, :keys, :keyset_u32) + "#, + )? + .bind("id", keyset.id.to_string()) + .bind( + "keys", + serde_json::to_string(&keyset.keys).map_err(Error::from)?, + ) + .bind("keyset_u32", u32::from(keyset.id)) + .execute(&*conn) + .await?; + + Ok(()) + } + + #[instrument(skip(self), fields(keyset_id = %keyset_id))] + async fn get_keys(&self, keyset_id: &Id) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + keys + FROM key + WHERE id = :id + "#, + )? + .bind("id", keyset_id.to_string()) + .pluck(&*conn) + .await? + .map(|keys| { + let keys = column_as_string!(keys); + serde_json::from_str(&keys).map_err(Error::from) + }) + .transpose()?) + } + + #[instrument(skip(self))] + async fn remove_keys(&self, id: &Id) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + query(r#"DELETE FROM key WHERE id = :id"#)? + .bind("id", id.to_string()) + .pluck(&*conn) + .await?; + + Ok(()) + } + + async fn update_proofs( + &self, + added: Vec, + removed_ys: Vec, + ) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + let tx = ConnectionWithTransaction::new(conn).await?; + + // TODO: Use a transaction for all these operations + for proof in added { + query( + r#" + INSERT INTO proof + (y, mint_url, state, spending_condition, unit, amount, keyset_id, secret, c, witness, dleq_e, dleq_s, dleq_r) + VALUES + (:y, :mint_url, :state, :spending_condition, :unit, :amount, :keyset_id, :secret, :c, :witness, :dleq_e, :dleq_s, :dleq_r) + ON CONFLICT(y) DO UPDATE SET + mint_url = excluded.mint_url, + state = excluded.state, + spending_condition = excluded.spending_condition, + unit = excluded.unit, + amount = excluded.amount, + keyset_id = excluded.keyset_id, + secret = excluded.secret, + c = excluded.c, + witness = excluded.witness, + dleq_e = excluded.dleq_e, + dleq_s = excluded.dleq_s, + dleq_r = excluded.dleq_r + ; + "#, + )? + .bind("y", proof.y.to_bytes().to_vec()) + .bind("mint_url", proof.mint_url.to_string()) + .bind("state",proof.state.to_string()) + .bind( + "spending_condition", + proof + .spending_condition + .map(|s| serde_json::to_string(&s).ok()), + ) + .bind("unit", proof.unit.to_string()) + .bind("amount", u64::from(proof.proof.amount) as i64) + .bind("keyset_id", proof.proof.keyset_id.to_string()) + .bind("secret", proof.proof.secret.to_string()) + .bind("c", proof.proof.c.to_bytes().to_vec()) + .bind( + "witness", + proof + .proof + .witness + .map(|w| serde_json::to_string(&w).unwrap()), + ) + .bind( + "dleq_e", + proof.proof.dleq.as_ref().map(|dleq| dleq.e.to_secret_bytes().to_vec()), + ) + .bind( + "dleq_s", + proof.proof.dleq.as_ref().map(|dleq| dleq.s.to_secret_bytes().to_vec()), + ) + .bind( + "dleq_r", + proof.proof.dleq.as_ref().map(|dleq| dleq.r.to_secret_bytes().to_vec()), + ) + .execute(&tx).await?; + } + if !removed_ys.is_empty() { + query(r#"DELETE FROM proof WHERE y IN (:ys)"#)? + .bind_vec( + "ys", + removed_ys.iter().map(|y| y.to_bytes().to_vec()).collect(), + ) + .execute(&tx) + .await?; + } + + tx.commit().await?; + + Ok(()) + } + + #[instrument(skip(self, state, spending_conditions))] + async fn get_proofs( + &self, + mint_url: Option, + unit: Option, + state: Option>, + spending_conditions: Option>, + ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + amount, + unit, + keyset_id, + secret, + c, + witness, + dleq_e, + dleq_s, + dleq_r, + y, + mint_url, + state, + spending_condition + FROM proof + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .filter_map(|row| { + let row = sql_row_to_proof_info(row).ok()?; + + if row.matches_conditions(&mint_url, &unit, &state, &spending_conditions) { + Some(row) + } else { + None + } + }) + .collect::>()) + } + + #[instrument(skip(self, ys))] + async fn get_proofs_by_ys(&self, ys: Vec) -> Result, Self::Err> { + if ys.is_empty() { + return Ok(Vec::new()); + } + + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + amount, + unit, + keyset_id, + secret, + c, + witness, + dleq_e, + dleq_s, + dleq_r, + y, + mint_url, + state, + spending_condition + FROM proof + WHERE y IN (:ys) + "#, + )? + .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) + .fetch_all(&*conn) + .await? + .into_iter() + .filter_map(|row| sql_row_to_proof_info(row).ok()) + .collect::>()) + } + + async fn get_balance( + &self, + mint_url: Option, + unit: Option, + states: Option>, + ) -> Result { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + let mut query_str = "SELECT COALESCE(SUM(amount), 0) as total FROM proof".to_string(); + let mut where_clauses = Vec::new(); + let states = states + .unwrap_or_default() + .into_iter() + .map(|x| x.to_string()) + .collect::>(); + + if mint_url.is_some() { + where_clauses.push("mint_url = :mint_url"); + } + if unit.is_some() { + where_clauses.push("unit = :unit"); + } + if !states.is_empty() { + where_clauses.push("state IN (:states)"); + } + + if !where_clauses.is_empty() { + query_str.push_str(" WHERE "); + query_str.push_str(&where_clauses.join(" AND ")); + } + + let mut q = query(&query_str)?; + + if let Some(ref mint_url) = mint_url { + q = q.bind("mint_url", mint_url.to_string()); + } + if let Some(ref unit) = unit { + q = q.bind("unit", unit.to_string()); + } + + if !states.is_empty() { + q = q.bind_vec("states", states); + } + + let balance = q + .pluck(&*conn) + .await? + .map(|n| { + // SQLite SUM returns INTEGER which we need to convert to u64 + match n { + crate::stmt::Column::Integer(i) => Ok(i as u64), + crate::stmt::Column::Real(f) => Ok(f as u64), + _ => Err(Error::Database(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Invalid balance type", + )))), + } + }) + .transpose()? + .unwrap_or(0); + + Ok(balance) + } + + async fn update_proofs_state(&self, ys: Vec, state: State) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + query("UPDATE proof SET state = :state WHERE y IN (:ys)")? + .bind_vec("ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) + .bind("state", state.to_string()) + .execute(&*conn) + .await?; + + Ok(()) + } + + #[instrument(skip(self), fields(keyset_id = %keyset_id))] + async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + let tx = ConnectionWithTransaction::new(conn).await?; + + // Lock the row and get current counter from keyset_counter table + let current_counter = query( + r#" + SELECT counter + FROM keyset_counter + WHERE keyset_id=:keyset_id + FOR UPDATE + "#, + )? + .bind("keyset_id", keyset_id.to_string()) + .pluck(&tx) + .await? + .map(|n| Ok::<_, Error>(column_as_number!(n))) + .transpose()? + .unwrap_or(0); + + let new_counter = current_counter + count; + + // Upsert the new counter value + query( + r#" + INSERT INTO keyset_counter (keyset_id, counter) + VALUES (:keyset_id, :new_counter) + ON CONFLICT(keyset_id) DO UPDATE SET + counter = excluded.counter + "#, + )? + .bind("keyset_id", keyset_id.to_string()) + .bind("new_counter", new_counter) + .execute(&tx) + .await?; + + tx.commit().await?; + + Ok(new_counter) + } + + #[instrument(skip(self))] + async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> { + let mint_url = transaction.mint_url.to_string(); + let direction = transaction.direction.to_string(); + let unit = transaction.unit.to_string(); + let amount = u64::from(transaction.amount) as i64; + let fee = u64::from(transaction.fee) as i64; + let ys = transaction + .ys + .iter() + .flat_map(|y| y.to_bytes().to_vec()) + .collect::>(); + + let id = transaction.id(); + + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + query( + r#" +INSERT INTO transactions +(id, mint_url, direction, unit, amount, fee, ys, timestamp, memo, metadata, quote_id, payment_request, payment_proof) +VALUES +(:id, :mint_url, :direction, :unit, :amount, :fee, :ys, :timestamp, :memo, :metadata, :quote_id, :payment_request, :payment_proof) +ON CONFLICT(id) DO UPDATE SET + mint_url = excluded.mint_url, + direction = excluded.direction, + unit = excluded.unit, + amount = excluded.amount, + fee = excluded.fee, + timestamp = excluded.timestamp, + memo = excluded.memo, + metadata = excluded.metadata, + quote_id = excluded.quote_id, + payment_request = excluded.payment_request, + payment_proof = excluded.payment_proof +; + "#, + )? + .bind("id", id.as_slice().to_vec()) + .bind("mint_url", mint_url) + .bind("direction", direction) + .bind("unit", unit) + .bind("amount", amount) + .bind("fee", fee) + .bind("ys", ys) + .bind("timestamp", transaction.timestamp as i64) + .bind("memo", transaction.memo) + .bind( + "metadata", + serde_json::to_string(&transaction.metadata).map_err(Error::from)?, + ) + .bind("quote_id", transaction.quote_id) + .bind("payment_request", transaction.payment_request) + .bind("payment_proof", transaction.payment_proof) + .execute(&*conn) + .await?; + + Ok(()) + } + + #[instrument(skip(self))] + async fn get_transaction( + &self, + transaction_id: TransactionId, + ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + Ok(query( + r#" + SELECT + mint_url, + direction, + unit, + amount, + fee, + ys, + timestamp, + memo, + metadata, + quote_id, + payment_request, + payment_proof + FROM + transactions + WHERE + id = :id + "#, + )? + .bind("id", transaction_id.as_slice().to_vec()) + .fetch_one(&*conn) + .await? + .map(sql_row_to_transaction) + .transpose()?) + } + + #[instrument(skip(self))] + async fn list_transactions( + &self, + mint_url: Option, + direction: Option, + unit: Option, + ) -> Result, Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + Ok(query( + r#" + SELECT + mint_url, + direction, + unit, + amount, + fee, + ys, + timestamp, + memo, + metadata, + quote_id, + payment_request, + payment_proof + FROM + transactions + "#, + )? + .fetch_all(&*conn) + .await? + .into_iter() + .filter_map(|row| { + // TODO: Avoid a table scan by passing the heavy lifting of checking to the DB engine + let transaction = sql_row_to_transaction(row).ok()?; + if transaction.matches_conditions(&mint_url, &direction, &unit) { + Some(transaction) + } else { + None + } + }) + .collect::>()) + } + + #[instrument(skip(self))] + async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err> { + let conn = self.pool.get().map_err(|e| Error::Database(Box::new(e)))?; + + query(r#"DELETE FROM transactions WHERE id=:id"#)? + .bind("id", transaction_id.as_slice().to_vec()) + .execute(&*conn) + .await?; + + Ok(()) + } +} + +fn sql_row_to_mint_info(row: Vec) -> Result { + unpack_into!( + let ( + name, + pubkey, + version, + description, + description_long, + contact, + nuts, + icon_url, + motd, + urls, + mint_time, + tos_url + ) = row + ); + + Ok(MintInfo { + name: column_as_nullable_string!(&name), + pubkey: column_as_nullable_string!(&pubkey, |v| serde_json::from_str(v).ok(), |v| { + serde_json::from_slice(v).ok() + }), + version: column_as_nullable_string!(&version).and_then(|v| serde_json::from_str(&v).ok()), + description: column_as_nullable_string!(description), + description_long: column_as_nullable_string!(description_long), + contact: column_as_nullable_string!(contact, |v| serde_json::from_str(&v).ok()), + nuts: column_as_nullable_string!(nuts, |v| serde_json::from_str(&v).ok()) + .unwrap_or_default(), + urls: column_as_nullable_string!(urls, |v| serde_json::from_str(&v).ok()), + icon_url: column_as_nullable_string!(icon_url), + motd: column_as_nullable_string!(motd), + time: column_as_nullable_number!(mint_time).map(|t| t), + tos_url: column_as_nullable_string!(tos_url), + }) +} + +#[instrument(skip_all)] +fn sql_row_to_keyset(row: Vec) -> Result { + unpack_into!( + let ( + id, + unit, + active, + input_fee_ppk, + final_expiry + ) = row + ); + + Ok(KeySetInfo { + id: column_as_string!(id, Id::from_str, Id::from_bytes), + unit: column_as_string!(unit, CurrencyUnit::from_str), + active: matches!(active, Column::Integer(1)), + input_fee_ppk: column_as_nullable_number!(input_fee_ppk).unwrap_or_default(), + final_expiry: column_as_nullable_number!(final_expiry), + }) +} + +fn sql_row_to_mint_quote(row: Vec) -> Result { + unpack_into!( + let ( + id, + mint_url, + amount, + unit, + request, + state, + expiry, + secret_key, + row_method, + row_amount_minted, + row_amount_paid + ) = row + ); + + let amount: Option = column_as_nullable_number!(amount); + + let amount_paid: u64 = column_as_number!(row_amount_paid); + let amount_minted: u64 = column_as_number!(row_amount_minted); + let payment_method = + PaymentMethod::from_str(&column_as_string!(row_method)).map_err(Error::from)?; + + Ok(MintQuote { + id: column_as_string!(id), + mint_url: column_as_string!(mint_url, MintUrl::from_str), + amount: amount.and_then(Amount::from_i64), + unit: column_as_string!(unit, CurrencyUnit::from_str), + request: column_as_string!(request), + state: column_as_string!(state, MintQuoteState::from_str), + expiry: column_as_number!(expiry), + secret_key: column_as_nullable_string!(secret_key) + .map(|v| SecretKey::from_str(&v)) + .transpose()?, + payment_method, + amount_issued: amount_minted.into(), + amount_paid: amount_paid.into(), + }) +} + +fn sql_row_to_melt_quote(row: Vec) -> Result { + unpack_into!( + let ( + id, + unit, + amount, + request, + fee_reserve, + state, + expiry, + payment_preimage, + row_method + ) = row + ); + + let amount: u64 = column_as_number!(amount); + let fee_reserve: u64 = column_as_number!(fee_reserve); + + let payment_method = + PaymentMethod::from_str(&column_as_string!(row_method)).map_err(Error::from)?; + + Ok(wallet::MeltQuote { + id: column_as_string!(id), + amount: Amount::from(amount), + unit: column_as_string!(unit, CurrencyUnit::from_str), + request: column_as_string!(request), + fee_reserve: Amount::from(fee_reserve), + state: column_as_string!(state, MeltQuoteState::from_str), + expiry: column_as_number!(expiry), + payment_preimage: column_as_nullable_string!(payment_preimage), + payment_method, + }) +} + +fn sql_row_to_proof_info(row: Vec) -> Result { + unpack_into!( + let ( + amount, + unit, + keyset_id, + secret, + c, + witness, + dleq_e, + dleq_s, + dleq_r, + y, + mint_url, + state, + spending_condition + ) = row + ); + + let dleq = match ( + column_as_nullable_binary!(dleq_e), + column_as_nullable_binary!(dleq_s), + column_as_nullable_binary!(dleq_r), + ) { + (Some(e), Some(s), Some(r)) => { + let e_key = SecretKey::from_slice(&e)?; + let s_key = SecretKey::from_slice(&s)?; + let r_key = SecretKey::from_slice(&r)?; + + Some(ProofDleq::new(e_key, s_key, r_key)) + } + _ => None, + }; + + let amount: u64 = column_as_number!(amount); + let proof = Proof { + amount: Amount::from(amount), + keyset_id: column_as_string!(keyset_id, Id::from_str), + secret: column_as_string!(secret, Secret::from_str), + witness: column_as_nullable_string!(witness, |v| { serde_json::from_str(&v).ok() }, |v| { + serde_json::from_slice(&v).ok() + }), + c: column_as_string!(c, PublicKey::from_str, PublicKey::from_slice), + dleq, + }; + + Ok(ProofInfo { + proof, + y: column_as_string!(y, PublicKey::from_str, PublicKey::from_slice), + mint_url: column_as_string!(mint_url, MintUrl::from_str), + state: column_as_string!(state, State::from_str), + spending_condition: column_as_nullable_string!( + spending_condition, + |r| { serde_json::from_str(&r).ok() }, + |r| { serde_json::from_slice(&r).ok() } + ), + unit: column_as_string!(unit, CurrencyUnit::from_str), + }) +} + +fn sql_row_to_transaction(row: Vec) -> Result { + unpack_into!( + let ( + mint_url, + direction, + unit, + amount, + fee, + ys, + timestamp, + memo, + metadata, + quote_id, + payment_request, + payment_proof + ) = row + ); + + let amount: u64 = column_as_number!(amount); + let fee: u64 = column_as_number!(fee); + + Ok(Transaction { + mint_url: column_as_string!(mint_url, MintUrl::from_str), + direction: column_as_string!(direction, TransactionDirection::from_str), + unit: column_as_string!(unit, CurrencyUnit::from_str), + amount: Amount::from(amount), + fee: Amount::from(fee), + ys: column_as_binary!(ys) + .chunks(33) + .map(PublicKey::from_slice) + .collect::, _>>()?, + timestamp: column_as_number!(timestamp), + memo: column_as_nullable_string!(memo), + metadata: column_as_nullable_string!(metadata, |v| serde_json::from_str(&v).ok(), |v| { + serde_json::from_slice(&v).ok() + }) + .unwrap_or_default(), + quote_id: column_as_nullable_string!(quote_id), + payment_request: column_as_nullable_string!(payment_request), + payment_proof: column_as_nullable_string!(payment_proof), + }) +} diff --git a/crates/cdk-sql-common/tests/legacy-sqlx.sql b/crates/cdk-sql-common/tests/legacy-sqlx.sql new file mode 100644 index 000000000..e1237f368 --- /dev/null +++ b/crates/cdk-sql-common/tests/legacy-sqlx.sql @@ -0,0 +1,97 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE _sqlx_migrations ( + version BIGINT PRIMARY KEY, + description TEXT NOT NULL, + installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + success BOOLEAN NOT NULL, + checksum BYTEA NOT NULL, + execution_time BIGINT NOT NULL +); +INSERT INTO _sqlx_migrations VALUES(20240612124932,'init','2025-06-13 20:01:04',1,X'42664ceda25b07bca420c2f7480c90334cb8a720203c1b4b8971181d5d3afabda3171aa89c1c0c8a26421eded94b77fa',921834); +INSERT INTO _sqlx_migrations VALUES(20240618195700,'quote state','2025-06-13 20:01:04',1,X'4b3a5a7f91032320f32b2c60a4348f0e80cef98fcf58153c4c942aa5124ddadce7c5c4338f29d2cb672fc4c08dd894a6',1019333); +INSERT INTO _sqlx_migrations VALUES(20240626092101,'nut04 state','2025-06-13 20:01:04',1,X'3641316faa018b13892d2972010b26a68d48b499aa67f8c084587265d070b575f541f165a9e2c5653b9c81a8dc198843',814000); +INSERT INTO _sqlx_migrations VALUES(20240703122347,'request lookup id','2025-06-13 20:01:04',1,X'234851aa0990048e119d07e9844f064ee71731c4e21021934e733359d6c50bc95a40051673f0a06e82d151c34fff6e8a',430875); +INSERT INTO _sqlx_migrations VALUES(20240710145043,'input fee','2025-06-13 20:01:04',1,X'422d4ce6a1d94c2df4a7fd9400c3d45db35953e53ba46025df7d3ed4d373e04f948468dcbcd8155829a5441f8b46d7f3',302916); +INSERT INTO _sqlx_migrations VALUES(20240711183109,'derivation path index','2025-06-13 20:01:04',1,X'83651c857135516fd578c5ee9f179a04964dc9a366a5b698c1cb54f2b5aa139dc912d34e28c5ff4cc157e6991032952f',225125); +INSERT INTO _sqlx_migrations VALUES(20240718203721,'allow unspent','2025-06-13 20:01:04',1,X'9b900846657b9083cdeca3da6ca7d74487c400f715f7d455c6a662de6b60e2761c3d80ea67d820e9b1ec9fbfd596e267',776167); +INSERT INTO _sqlx_migrations VALUES(20240811031111,'update mint url','2025-06-13 20:01:04',1,X'b8d771e08d3bbe3fc1e8beb1674714f0306d7f9f7cc09990fc0215850179a64366c8c46305ea0c1fb5dbc73a5fe48207',79334); +INSERT INTO _sqlx_migrations VALUES(20240919103407,'proofs quote id','2025-06-13 20:01:04',1,X'e3df13daebbc7df1907c68963258ad3722a0f2398f5ee1e92ea1824ce1a22f5657411f9c08a1f72bfd250e40630fdca5',387875); +INSERT INTO _sqlx_migrations VALUES(20240923153640,'melt requests','2025-06-13 20:01:04',1,X'8c35d740fbb1c0c13dc4594da50cce3e066cba2ff3926a5527629207678afe3a4fa3b7c8f5fab7e08525c676a4098154',188958); +INSERT INTO _sqlx_migrations VALUES(20240930101140,'dleq for sigs','2025-06-13 20:01:04',1,X'23c61a60db9bb145c238bb305583ccc025cd17958e61a6ff97ef0e4385517fe87729f77de0c26ce9cfa3a0c70b273038',383542); +INSERT INTO _sqlx_migrations VALUES(20241108093102,'mint mint quote pubkey','2025-06-13 20:01:04',1,X'00c83af91dc109368fcdc9a1360e1c893afcac3a649c7dfd04e841f1f8fe3d0e99a2ade6891ab752e1b942a738ac6b44',246875); +INSERT INTO _sqlx_migrations VALUES(20250103201327,'amount to pay msats','2025-06-13 20:01:04',1,X'4cc8bd34aec65365271e2dc2a19735403c8551dbf738b541659399c900fb167577d3f02b1988679e6c7922fe018b9a32',235041); +INSERT INTO _sqlx_migrations VALUES(20250129200912,'remove mint url','2025-06-13 20:01:04',1,X'f86b07a6b816683d72bdad637502a47cdeb21f6535aa8e2c0647d4b29f4f58931683b72062b3e313a5936264876bb2c3',638084); +INSERT INTO _sqlx_migrations VALUES(20250129230326,'add config table','2025-06-13 20:01:04',1,X'c232f4cfa032105cdd48097197d7fb0eea290a593af0996434c3f1f5396efb41d1f225592b292367fd9d584672a347d8',163625); +INSERT INTO _sqlx_migrations VALUES(20250307213652,'keyset id as foreign key','2025-06-13 20:01:04',1,X'50a36140780074b2730d429d664c2a7593f2c2237c1a36ed2a11e22c40bfa40b24dc3a5c8089959fae955fdbe2f06533',1498459); +INSERT INTO _sqlx_migrations VALUES(20250406091754,'mint time of quotes','2025-06-13 20:01:04',1,X'ac0165a8371cf7ad424be08c0e6931e1dd1249354ea0e33b4a04ff48ab4188da105e1fd763c42f06aeb733eb33d85415',934250); +INSERT INTO _sqlx_migrations VALUES(20250406093755,'mint created time signature','2025-06-13 20:01:04',1,X'7f2ff8e30f66ab142753cc2e0faec89560726d96298e9ce0c9e871974300fcbe7c2f8a9b2d48ed4ca8daf1b9a5043e95',447000); +INSERT INTO _sqlx_migrations VALUES(20250415093121,'drop keystore foreign','2025-06-13 20:01:04',1,X'efa99131d37335d64c86680c9e5b1362c2bf4d03fbdb6f60c9160edc572add6422d871f76a245d6f55f7fb6f4491b825',1375084); +CREATE TABLE keyset ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + active BOOL NOT NULL, + valid_from INTEGER NOT NULL, + valid_to INTEGER, + derivation_path TEXT NOT NULL, + max_order INTEGER NOT NULL +, input_fee_ppk INTEGER, derivation_path_index INTEGER); +INSERT INTO keyset VALUES('0083a60439303340','sat',1,1749844864,NULL,'0''/0''/0''',32,0,0); +INSERT INTO keyset VALUES('00b13456b2934304','auth',1,1749844864,NULL,'0''/4''/0''',1,0,0); +INSERT INTO keyset VALUES('0002c733628bb92f','usd',1,1749844864,NULL,'0''/2''/0''',32,0,0); +CREATE TABLE mint_quote ( + id TEXT PRIMARY KEY, + amount INTEGER NOT NULL, + unit TEXT NOT NULL, + request TEXT NOT NULL, + expiry INTEGER NOT NULL +, state TEXT CHECK ( state IN ('UNPAID', 'PENDING', 'PAID', 'ISSUED' ) ) NOT NULL DEFAULT 'UNPAID', request_lookup_id TEXT, pubkey TEXT, created_time INTEGER NOT NULL DEFAULT 0, paid_time INTEGER, issued_time INTEGER); +CREATE TABLE melt_quote ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + amount INTEGER NOT NULL, + request TEXT NOT NULL, + fee_reserve INTEGER NOT NULL, + expiry INTEGER NOT NULL +, state TEXT CHECK ( state IN ('UNPAID', 'PENDING', 'PAID' ) ) NOT NULL DEFAULT 'UNPAID', payment_preimage TEXT, request_lookup_id TEXT, msat_to_pay INTEGER, created_time INTEGER NOT NULL DEFAULT 0, paid_time INTEGER); +CREATE TABLE melt_request ( +id TEXT PRIMARY KEY, +inputs TEXT NOT NULL, +outputs TEXT, +method TEXT NOT NULL, +unit TEXT NOT NULL +); +CREATE TABLE config ( + id TEXT PRIMARY KEY, + value TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS "proof" ( + y BYTEA PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, -- no FK constraint here + secret TEXT NOT NULL, + c BYTEA NOT NULL, + witness TEXT, + state TEXT CHECK (state IN ('SPENT', 'PENDING', 'UNSPENT', 'RESERVED', 'UNKNOWN')) NOT NULL, + quote_id TEXT, + created_time INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS "blind_signature" ( + y BYTEA PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, -- FK removed + c BYTEA NOT NULL, + dleq_e TEXT, + dleq_s TEXT, + quote_id TEXT, + created_time INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX unit_index ON keyset(unit); +CREATE INDEX active_index ON keyset(active); +CREATE INDEX request_index ON mint_quote(request); +CREATE INDEX expiry_index ON mint_quote(expiry); +CREATE INDEX melt_quote_state_index ON melt_quote(state); +CREATE INDEX mint_quote_state_index ON mint_quote(state); +CREATE UNIQUE INDEX unique_request_lookup_id_mint ON mint_quote(request_lookup_id); +CREATE UNIQUE INDEX unique_request_lookup_id_melt ON melt_quote(request_lookup_id); +COMMIT; diff --git a/crates/cdk-sqlite/Cargo.toml b/crates/cdk-sqlite/Cargo.toml index 6cfb99a58..5ffd314d6 100644 --- a/crates/cdk-sqlite/Cargo.toml +++ b/crates/cdk-sqlite/Cargo.toml @@ -13,20 +13,25 @@ readme = "README.md" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] default = ["mint", "wallet", "auth"] -mint = ["cdk-common/mint"] -wallet = ["cdk-common/wallet"] -auth = ["cdk-common/auth"] +mint = ["cdk-common/mint", "cdk-sql-common/mint"] +wallet = ["cdk-common/wallet", "cdk-sql-common/wallet"] +auth = ["cdk-common/auth", "cdk-sql-common/auth"] sqlcipher = ["rusqlite/bundled-sqlcipher"] - +prometheus = ["cdk-sql-common/prometheus", "cdk-prometheus"] [dependencies] async-trait.workspace = true cdk-common = { workspace = true, features = ["test"] } +cdk-prometheus = { workspace = true, optional = true } bitcoin.workspace = true +cdk-sql-common = { workspace = true } rusqlite = { version = "0.31", features = ["bundled"]} thiserror.workspace = true -tokio.workspace = true +tokio = { workspace = true, features = ["rt-multi-thread"]} tracing.workspace = true serde.workspace = true serde_json.workspace = true lightning-invoice.workspace = true uuid.workspace = true + +[target.'cfg(target_arch = "wasm32")'.dependencies] +uuid = { workspace = true, features = ["js"] } diff --git a/crates/cdk-sqlite/build.rs b/crates/cdk-sqlite/build.rs deleted file mode 100644 index 0891729a2..000000000 --- a/crates/cdk-sqlite/build.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::fs::{self, File}; -use std::io::Write; -use std::path::{Path, PathBuf}; - -fn main() { - // Step 1: Find `migrations/` folder recursively - let root = Path::new("src"); - - for migration_path in find_migrations_dirs(root) { - // Step 2: Collect all files inside the migrations dir - let mut files = Vec::new(); - visit_dirs(&migration_path, &mut files).expect("Failed to read migrations directory"); - files.sort(); - - // Step 3: Output file path (e.g., `src/db/migrations.rs`) - let parent = migration_path.parent().unwrap(); - let skip_path = parent.to_str().unwrap_or_default().len(); - let dest_path = parent.join("migrations.rs"); - let mut out_file = File::create(&dest_path).expect("Failed to create migrations.rs"); - - writeln!(out_file, "// @generated").unwrap(); - writeln!(out_file, "// Auto-generated by build.rs").unwrap(); - writeln!(out_file, "pub static MIGRATIONS: &[(&str, &str)] = &[").unwrap(); - - for path in &files { - let name = path.file_name().unwrap().to_string_lossy(); - let rel_path = &path.to_str().unwrap().replace("\\", "/")[skip_path..]; // for Windows - writeln!( - out_file, - " (\"{name}\", include_str!(r#\".{rel_path}\"#))," - ) - .unwrap(); - } - - writeln!(out_file, "];").unwrap(); - - println!("cargo:rerun-if-changed={}", migration_path.display()); - } -} - -fn find_migrations_dirs(root: &Path) -> Vec { - let mut found = Vec::new(); - find_migrations_dirs_rec(root, &mut found); - found -} - -fn find_migrations_dirs_rec(dir: &Path, found: &mut Vec) { - if let Ok(entries) = fs::read_dir(dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - if path.file_name().unwrap_or_default() == "migrations" { - found.push(path.clone()); - } - find_migrations_dirs_rec(&path, found); - } - } - } -} - -fn visit_dirs(dir: &Path, files: &mut Vec) -> std::io::Result<()> { - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - if path.is_dir() { - visit_dirs(&path, files)?; - } else if path.is_file() { - files.push(path); - } - } - Ok(()) -} diff --git a/crates/cdk-sqlite/src/async_sqlite.rs b/crates/cdk-sqlite/src/async_sqlite.rs new file mode 100644 index 000000000..ce63ed1ec --- /dev/null +++ b/crates/cdk-sqlite/src/async_sqlite.rs @@ -0,0 +1,208 @@ +//! Simple SQLite +use cdk_common::database::Error; +use cdk_sql_common::database::{DatabaseConnector, DatabaseExecutor, DatabaseTransaction}; +use cdk_sql_common::run_db_operation_sync; +use cdk_sql_common::stmt::{query, Column, SqlPart, Statement}; +use rusqlite::{ffi, CachedStatement, Connection, Error as SqliteError, ErrorCode}; +use tokio::sync::Mutex; + +use crate::common::{from_sqlite, to_sqlite}; + +/// Async Sqlite wrapper +#[derive(Debug)] +pub struct AsyncSqlite { + inner: Mutex, +} + +impl AsyncSqlite { + pub fn new(inner: Connection) -> Self { + Self { + inner: inner.into(), + } + } +} +impl AsyncSqlite { + fn get_stmt<'a>( + &self, + conn: &'a Connection, + statement: Statement, + ) -> Result<(String, CachedStatement<'a>), Error> { + let (sql, placeholder_values) = statement.to_sql()?; + + let new_sql = sql.trim().trim_end_matches("FOR UPDATE"); + + let mut stmt = conn + .prepare_cached(new_sql) + .map_err(|e| Error::Database(Box::new(e)))?; + + for (i, value) in placeholder_values.into_iter().enumerate() { + stmt.raw_bind_parameter(i + 1, to_sqlite(value)) + .map_err(|e| Error::Database(Box::new(e)))?; + } + + Ok((sql, stmt)) + } +} + +#[inline(always)] +fn to_sqlite_error(err: SqliteError) -> Error { + tracing::error!("Failed query with error {:?}", err); + if let rusqlite::Error::SqliteFailure( + ffi::Error { + code, + extended_code, + }, + _, + ) = err + { + if code == ErrorCode::ConstraintViolation + && (extended_code == ffi::SQLITE_CONSTRAINT_PRIMARYKEY + || extended_code == ffi::SQLITE_CONSTRAINT_UNIQUE) + { + Error::Duplicate + } else { + Error::Database(Box::new(err)) + } + } else { + Error::Database(Box::new(err)) + } +} + +/// SQLite trasanction handler +pub struct SQLiteTransactionHandler; + +#[async_trait::async_trait] +impl DatabaseTransaction for SQLiteTransactionHandler { + /// Consumes the current transaction committing the changes + async fn commit(conn: &mut AsyncSqlite) -> Result<(), Error> { + query("COMMIT")?.execute(conn).await?; + Ok(()) + } + + /// Begin a transaction + async fn begin(conn: &mut AsyncSqlite) -> Result<(), Error> { + query("BEGIN IMMEDIATE")?.execute(conn).await?; + Ok(()) + } + + /// Consumes the transaction rolling back all changes + async fn rollback(conn: &mut AsyncSqlite) -> Result<(), Error> { + query("ROLLBACK")?.execute(conn).await?; + Ok(()) + } +} + +impl DatabaseConnector for AsyncSqlite { + type Transaction = SQLiteTransactionHandler; +} + +#[async_trait::async_trait] +impl DatabaseExecutor for AsyncSqlite { + fn name() -> &'static str { + "sqlite" + } + + async fn execute(&self, statement: Statement) -> Result { + let conn = self.inner.lock().await; + + let (sql, mut stmt) = self + .get_stmt(&conn, statement) + .map_err(|e| Error::Database(Box::new(e)))?; + + run_db_operation_sync(&sql, || stmt.raw_execute(), to_sqlite_error) + } + + async fn fetch_one(&self, statement: Statement) -> Result>, Error> { + let conn = self.inner.lock().await; + let (sql, mut stmt) = self + .get_stmt(&conn, statement) + .map_err(|e| Error::Database(Box::new(e)))?; + + run_db_operation_sync( + &sql, + || { + let columns = stmt.column_count(); + + let mut rows = stmt.raw_query(); + rows.next()? + .map(|row| { + (0..columns) + .map(|i| row.get(i).map(from_sqlite)) + .collect::, _>>() + }) + .transpose() + }, + to_sqlite_error, + ) + } + + async fn fetch_all(&self, statement: Statement) -> Result>, Error> { + let conn = self.inner.lock().await; + let (sql, mut stmt) = self + .get_stmt(&conn, statement) + .map_err(|e| Error::Database(Box::new(e)))?; + + let columns = stmt.column_count(); + + run_db_operation_sync( + &sql, + || { + let mut rows = stmt.raw_query(); + let mut results = vec![]; + + while let Some(row) = rows.next()? { + results.push( + (0..columns) + .map(|i| row.get(i).map(from_sqlite)) + .collect::, _>>()?, + ) + } + + Ok(results) + }, + to_sqlite_error, + ) + } + + async fn pluck(&self, statement: Statement) -> Result, Error> { + let conn = self.inner.lock().await; + let (sql, mut stmt) = self + .get_stmt(&conn, statement) + .map_err(|e| Error::Database(Box::new(e)))?; + + run_db_operation_sync( + &sql, + || { + let mut rows = stmt.raw_query(); + rows.next()? + .map(|row| row.get(0usize).map(from_sqlite)) + .transpose() + }, + to_sqlite_error, + ) + } + + async fn batch(&self, mut statement: Statement) -> Result<(), Error> { + let sql = { + let part = statement + .parts + .pop() + .ok_or(Error::Internal("Empty SQL".to_owned()))?; + + if !statement.parts.is_empty() || matches!(part, SqlPart::Placeholder(_, _)) { + return Err(Error::Internal( + "Invalid usage, batch does not support placeholders".to_owned(), + )); + } + + if let SqlPart::Raw(sql) = part { + sql + } else { + unreachable!() + } + }; + let conn = self.inner.lock().await; + + run_db_operation_sync(&sql, || conn.execute_batch(&sql), to_sqlite_error) + } +} diff --git a/crates/cdk-sqlite/src/common.rs b/crates/cdk-sqlite/src/common.rs index e04a00def..6b0681ceb 100644 --- a/crates/cdk-sqlite/src/common.rs +++ b/crates/cdk-sqlite/src/common.rs @@ -1,32 +1,61 @@ +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::time::Duration; -use rusqlite::{params, Connection}; +use cdk_sql_common::pool::{self, DatabasePool}; +use cdk_sql_common::value::Value; +use rusqlite::Connection; -use crate::pool::{Pool, ResourceManager}; +use crate::async_sqlite; /// The config need to create a new SQLite connection -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct Config { path: Option, password: Option, } +impl pool::DatabaseConfig for Config { + fn default_timeout(&self) -> Duration { + Duration::from_secs(5) + } + + fn max_size(&self) -> usize { + if self.password.is_none() { + 1 + } else { + 20 + } + } +} + /// Sqlite connection manager #[derive(Debug)] pub struct SqliteConnectionManager; -impl ResourceManager for SqliteConnectionManager { +impl DatabasePool for SqliteConnectionManager { type Config = Config; - type Resource = Connection; + type Connection = async_sqlite::AsyncSqlite; type Error = rusqlite::Error; fn new_resource( config: &Self::Config, - ) -> Result> { + _stale: Arc, + _timeout: Duration, + ) -> Result> { let conn = if let Some(path) = config.path.as_ref() { + // Check if parent directory exists before attempting to open database + let path_buf = PathBuf::from(path); + if let Some(parent) = path_buf.parent() { + if !parent.to_str().unwrap_or_default().is_empty() && !parent.exists() { + return Err(pool::Error::Resource(rusqlite::Error::InvalidPath( + path_buf.clone(), + ))); + } + } Connection::open(path)? } else { Connection::open_in_memory()? @@ -42,96 +71,87 @@ impl ResourceManager for SqliteConnectionManager { pragma journal_mode = WAL; pragma synchronous = normal; pragma temp_store = memory; - pragma mmap_size = 30000000000; + pragma mmap_size = 5242880; pragma cache = shared; "#, )?; conn.busy_timeout(Duration::from_secs(10))?; - Ok(conn) + Ok(async_sqlite::AsyncSqlite::new(conn)) } } -/// Create a configured rusqlite connection to a SQLite database. -/// For SQLCipher support, enable the "sqlcipher" feature and pass a password. -pub fn create_sqlite_pool( - path: &str, - #[cfg(feature = "sqlcipher")] password: String, -) -> Arc> { - #[cfg(feature = "sqlcipher")] - let password = Some(password); +impl From for Config { + fn from(path: PathBuf) -> Self { + path.to_str().unwrap_or_default().into() + } +} + +impl From<(PathBuf, String)> for Config { + fn from((path, password): (PathBuf, String)) -> Self { + (path.to_str().unwrap_or_default(), password.as_str()).into() + } +} - #[cfg(not(feature = "sqlcipher"))] - let password = None; +impl From<&PathBuf> for Config { + fn from(path: &PathBuf) -> Self { + path.to_str().unwrap_or_default().into() + } +} - let (config, max_size) = if path.contains(":memory:") { - ( +impl From<&str> for Config { + fn from(path: &str) -> Self { + if path.contains(":memory:") { Config { path: None, - password, - }, - 1, - ) - } else { - ( + password: None, + } + } else { Config { path: Some(path.to_owned()), - password, - }, - 20, - ) - }; - - Pool::new(config, max_size, Duration::from_secs(10)) -} - -/// Migrates the migration generated by `build.rs` -pub fn migrate(conn: &mut Connection, migrations: &[(&str, &str)]) -> Result<(), rusqlite::Error> { - let tx = conn.transaction()?; - tx.execute( - r#" - CREATE TABLE IF NOT EXISTS migrations ( - name TEXT PRIMARY KEY, - applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - "#, - [], - )?; - - if tx.query_row( - r#"select count(*) from sqlite_master where name = '_sqlx_migrations'"#, - [], - |row| row.get::<_, i32>(0), - )? == 1 - { - tx.execute_batch( - r#" - INSERT INTO migrations - SELECT - version || '_' || REPLACE(description, ' ', '_') || '.sql', - execution_time - FROM _sqlx_migrations; - DROP TABLE _sqlx_migrations; - "#, - )?; + password: None, + } + } } +} - // Apply each migration if it hasn’t been applied yet - for (name, sql) in migrations { - let already_applied: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM migrations WHERE name = ?1)", - params![name], - |row| row.get(0), - )?; - - if !already_applied { - tx.execute_batch(sql)?; - tx.execute("INSERT INTO migrations (name) VALUES (?1)", params![name])?; +impl From<(&str, &str)> for Config { + fn from((path, pass): (&str, &str)) -> Self { + if path.contains(":memory:") { + Config { + path: None, + password: Some(pass.to_owned()), + } + } else { + Config { + path: Some(path.to_owned()), + password: Some(pass.to_owned()), + } } } +} - tx.commit()?; +/// Convert cdk_sql_common::value::Value to rusqlite Value +#[inline(always)] +pub fn to_sqlite(v: Value) -> rusqlite::types::Value { + match v { + Value::Blob(blob) => rusqlite::types::Value::Blob(blob), + Value::Integer(i) => rusqlite::types::Value::Integer(i), + Value::Null => rusqlite::types::Value::Null, + Value::Text(t) => rusqlite::types::Value::Text(t), + Value::Real(r) => rusqlite::types::Value::Real(r), + } +} - Ok(()) +/// Convert from rusqlite Valute to cdk_sql_common::value::Value +#[inline(always)] +pub fn from_sqlite(v: rusqlite::types::Value) -> Value { + match v { + rusqlite::types::Value::Blob(blob) => Value::Blob(blob), + rusqlite::types::Value::Integer(i) => Value::Integer(i), + rusqlite::types::Value::Null => Value::Null, + rusqlite::types::Value::Text(t) => Value::Text(t), + rusqlite::types::Value::Real(r) => Value::Real(r), + } } diff --git a/crates/cdk-sqlite/src/lib.rs b/crates/cdk-sqlite/src/lib.rs index 0a4c56730..2610f682f 100644 --- a/crates/cdk-sqlite/src/lib.rs +++ b/crates/cdk-sqlite/src/lib.rs @@ -3,10 +3,8 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] +mod async_sqlite; mod common; -mod macros; -mod pool; -mod stmt; #[cfg(feature = "mint")] pub mod mint; diff --git a/crates/cdk-sqlite/src/mint/async_rusqlite.rs b/crates/cdk-sqlite/src/mint/async_rusqlite.rs deleted file mode 100644 index 3cd3593af..000000000 --- a/crates/cdk-sqlite/src/mint/async_rusqlite.rs +++ /dev/null @@ -1,573 +0,0 @@ -use std::marker::PhantomData; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{mpsc as std_mpsc, Arc, Mutex}; -use std::thread::spawn; -use std::time::Instant; - -use rusqlite::{ffi, Connection, ErrorCode, TransactionBehavior}; -use tokio::sync::{mpsc, oneshot}; - -use crate::common::SqliteConnectionManager; -use crate::mint::Error; -use crate::pool::{Pool, PooledResource}; -use crate::stmt::{Column, ExpectedSqlResponse, Statement as InnerStatement, Value}; - -/// The number of queued SQL statements before it start failing -const SQL_QUEUE_SIZE: usize = 10_000; -/// How many ms is considered a slow query, and it'd be logged for further debugging -const SLOW_QUERY_THRESHOLD_MS: u128 = 20; -/// How many SQLite parallel connections can be used to read things in parallel -const WORKING_THREAD_POOL_SIZE: usize = 5; - -#[derive(Debug, Clone)] -pub struct AsyncRusqlite { - sender: mpsc::Sender, - inflight_requests: Arc, -} - -/// Internal request for the database thread -#[derive(Debug)] -pub enum DbRequest { - Sql(InnerStatement, oneshot::Sender), - Begin(oneshot::Sender), - Commit(oneshot::Sender), - Rollback(oneshot::Sender), -} - -#[derive(Debug)] -pub enum DbResponse { - Transaction(mpsc::Sender), - AffectedRows(usize), - Pluck(Option), - Row(Option>), - Rows(Vec>), - Error(Error), - Unexpected, - Ok, -} - -/// Statement for the async_rusqlite wrapper -pub struct Statement(InnerStatement); - -impl Statement { - /// Bind a variable - pub fn bind(self, name: C, value: V) -> Self - where - C: ToString, - V: Into, - { - Self(self.0.bind(name, value)) - } - - /// Bind vec - pub fn bind_vec(self, name: C, value: Vec) -> Self - where - C: ToString, - V: Into, - { - Self(self.0.bind_vec(name, value)) - } - - /// Executes a query and return the number of affected rows - pub async fn execute(self, conn: &C) -> Result - where - C: DatabaseExecutor + Send + Sync, - { - conn.execute(self.0).await - } - - /// Returns the first column of the first row of the query result - pub async fn pluck(self, conn: &C) -> Result, Error> - where - C: DatabaseExecutor + Send + Sync, - { - conn.pluck(self.0).await - } - - /// Returns the first row of the query result - pub async fn fetch_one(self, conn: &C) -> Result>, Error> - where - C: DatabaseExecutor + Send + Sync, - { - conn.fetch_one(self.0).await - } - - /// Returns all rows of the query result - pub async fn fetch_all(self, conn: &C) -> Result>, Error> - where - C: DatabaseExecutor + Send + Sync, - { - conn.fetch_all(self.0).await - } -} - -/// Process a query -#[inline(always)] -fn process_query(conn: &Connection, sql: InnerStatement) -> Result { - let start = Instant::now(); - let mut args = sql.args; - let mut stmt = conn.prepare_cached(&sql.sql)?; - let total_parameters = stmt.parameter_count(); - - for index in 1..=total_parameters { - let value = if let Some(value) = stmt.parameter_name(index).map(|name| { - args.remove(name) - .ok_or(Error::MissingParameter(name.to_owned())) - }) { - value? - } else { - continue; - }; - - stmt.raw_bind_parameter(index, value)?; - } - - let columns = stmt.column_count(); - - let to_return = match sql.expected_response { - ExpectedSqlResponse::AffectedRows => DbResponse::AffectedRows(stmt.raw_execute()?), - ExpectedSqlResponse::ManyRows => { - let mut rows = stmt.raw_query(); - let mut results = vec![]; - - while let Some(row) = rows.next()? { - results.push( - (0..columns) - .map(|i| row.get(i)) - .collect::, _>>()?, - ) - } - - DbResponse::Rows(results) - } - ExpectedSqlResponse::Pluck => { - let mut rows = stmt.raw_query(); - DbResponse::Pluck(rows.next()?.map(|row| row.get(0usize)).transpose()?) - } - ExpectedSqlResponse::SingleRow => { - let mut rows = stmt.raw_query(); - let row = rows - .next()? - .map(|row| { - (0..columns) - .map(|i| row.get(i)) - .collect::, _>>() - }) - .transpose()?; - DbResponse::Row(row) - } - }; - - let duration = start.elapsed(); - - if duration.as_millis() > SLOW_QUERY_THRESHOLD_MS { - tracing::warn!("[SLOW QUERY] Took {} ms: {}", duration.as_millis(), sql.sql); - } - - Ok(to_return) -} - -/// Spawns N number of threads to execute SQL statements -/// -/// Enable parallelism with a pool of threads. -/// -/// There is a main thread, which receives SQL requests and routes them to a worker thread from a -/// fixed-size pool. -/// -/// By doing so, SQLite does synchronization, and Rust will only intervene when a transaction is -/// executed. Transactions are executed in the main thread. -fn rusqlite_spawn_worker_threads( - inflight_requests: Arc, - threads: usize, -) -> std_mpsc::Sender<( - PooledResource, - InnerStatement, - oneshot::Sender, -)> { - let (sender, receiver) = std_mpsc::channel::<( - PooledResource, - InnerStatement, - oneshot::Sender, - )>(); - let receiver = Arc::new(Mutex::new(receiver)); - - for _ in 0..threads { - let rx = receiver.clone(); - let inflight_requests = inflight_requests.clone(); - spawn(move || loop { - while let Ok((conn, sql, reply_to)) = rx.lock().expect("failed to acquire").recv() { - tracing::trace!("Execute query: {}", sql.sql); - let result = process_query(&conn, sql); - let _ = match result { - Ok(ok) => reply_to.send(ok), - Err(err) => { - tracing::error!("Failed query with error {:?}", err); - let err = if let Error::Sqlite(rusqlite::Error::SqliteFailure( - ffi::Error { - code, - extended_code, - }, - _, - )) = &err - { - if *code == ErrorCode::ConstraintViolation - && (*extended_code == ffi::SQLITE_CONSTRAINT_PRIMARYKEY - || *extended_code == ffi::SQLITE_CONSTRAINT_UNIQUE) - { - Error::Duplicate - } else { - err - } - } else { - err - }; - - reply_to.send(DbResponse::Error(err)) - } - }; - drop(conn); - inflight_requests.fetch_sub(1, Ordering::Relaxed); - } - }); - } - sender -} - -/// # Rusqlite main worker -/// -/// This function takes ownership of a pool of connections to SQLite, executes SQL statements, and -/// returns the results or number of affected rows to the caller. All communications are done -/// through channels. This function is synchronous, but a thread pool exists to execute queries, and -/// SQLite will coordinate data access. Transactions are executed in the main and it takes ownership -/// of the main thread until it is finalized -/// -/// This is meant to be called in their thread, as it will not exit the loop until the communication -/// channel is closed. -fn rusqlite_worker_manager( - mut receiver: mpsc::Receiver, - pool: Arc>, - inflight_requests: Arc, -) { - let send_sql_to_thread = - rusqlite_spawn_worker_threads(inflight_requests.clone(), WORKING_THREAD_POOL_SIZE); - - let mut tx_id: usize = 0; - - while let Some(request) = receiver.blocking_recv() { - inflight_requests.fetch_add(1, Ordering::Relaxed); - match request { - DbRequest::Sql(sql, reply_to) => { - let conn = match pool.get() { - Ok(conn) => conn, - Err(err) => { - tracing::error!("Failed to acquire a pool connection: {:?}", err); - inflight_requests.fetch_sub(1, Ordering::Relaxed); - let _ = reply_to.send(DbResponse::Error(err.into())); - continue; - } - }; - - let _ = send_sql_to_thread.send((conn, sql, reply_to)); - continue; - } - DbRequest::Begin(reply_to) => { - let (sender, mut receiver) = mpsc::channel(SQL_QUEUE_SIZE); - let mut conn = match pool.get() { - Ok(conn) => conn, - Err(err) => { - tracing::error!("Failed to acquire a pool connection: {:?}", err); - inflight_requests.fetch_sub(1, Ordering::Relaxed); - let _ = reply_to.send(DbResponse::Error(err.into())); - continue; - } - }; - - let tx = match conn.transaction_with_behavior(TransactionBehavior::Immediate) { - Ok(tx) => tx, - Err(err) => { - tracing::error!("Failed to begin a transaction: {:?}", err); - inflight_requests.fetch_sub(1, Ordering::Relaxed); - let _ = reply_to.send(DbResponse::Error(err.into())); - continue; - } - }; - - // Transaction has begun successfully, send the `sender` back to the caller - // and wait for statements to execute. On `Drop` the wrapper transaction - // should send a `rollback`. - let _ = reply_to.send(DbResponse::Transaction(sender)); - - tx_id += 1; - - // We intentionally handle the transaction hijacking the main loop, there is - // no point is queueing more operations for SQLite, since transaction have - // exclusive access. In other database implementation this block of code - // should be sent to their own thread to allow concurrency - loop { - let request = if let Some(request) = receiver.blocking_recv() { - request - } else { - // If the receiver loop is broken (i.e no more `senders` are active) and no - // `Commit` statement has been sent, this will trigger a `Rollback` - // automatically - tracing::trace!("Tx {}: Transaction rollback on drop", tx_id); - let _ = tx.rollback(); - break; - }; - - match request { - DbRequest::Commit(reply_to) => { - tracing::trace!("Tx {}: Commit", tx_id); - let _ = reply_to.send(match tx.commit() { - Ok(()) => DbResponse::Ok, - Err(err) => { - tracing::error!("Failed commit {:?}", err); - DbResponse::Error(err.into()) - } - }); - break; - } - DbRequest::Rollback(reply_to) => { - tracing::trace!("Tx {}: Rollback", tx_id); - let _ = reply_to.send(match tx.rollback() { - Ok(()) => DbResponse::Ok, - Err(err) => { - tracing::error!("Failed rollback {:?}", err); - DbResponse::Error(err.into()) - } - }); - break; - } - DbRequest::Begin(reply_to) => { - let _ = reply_to.send(DbResponse::Unexpected); - } - DbRequest::Sql(sql, reply_to) => { - tracing::trace!("Tx {}: SQL {}", tx_id, sql.sql); - let _ = match process_query(&tx, sql) { - Ok(ok) => reply_to.send(ok), - Err(err) => { - tracing::error!( - "Tx {}: Failed query with error {:?}", - tx_id, - err - ); - let err = if let Error::Sqlite( - rusqlite::Error::SqliteFailure( - ffi::Error { - code, - extended_code, - }, - _, - ), - ) = &err - { - if *code == ErrorCode::ConstraintViolation - && (*extended_code == ffi::SQLITE_CONSTRAINT_PRIMARYKEY - || *extended_code == ffi::SQLITE_CONSTRAINT_UNIQUE) - { - Error::Duplicate - } else { - err - } - } else { - err - }; - reply_to.send(DbResponse::Error(err)) - } - }; - } - } - } - - drop(conn); - } - DbRequest::Commit(reply_to) => { - let _ = reply_to.send(DbResponse::Unexpected); - } - DbRequest::Rollback(reply_to) => { - let _ = reply_to.send(DbResponse::Unexpected); - } - } - - // If wasn't a `continue` the transaction is done by reaching this code, and we should - // decrease the inflight_request counter - inflight_requests.fetch_sub(1, Ordering::Relaxed); - } -} - -#[async_trait::async_trait] -pub trait DatabaseExecutor { - /// Returns the connection to the database thread (or the on-going transaction) - fn get_queue_sender(&self) -> mpsc::Sender; - - /// Executes a query and returns the affected rows - async fn execute(&self, mut statement: InnerStatement) -> Result { - let (sender, receiver) = oneshot::channel(); - statement.expected_response = ExpectedSqlResponse::AffectedRows; - self.get_queue_sender() - .send(DbRequest::Sql(statement, sender)) - .await - .map_err(|_| Error::Communication)?; - - match receiver.await.map_err(|_| Error::Communication)? { - DbResponse::AffectedRows(n) => Ok(n), - DbResponse::Error(err) => Err(err), - _ => Err(Error::InvalidDbResponse), - } - } - - /// Runs the query and returns the first row or None - async fn fetch_one(&self, mut statement: InnerStatement) -> Result>, Error> { - let (sender, receiver) = oneshot::channel(); - statement.expected_response = ExpectedSqlResponse::SingleRow; - self.get_queue_sender() - .send(DbRequest::Sql(statement, sender)) - .await - .map_err(|_| Error::Communication)?; - - match receiver.await.map_err(|_| Error::Communication)? { - DbResponse::Row(row) => Ok(row), - DbResponse::Error(err) => Err(err), - _ => Err(Error::InvalidDbResponse), - } - } - - /// Runs the query and returns the first row or None - async fn fetch_all(&self, mut statement: InnerStatement) -> Result>, Error> { - let (sender, receiver) = oneshot::channel(); - statement.expected_response = ExpectedSqlResponse::ManyRows; - self.get_queue_sender() - .send(DbRequest::Sql(statement, sender)) - .await - .map_err(|_| Error::Communication)?; - - match receiver.await.map_err(|_| Error::Communication)? { - DbResponse::Rows(rows) => Ok(rows), - DbResponse::Error(err) => Err(err), - _ => Err(Error::InvalidDbResponse), - } - } - - async fn pluck(&self, mut statement: InnerStatement) -> Result, Error> { - let (sender, receiver) = oneshot::channel(); - statement.expected_response = ExpectedSqlResponse::Pluck; - self.get_queue_sender() - .send(DbRequest::Sql(statement, sender)) - .await - .map_err(|_| Error::Communication)?; - - match receiver.await.map_err(|_| Error::Communication)? { - DbResponse::Pluck(value) => Ok(value), - DbResponse::Error(err) => Err(err), - _ => Err(Error::InvalidDbResponse), - } - } -} - -#[inline(always)] -pub fn query(sql: T) -> Statement -where - T: ToString, -{ - Statement(crate::stmt::Statement::new(sql)) -} - -impl AsyncRusqlite { - /// Creates a new Async Rusqlite wrapper. - pub fn new(pool: Arc>) -> Self { - let (sender, receiver) = mpsc::channel(SQL_QUEUE_SIZE); - let inflight_requests = Arc::new(AtomicUsize::new(0)); - let inflight_requests_for_thread = inflight_requests.clone(); - spawn(move || { - rusqlite_worker_manager(receiver, pool, inflight_requests_for_thread); - }); - - Self { - sender, - inflight_requests, - } - } - - /// Show how many inflight requests - #[allow(dead_code)] - pub fn inflight_requests(&self) -> usize { - self.inflight_requests.load(Ordering::Relaxed) - } - - /// Begins a transaction - /// - /// If the transaction is Drop it will trigger a rollback operation - pub async fn begin(&self) -> Result, Error> { - let (sender, receiver) = oneshot::channel(); - self.sender - .send(DbRequest::Begin(sender)) - .await - .map_err(|_| Error::Communication)?; - - match receiver.await.map_err(|_| Error::Communication)? { - DbResponse::Transaction(db_sender) => Ok(Transaction { - db_sender, - _marker: PhantomData, - }), - DbResponse::Error(err) => Err(err), - _ => Err(Error::InvalidDbResponse), - } - } -} - -impl DatabaseExecutor for AsyncRusqlite { - #[inline(always)] - fn get_queue_sender(&self) -> mpsc::Sender { - self.sender.clone() - } -} - -pub struct Transaction<'conn> { - db_sender: mpsc::Sender, - _marker: PhantomData<&'conn ()>, -} - -impl Drop for Transaction<'_> { - fn drop(&mut self) { - let (sender, _) = oneshot::channel(); - let _ = self.db_sender.try_send(DbRequest::Rollback(sender)); - } -} - -impl Transaction<'_> { - pub async fn commit(self) -> Result<(), Error> { - let (sender, receiver) = oneshot::channel(); - self.db_sender - .send(DbRequest::Commit(sender)) - .await - .map_err(|_| Error::Communication)?; - - match receiver.await.map_err(|_| Error::Communication)? { - DbResponse::Ok => Ok(()), - DbResponse::Error(err) => Err(err), - _ => Err(Error::InvalidDbResponse), - } - } - - pub async fn rollback(self) -> Result<(), Error> { - let (sender, receiver) = oneshot::channel(); - self.db_sender - .send(DbRequest::Rollback(sender)) - .await - .map_err(|_| Error::Communication)?; - - match receiver.await.map_err(|_| Error::Communication)? { - DbResponse::Ok => Ok(()), - DbResponse::Error(err) => Err(err), - _ => Err(Error::InvalidDbResponse), - } - } -} - -impl DatabaseExecutor for Transaction<'_> { - /// Get the internal sender to the SQL queue - #[inline(always)] - fn get_queue_sender(&self) -> mpsc::Sender { - self.db_sender.clone() - } -} diff --git a/crates/cdk-sqlite/src/mint/auth/migrations.rs b/crates/cdk-sqlite/src/mint/auth/migrations.rs deleted file mode 100644 index 4edbb850c..000000000 --- a/crates/cdk-sqlite/src/mint/auth/migrations.rs +++ /dev/null @@ -1,5 +0,0 @@ -// @generated -// Auto-generated by build.rs -pub static MIGRATIONS: &[(&str, &str)] = &[ - ("20250109143347_init.sql", include_str!(r#"./migrations/20250109143347_init.sql"#)), -]; diff --git a/crates/cdk-sqlite/src/mint/error.rs b/crates/cdk-sqlite/src/mint/error.rs deleted file mode 100644 index eea510a5f..000000000 --- a/crates/cdk-sqlite/src/mint/error.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! SQLite Database Error - -use thiserror::Error; - -/// SQLite Database Error -#[derive(Debug, Error)] -pub enum Error { - /// SQLX Error - #[error(transparent)] - Sqlite(#[from] rusqlite::Error), - - /// Duplicate entry - #[error("Record already exists")] - Duplicate, - - /// Pool error - #[error(transparent)] - Pool(#[from] crate::pool::Error), - /// Invalid UUID - #[error("Invalid UUID: {0}")] - InvalidUuid(String), - /// QuoteNotFound - #[error("Quote not found")] - QuoteNotFound, - - /// Missing named parameter - #[error("Missing named parameter {0}")] - MissingParameter(String), - - /// Communication error with the database - #[error("Internal communication error")] - Communication, - - /// Invalid response from the database thread - #[error("Unexpected database response")] - InvalidDbResponse, - - /// Invalid db type - #[error("Invalid type from db, expected {0} got {1}")] - InvalidType(String, String), - - /// Missing columns - #[error("Not enough elements: expected {0}, got {1}")] - MissingColumn(usize, usize), - - /// Invalid data conversion in column - #[error("Error converting {0} to {1}")] - InvalidConversion(String, String), - - /// NUT00 Error - #[error(transparent)] - CDKNUT00(#[from] cdk_common::nuts::nut00::Error), - /// NUT01 Error - #[error(transparent)] - CDKNUT01(#[from] cdk_common::nuts::nut01::Error), - /// NUT02 Error - #[error(transparent)] - CDKNUT02(#[from] cdk_common::nuts::nut02::Error), - /// NUT04 Error - #[error(transparent)] - CDKNUT04(#[from] cdk_common::nuts::nut04::Error), - /// NUT05 Error - #[error(transparent)] - CDKNUT05(#[from] cdk_common::nuts::nut05::Error), - /// NUT07 Error - #[error(transparent)] - CDKNUT07(#[from] cdk_common::nuts::nut07::Error), - /// NUT23 Error - #[error(transparent)] - CDKNUT23(#[from] cdk_common::nuts::nut23::Error), - /// Secret Error - #[error(transparent)] - CDKSECRET(#[from] cdk_common::secret::Error), - /// BIP32 Error - #[error(transparent)] - BIP32(#[from] bitcoin::bip32::Error), - /// Mint Url Error - #[error(transparent)] - MintUrl(#[from] cdk_common::mint_url::Error), - /// Could Not Initialize Database - #[error("Could not initialize database")] - CouldNotInitialize, - /// Invalid Database Path - #[error("Invalid database path")] - InvalidDbPath, - /// Serde Error - #[error(transparent)] - Serde(#[from] serde_json::Error), - /// Unknown Mint Info - #[error("Unknown mint info")] - UnknownMintInfo, - /// Unknown quote TTL - #[error("Unknown quote TTL")] - UnknownQuoteTTL, - /// Proof not found - #[error("Proof not found")] - ProofNotFound, - /// Invalid keyset ID - #[error("Invalid keyset ID")] - InvalidKeysetId, -} - -impl From for cdk_common::database::Error { - fn from(e: Error) -> Self { - match e { - Error::Duplicate => Self::Duplicate, - e => Self::Database(Box::new(e)), - } - } -} diff --git a/crates/cdk-sqlite/src/mint/memory.rs b/crates/cdk-sqlite/src/mint/memory.rs index 735f7670a..b4c341c6d 100644 --- a/crates/cdk-sqlite/src/mint/memory.rs +++ b/crates/cdk-sqlite/src/mint/memory.rs @@ -2,19 +2,24 @@ 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; use super::MintSqliteDatabase; +const CDK_MINT_PRIMARY_NAMESPACE: &str = "cdk_mint"; +const CDK_MINT_CONFIG_SECONDARY_NAMESPACE: &str = "config"; +const CDK_MINT_CONFIG_KV_KEY: &str = "mint_info"; + /// Creates a new in-memory [`MintSqliteDatabase`] instance pub async fn empty() -> Result { #[cfg(not(feature = "sqlcipher"))] - let db = MintSqliteDatabase::new(":memory:").await?; + let path = ":memory:"; #[cfg(feature = "sqlcipher")] - let db = MintSqliteDatabase::new(":memory:", "memory".to_string()).await?; - Ok(db) + let path = (":memory:", "memory"); + + MintSqliteDatabase::new(path).await } /// Creates a new in-memory [`MintSqliteDatabase`] instance with the given state @@ -44,16 +49,25 @@ pub async fn new_with_state( let mut tx = MintDatabase::begin_transaction(&db).await?; for quote in mint_quotes { - tx.add_or_replace_mint_quote(quote).await?; + tx.add_mint_quote(quote).await?; } for quote in melt_quotes { tx.add_melt_quote(quote).await?; } - tx.add_proofs(pending_proofs, None).await?; - tx.add_proofs(spent_proofs, None).await?; - tx.set_mint_info(mint_info).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, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_CONFIG_KV_KEY, + &mint_info_bytes, + ) + .await?; tx.commit().await?; Ok(db) diff --git a/crates/cdk-sqlite/src/mint/migrations.rs b/crates/cdk-sqlite/src/mint/migrations.rs deleted file mode 100644 index 9384c4966..000000000 --- a/crates/cdk-sqlite/src/mint/migrations.rs +++ /dev/null @@ -1,24 +0,0 @@ -// @generated -// Auto-generated by build.rs -pub static MIGRATIONS: &[(&str, &str)] = &[ - ("20240612124932_init.sql", include_str!(r#"./migrations/20240612124932_init.sql"#)), - ("20240618195700_quote_state.sql", include_str!(r#"./migrations/20240618195700_quote_state.sql"#)), - ("20240626092101_nut04_state.sql", include_str!(r#"./migrations/20240626092101_nut04_state.sql"#)), - ("20240703122347_request_lookup_id.sql", include_str!(r#"./migrations/20240703122347_request_lookup_id.sql"#)), - ("20240710145043_input_fee.sql", include_str!(r#"./migrations/20240710145043_input_fee.sql"#)), - ("20240711183109_derivation_path_index.sql", include_str!(r#"./migrations/20240711183109_derivation_path_index.sql"#)), - ("20240718203721_allow_unspent.sql", include_str!(r#"./migrations/20240718203721_allow_unspent.sql"#)), - ("20240811031111_update_mint_url.sql", include_str!(r#"./migrations/20240811031111_update_mint_url.sql"#)), - ("20240919103407_proofs_quote_id.sql", include_str!(r#"./migrations/20240919103407_proofs_quote_id.sql"#)), - ("20240923153640_melt_requests.sql", include_str!(r#"./migrations/20240923153640_melt_requests.sql"#)), - ("20240930101140_dleq_for_sigs.sql", include_str!(r#"./migrations/20240930101140_dleq_for_sigs.sql"#)), - ("20241108093102_mint_mint_quote_pubkey.sql", include_str!(r#"./migrations/20241108093102_mint_mint_quote_pubkey.sql"#)), - ("20250103201327_amount_to_pay_msats.sql", include_str!(r#"./migrations/20250103201327_amount_to_pay_msats.sql"#)), - ("20250129200912_remove_mint_url.sql", include_str!(r#"./migrations/20250129200912_remove_mint_url.sql"#)), - ("20250129230326_add_config_table.sql", include_str!(r#"./migrations/20250129230326_add_config_table.sql"#)), - ("20250307213652_keyset_id_as_foreign_key.sql", include_str!(r#"./migrations/20250307213652_keyset_id_as_foreign_key.sql"#)), - ("20250406091754_mint_time_of_quotes.sql", include_str!(r#"./migrations/20250406091754_mint_time_of_quotes.sql"#)), - ("20250406093755_mint_created_time_signature.sql", include_str!(r#"./migrations/20250406093755_mint_created_time_signature.sql"#)), - ("20250415093121_drop_keystore_foreign.sql", include_str!(r#"./migrations/20250415093121_drop_keystore_foreign.sql"#)), - ("20250626120251_rename_blind_message_y_to_b.sql", include_str!(r#"./migrations/20250626120251_rename_blind_message_y_to_b.sql"#)), -]; diff --git a/crates/cdk-sqlite/src/mint/mod.rs b/crates/cdk-sqlite/src/mint/mod.rs index f4cf3e926..df0157ee6 100644 --- a/crates/cdk-sqlite/src/mint/mod.rs +++ b/crates/cdk-sqlite/src/mint/mod.rs @@ -1,1628 +1,46 @@ //! SQLite Mint -use std::collections::HashMap; -use std::ops::DerefMut; -use std::path::Path; -use std::str::FromStr; +use cdk_sql_common::mint::SQLMintAuthDatabase; +use cdk_sql_common::SQLMintDatabase; -use async_rusqlite::{query, DatabaseExecutor, Transaction}; -use async_trait::async_trait; -use bitcoin::bip32::DerivationPath; -use cdk_common::common::QuoteTTL; -use cdk_common::database::{ - self, MintDatabase, MintDbWriterFinalizer, MintKeyDatabaseTransaction, MintKeysDatabase, - MintProofsDatabase, MintProofsTransaction, MintQuotesDatabase, MintQuotesTransaction, - MintSignatureTransaction, MintSignaturesDatabase, -}; -use cdk_common::mint::{self, MintKeySetInfo, MintQuote}; -use cdk_common::nut00::ProofsMethods; -use cdk_common::nut05::QuoteState; -use cdk_common::secret::Secret; -use cdk_common::state::check_state_transition; -use cdk_common::util::unix_time; -use cdk_common::{ - Amount, BlindSignature, BlindSignatureDleq, CurrencyUnit, Id, MeltQuoteState, MintInfo, - MintQuoteState, Proof, Proofs, PublicKey, SecretKey, State, -}; -use error::Error; -use lightning_invoice::Bolt11Invoice; -use uuid::Uuid; +use crate::common::SqliteConnectionManager; -use crate::common::{create_sqlite_pool, migrate}; -use crate::stmt::Column; -use crate::{ - column_as_nullable_number, column_as_nullable_string, column_as_number, column_as_string, - unpack_into, -}; - -mod async_rusqlite; -#[cfg(feature = "auth")] -mod auth; -pub mod error; pub mod memory; -#[rustfmt::skip] -mod migrations; +/// Mint SQLite implementation with rusqlite +pub type MintSqliteDatabase = SQLMintDatabase; +/// Mint Auth database with rusqlite #[cfg(feature = "auth")] -pub use auth::MintSqliteAuthDatabase; - -/// Mint SQLite Database -#[derive(Debug, Clone)] -pub struct MintSqliteDatabase { - pool: async_rusqlite::AsyncRusqlite, -} - -#[inline(always)] -async fn get_current_states( - conn: &C, - ys: &[PublicKey], -) -> Result, Error> -where - C: DatabaseExecutor + Send + Sync, -{ - query(r#"SELECT y, state FROM proof WHERE y IN (:ys)"#) - .bind_vec(":ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) - .fetch_all(conn) - .await? - .into_iter() - .map(|row| { - Ok(( - column_as_string!(&row[0], PublicKey::from_hex, PublicKey::from_slice), - column_as_string!(&row[1], State::from_str), - )) - }) - .collect::, _>>() -} - -#[inline(always)] -async fn set_to_config(conn: &C, id: &str, value: &T) -> Result<(), Error> -where - T: ?Sized + serde::Serialize, - C: DatabaseExecutor + Send + Sync, -{ - query( - r#" - INSERT INTO config (id, value) VALUES (:id, :value) - ON CONFLICT(id) DO UPDATE SET value = excluded.value - "#, - ) - .bind(":id", id.to_owned()) - .bind(":value", serde_json::to_string(&value)?) - .execute(conn) - .await?; - - Ok(()) -} - -impl MintSqliteDatabase { - /// Create new [`MintSqliteDatabase`] - #[cfg(not(feature = "sqlcipher"))] - pub async fn new>(path: P) -> Result { - let pool = create_sqlite_pool(path.as_ref().to_str().ok_or(Error::InvalidDbPath)?); - migrate(pool.get()?.deref_mut(), migrations::MIGRATIONS)?; - - Ok(Self { - pool: async_rusqlite::AsyncRusqlite::new(pool), - }) - } - - /// Create new [`MintSqliteDatabase`] - #[cfg(feature = "sqlcipher")] - pub async fn new>(path: P, password: String) -> Result { - let pool = create_sqlite_pool( - path.as_ref().to_str().ok_or(Error::InvalidDbPath)?, - password, - ); - migrate(pool.get()?.deref_mut(), migrations::MIGRATIONS)?; - - Ok(Self { - pool: async_rusqlite::AsyncRusqlite::new(pool), - }) - } - - #[inline(always)] - async fn fetch_from_config(&self, id: &str) -> Result - where - T: serde::de::DeserializeOwned, - { - let value = column_as_string!(query(r#"SELECT value FROM config WHERE id = :id LIMIT 1"#) - .bind(":id", id.to_owned()) - .pluck(&self.pool) - .await? - .ok_or::(Error::UnknownQuoteTTL)?); - - Ok(serde_json::from_str(&value)?) - } -} - -/// Sqlite Writer -pub struct SqliteTransaction<'a> { - inner: Transaction<'a>, -} - -#[async_trait] -impl<'a> database::MintTransaction<'a, database::Error> for SqliteTransaction<'a> { - async fn set_mint_info(&mut self, mint_info: MintInfo) -> Result<(), database::Error> { - Ok(set_to_config(&self.inner, "mint_info", &mint_info).await?) - } - - async fn set_quote_ttl(&mut self, quote_ttl: QuoteTTL) -> Result<(), database::Error> { - Ok(set_to_config(&self.inner, "quote_ttl", "e_ttl).await?) - } -} - -#[async_trait] -impl MintDbWriterFinalizer for SqliteTransaction<'_> { - type Err = database::Error; - - async fn commit(self: Box) -> Result<(), database::Error> { - Ok(self.inner.commit().await?) - } - - async fn rollback(self: Box) -> Result<(), database::Error> { - Ok(self.inner.rollback().await?) - } -} - -#[async_trait] -impl<'a> MintKeyDatabaseTransaction<'a, database::Error> for SqliteTransaction<'a> { - async fn add_keyset_info(&mut self, keyset: MintKeySetInfo) -> Result<(), database::Error> { - query( - r#" - INSERT INTO - keyset ( - id, unit, active, valid_from, valid_to, derivation_path, - max_order, input_fee_ppk, derivation_path_index - ) - VALUES ( - :id, :unit, :active, :valid_from, :valid_to, :derivation_path, - :max_order, :input_fee_ppk, :derivation_path_index - ) - ON CONFLICT(id) DO UPDATE SET - unit = excluded.unit, - active = excluded.active, - valid_from = excluded.valid_from, - valid_to = excluded.valid_to, - derivation_path = excluded.derivation_path, - max_order = excluded.max_order, - input_fee_ppk = excluded.input_fee_ppk, - derivation_path_index = excluded.derivation_path_index - "#, - ) - .bind(":id", keyset.id.to_string()) - .bind(":unit", keyset.unit.to_string()) - .bind(":active", keyset.active) - .bind(":valid_from", keyset.valid_from as i64) - .bind(":valid_to", keyset.final_expiry.map(|v| v as i64)) - .bind(":derivation_path", keyset.derivation_path.to_string()) - .bind(":max_order", keyset.max_order) - .bind(":input_fee_ppk", keyset.input_fee_ppk as i64) - .bind(":derivation_path_index", keyset.derivation_path_index) - .execute(&self.inner) - .await?; - - Ok(()) - } - - async fn set_active_keyset( - &mut self, - unit: CurrencyUnit, - id: Id, - ) -> Result<(), database::Error> { - query(r#"UPDATE keyset SET active=FALSE WHERE unit IS :unit"#) - .bind(":unit", unit.to_string()) - .execute(&self.inner) - .await?; - - query(r#"UPDATE keyset SET active=TRUE WHERE unit IS :unit AND id IS :id"#) - .bind(":unit", unit.to_string()) - .bind(":id", id.to_string()) - .execute(&self.inner) - .await?; - - Ok(()) - } -} - -#[async_trait] -impl MintKeysDatabase for MintSqliteDatabase { - type Err = database::Error; - - async fn begin_transaction<'a>( - &'a self, - ) -> Result< - Box + Send + Sync + 'a>, - database::Error, - > { - Ok(Box::new(SqliteTransaction { - inner: self.pool.begin().await?, - })) - } - - async fn get_active_keyset_id(&self, unit: &CurrencyUnit) -> Result, Self::Err> { - Ok( - query(r#" SELECT id FROM keyset WHERE active = 1 AND unit IS :unit"#) - .bind(":unit", unit.to_string()) - .pluck(&self.pool) - .await? - .map(|id| match id { - Column::Text(text) => Ok(Id::from_str(&text)?), - Column::Blob(id) => Ok(Id::from_bytes(&id)?), - _ => Err(Error::InvalidKeysetId), - }) - .transpose()?, - ) - } - - async fn get_active_keysets(&self) -> Result, Self::Err> { - Ok(query(r#"SELECT id, unit FROM keyset WHERE active = 1"#) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(|row| { - Ok(( - column_as_string!(&row[1], CurrencyUnit::from_str), - column_as_string!(&row[0], Id::from_str, Id::from_bytes), - )) - }) - .collect::, Error>>()?) - } - - async fn get_keyset_info(&self, id: &Id) -> Result, Self::Err> { - Ok(query( - r#"SELECT - id, - unit, - active, - valid_from, - valid_to, - derivation_path, - derivation_path_index, - max_order, - input_fee_ppk - FROM - keyset - WHERE id=:id"#, - ) - .bind(":id", id.to_string()) - .fetch_one(&self.pool) - .await? - .map(sqlite_row_to_keyset_info) - .transpose()?) - } - - async fn get_keyset_infos(&self) -> Result, Self::Err> { - Ok(query( - r#"SELECT - id, - unit, - active, - valid_from, - valid_to, - derivation_path, - derivation_path_index, - max_order, - input_fee_ppk - FROM - keyset - "#, - ) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_keyset_info) - .collect::, _>>()?) - } -} - -#[async_trait] -impl<'a> MintQuotesTransaction<'a> for SqliteTransaction<'a> { - type Err = database::Error; - - async fn add_or_replace_mint_quote(&mut self, quote: MintQuote) -> Result<(), Self::Err> { - query( - r#" - INSERT OR REPLACE INTO mint_quote ( - id, amount, unit, request, state, expiry, request_lookup_id, - pubkey, created_time, paid_time, issued_time - ) - VALUES ( - :id, :amount, :unit, :request, :state, :expiry, :request_lookup_id, - :pubkey, :created_time, :paid_time, :issued_time - ) - "#, - ) - .bind(":id", quote.id.to_string()) - .bind(":amount", u64::from(quote.amount) as i64) - .bind(":unit", quote.unit.to_string()) - .bind(":request", quote.request) - .bind(":state", quote.state.to_string()) - .bind(":expiry", quote.expiry as i64) - .bind(":request_lookup_id", quote.request_lookup_id) - .bind(":pubkey", quote.pubkey.map(|p| p.to_string())) - .bind(":created_time", quote.created_time as i64) - .bind(":paid_time", quote.paid_time.map(|t| t as i64)) - .bind(":issued_time", quote.issued_time.map(|t| t as i64)) - .execute(&self.inner) - .await?; - - Ok(()) - } - - async fn remove_mint_quote(&mut self, quote_id: &Uuid) -> Result<(), Self::Err> { - query(r#"DELETE FROM mint_quote WHERE id=:id"#) - .bind(":id", quote_id.as_hyphenated().to_string()) - .execute(&self.inner) - .await?; - Ok(()) - } - - async fn add_melt_quote(&mut self, quote: mint::MeltQuote) -> Result<(), Self::Err> { - // First try to find and replace any expired UNPAID quotes with the same request_lookup_id - let current_time = unix_time(); - let row_affected = query( - r#" - DELETE FROM melt_quote - WHERE request_lookup_id = :request_lookup_id - AND state = :state - AND expiry < :current_time - "#, - ) - .bind(":request_lookup_id", quote.request_lookup_id.to_string()) - .bind(":state", MeltQuoteState::Unpaid.to_string()) - .bind(":current_time", current_time as i64) - .execute(&self.inner) - .await?; - - if row_affected > 0 { - tracing::info!("Received new melt quote for existing invoice with expired quote."); - } - - // Now insert the new quote - query( - r#" - INSERT INTO melt_quote - ( - id, unit, amount, request, fee_reserve, state, - expiry, payment_preimage, request_lookup_id, msat_to_pay, - created_time, paid_time - ) - VALUES - ( - :id, :unit, :amount, :request, :fee_reserve, :state, - :expiry, :payment_preimage, :request_lookup_id, :msat_to_pay, - :created_time, :paid_time - ) - "#, - ) - .bind(":id", quote.id.to_string()) - .bind(":unit", quote.unit.to_string()) - .bind(":amount", u64::from(quote.amount) as i64) - .bind(":request", quote.request) - .bind(":fee_reserve", u64::from(quote.fee_reserve) as i64) - .bind(":state", quote.state.to_string()) - .bind(":expiry", quote.expiry as i64) - .bind(":payment_preimage", quote.payment_preimage) - .bind(":request_lookup_id", quote.request_lookup_id) - .bind( - ":msat_to_pay", - quote.msat_to_pay.map(|a| u64::from(a) as i64), - ) - .bind(":created_time", quote.created_time as i64) - .bind(":paid_time", quote.paid_time.map(|t| t as i64)) - .execute(&self.inner) - .await?; - - Ok(()) - } - - async fn update_melt_quote_request_lookup_id( - &mut self, - quote_id: &Uuid, - new_request_lookup_id: &str, - ) -> Result<(), Self::Err> { - query(r#"UPDATE melt_quote SET request_lookup_id = :new_req_id WHERE id = :id"#) - .bind(":new_req_id", new_request_lookup_id.to_owned()) - .bind(":id", quote_id.as_hyphenated().to_string()) - .execute(&self.inner) - .await?; - Ok(()) - } - - async fn update_melt_quote_state( - &mut self, - quote_id: &Uuid, - state: MeltQuoteState, - ) -> Result<(MeltQuoteState, mint::MeltQuote), Self::Err> { - let mut quote = query( - r#" - SELECT - id, - unit, - amount, - request, - fee_reserve, - state, - expiry, - payment_preimage, - request_lookup_id, - msat_to_pay, - created_time, - paid_time - FROM - melt_quote - WHERE - id=:id - AND state != :state - "#, - ) - .bind(":id", quote_id.as_hyphenated().to_string()) - .bind(":state", state.to_string()) - .fetch_one(&self.inner) - .await? - .map(sqlite_row_to_melt_quote) - .transpose()? - .ok_or(Error::QuoteNotFound)?; - - let rec = if state == MeltQuoteState::Paid { - let current_time = unix_time(); - query(r#"UPDATE melt_quote SET state = :state, paid_time = :paid_time WHERE id = :id"#) - .bind(":state", state.to_string()) - .bind(":paid_time", current_time as i64) - .bind(":id", quote_id.as_hyphenated().to_string()) - .execute(&self.inner) - .await - } else { - query(r#"UPDATE melt_quote SET state = :state WHERE id = :id"#) - .bind(":state", state.to_string()) - .bind(":id", quote_id.as_hyphenated().to_string()) - .execute(&self.inner) - .await - }; - - match rec { - Ok(_) => {} - Err(err) => { - tracing::error!("SQLite Could not update melt quote"); - return Err(err.into()); - } - }; - - let old_state = quote.state; - quote.state = state; - - Ok((old_state, quote)) - } - - async fn remove_melt_quote(&mut self, quote_id: &Uuid) -> Result<(), Self::Err> { - query( - r#" - DELETE FROM melt_quote - WHERE id=? - "#, - ) - .bind(":id", quote_id.as_hyphenated().to_string()) - .execute(&self.inner) - .await?; - - Ok(()) - } - - async fn update_mint_quote_state( - &mut self, - quote_id: &Uuid, - state: MintQuoteState, - ) -> Result { - let quote = query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE id = :id"#, - ) - .bind(":id", quote_id.as_hyphenated().to_string()) - .fetch_one(&self.inner) - .await? - .map(sqlite_row_to_mint_quote) - .ok_or(Error::QuoteNotFound)??; - - let update_query = match state { - MintQuoteState::Paid => { - r#"UPDATE mint_quote SET state = :state, paid_time = :current_time WHERE id = :quote_id"# - } - MintQuoteState::Issued => { - r#"UPDATE mint_quote SET state = :state, issued_time = :current_time WHERE id = :quote_id"# - } - _ => r#"UPDATE mint_quote SET state = :state WHERE id = :quote_id"#, - }; - - let current_time = unix_time(); - - let update = match state { - MintQuoteState::Paid => query(update_query) - .bind(":state", state.to_string()) - .bind(":current_time", current_time as i64) - .bind(":quote_id", quote_id.as_hyphenated().to_string()), - MintQuoteState::Issued => query(update_query) - .bind(":state", state.to_string()) - .bind(":current_time", current_time as i64) - .bind(":quote_id", quote_id.as_hyphenated().to_string()), - _ => query(update_query) - .bind(":state", state.to_string()) - .bind(":quote_id", quote_id.as_hyphenated().to_string()), - }; - - match update.execute(&self.inner).await { - Ok(_) => Ok(quote.state), - Err(err) => { - tracing::error!("SQLite Could not update keyset: {:?}", err); - - return Err(err.into()); - } - } - } - - async fn get_mint_quote(&mut self, quote_id: &Uuid) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE id = :id"#, - ) - .bind(":id", quote_id.as_hyphenated().to_string()) - .fetch_one(&self.inner) - .await? - .map(sqlite_row_to_mint_quote) - .transpose()?) - } - - async fn get_melt_quote( - &mut self, - quote_id: &Uuid, - ) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - unit, - amount, - request, - fee_reserve, - state, - expiry, - payment_preimage, - request_lookup_id, - msat_to_pay, - created_time, - paid_time - FROM - melt_quote - WHERE - id=:id - "#, - ) - .bind(":id", quote_id.as_hyphenated().to_string()) - .fetch_one(&self.inner) - .await? - .map(sqlite_row_to_melt_quote) - .transpose()?) - } - - async fn get_mint_quote_by_request( - &mut self, - request: &str, - ) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE request = :request"#, - ) - .bind(":request", request.to_owned()) - .fetch_one(&self.inner) - .await? - .map(sqlite_row_to_mint_quote) - .transpose()?) - } -} - -#[async_trait] -impl MintQuotesDatabase for MintSqliteDatabase { - type Err = database::Error; - - async fn get_mint_quote(&self, quote_id: &Uuid) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE id = :id"#, - ) - .bind(":id", quote_id.as_hyphenated().to_string()) - .fetch_one(&self.pool) - .await? - .map(sqlite_row_to_mint_quote) - .transpose()?) - } - - async fn get_mint_quote_by_request( - &self, - request: &str, - ) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE request = :request"#, - ) - .bind(":request", request.to_owned()) - .fetch_one(&self.pool) - .await? - .map(sqlite_row_to_mint_quote) - .transpose()?) - } - - async fn get_mint_quote_by_request_lookup_id( - &self, - request_lookup_id: &str, - ) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE request_lookup_id = :request_lookup_id"#, - ) - .bind(":request_lookup_id", request_lookup_id.to_owned()) - .fetch_one(&self.pool) - .await? - .map(sqlite_row_to_mint_quote) - .transpose()?) - } - - async fn get_mint_quotes(&self) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - "#, - ) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_mint_quote) - .collect::, _>>()?) - } - - async fn get_mint_quotes_with_state( - &self, - state: MintQuoteState, - ) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - amount, - unit, - request, - state, - expiry, - request_lookup_id, - pubkey, - created_time, - paid_time, - issued_time - FROM - mint_quote - WHERE - state = :state - "#, - ) - .bind(":state", state.to_string()) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_mint_quote) - .collect::, _>>()?) - } - - async fn get_melt_quote(&self, quote_id: &Uuid) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - unit, - amount, - request, - fee_reserve, - state, - expiry, - payment_preimage, - request_lookup_id, - msat_to_pay, - created_time, - paid_time - FROM - melt_quote - WHERE - id=:id - "#, - ) - .bind(":id", quote_id.as_hyphenated().to_string()) - .fetch_one(&self.pool) - .await? - .map(sqlite_row_to_melt_quote) - .transpose()?) - } - - async fn get_melt_quotes(&self) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - id, - unit, - amount, - request, - fee_reserve, - state, - expiry, - payment_preimage, - request_lookup_id, - msat_to_pay, - created_time, - paid_time - FROM - melt_quote - "#, - ) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_melt_quote) - .collect::, _>>()?) - } -} - -#[async_trait] -impl<'a> MintProofsTransaction<'a> for SqliteTransaction<'a> { - type Err = database::Error; - - async fn add_proofs( - &mut self, - proofs: Proofs, - quote_id: Option, - ) -> Result<(), Self::Err> { - let current_time = unix_time(); - - // Check any previous proof, this query should return None in order to proceed storing - // Any result here would error - match query(r#"SELECT state FROM proof WHERE y IN (:ys) LIMIT 1"#) - .bind_vec( - ":ys", - proofs - .iter() - .map(|y| y.y().map(|y| y.to_bytes().to_vec())) - .collect::>()?, - ) - .pluck(&self.inner) - .await? - .map(|state| Ok::<_, Error>(column_as_string!(&state, State::from_str))) - .transpose()? - { - Some(State::Spent) => Err(database::Error::AttemptUpdateSpentProof), - Some(_) => Err(database::Error::Duplicate), - None => Ok(()), // no previous record - }?; - - for proof in proofs { - query( - r#" - INSERT INTO proof - (y, amount, keyset_id, secret, c, witness, state, quote_id, created_time) - VALUES - (:y, :amount, :keyset_id, :secret, :c, :witness, :state, :quote_id, :created_time) - "#, - ) - .bind(":y", proof.y()?.to_bytes().to_vec()) - .bind(":amount", u64::from(proof.amount) as i64) - .bind(":keyset_id", proof.keyset_id.to_string()) - .bind(":secret", proof.secret.to_string()) - .bind(":c", proof.c.to_bytes().to_vec()) - .bind( - ":witness", - proof.witness.map(|w| serde_json::to_string(&w).unwrap()), - ) - .bind(":state", "UNSPENT".to_string()) - .bind(":quote_id", quote_id.map(|q| q.hyphenated().to_string())) - .bind(":created_time", current_time as i64) - .execute(&self.inner) - .await?; - } - - Ok(()) - } - - async fn update_proofs_states( - &mut self, - ys: &[PublicKey], - new_state: State, - ) -> Result>, Self::Err> { - let mut current_states = get_current_states(&self.inner, ys).await?; - - if current_states.len() != ys.len() { - tracing::warn!( - "Attempted to update state of non-existent proof {} {}", - current_states.len(), - ys.len() - ); - return Err(database::Error::ProofNotFound); - } - - for state in current_states.values() { - check_state_transition(*state, new_state)?; - } - - query(r#"UPDATE proof SET state = :new_state WHERE y IN (:ys)"#) - .bind(":new_state", new_state.to_string()) - .bind_vec(":ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) - .execute(&self.inner) - .await?; - - Ok(ys.iter().map(|y| current_states.remove(y)).collect()) - } - - async fn remove_proofs( - &mut self, - ys: &[PublicKey], - _quote_id: Option, - ) -> Result<(), Self::Err> { - let total_deleted = query( - r#" - DELETE FROM proof WHERE y IN (:ys) AND state NOT IN (:exclude_state) - "#, - ) - .bind_vec(":ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) - .bind_vec(":exclude_state", vec![State::Spent.to_string()]) - .execute(&self.inner) - .await?; - - if total_deleted != ys.len() { - return Err(Self::Err::AttemptRemoveSpentProof); - } - - Ok(()) - } -} - -#[async_trait] -impl MintProofsDatabase for MintSqliteDatabase { - type Err = database::Error; - - async fn get_proofs_by_ys(&self, ys: &[PublicKey]) -> Result>, Self::Err> { - let mut proofs = query( - r#" - SELECT - amount, - keyset_id, - secret, - c, - witness, - y - FROM - proof - WHERE - y IN (:ys) - "#, - ) - .bind_vec(":ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(|mut row| { - Ok(( - column_as_string!( - row.pop().ok_or(Error::InvalidDbPath)?, - PublicKey::from_hex, - PublicKey::from_slice - ), - sqlite_row_to_proof(row)?, - )) - }) - .collect::, Error>>()?; - - Ok(ys.iter().map(|y| proofs.remove(y)).collect()) - } - - async fn get_proof_ys_by_quote_id(&self, quote_id: &Uuid) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - amount, - keyset_id, - secret, - c, - witness - FROM - proof - WHERE - quote_id = :quote_id - "#, - ) - .bind(":quote_id", quote_id.as_hyphenated().to_string()) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_proof) - .collect::, _>>()? - .ys()?) - } - - async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err> { - let mut current_states = get_current_states(&self.pool, ys).await?; - - Ok(ys.iter().map(|y| current_states.remove(y)).collect()) - } - - async fn get_proofs_by_keyset_id( - &self, - keyset_id: &Id, - ) -> Result<(Proofs, Vec>), Self::Err> { - Ok(query( - r#" - SELECT - keyset_id, - amount, - secret, - c, - witness, - state - FROM - proof - WHERE - keyset_id=? - "#, - ) - .bind(":keyset_id", keyset_id.to_string()) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_proof_with_state) - .collect::, _>>()? - .into_iter() - .unzip()) - } -} - -#[async_trait] -impl<'a> MintSignatureTransaction<'a> for SqliteTransaction<'a> { - type Err = database::Error; - - async fn add_blind_signatures( - &mut self, - blinded_messages: &[PublicKey], - blind_signatures: &[BlindSignature], - quote_id: Option, - ) -> Result<(), Self::Err> { - let current_time = unix_time(); - - for (message, signature) in blinded_messages.iter().zip(blind_signatures) { - query( - r#" - INSERT INTO blind_signature - (blinded_message, amount, keyset_id, c, quote_id, dleq_e, dleq_s, created_time) - VALUES - (:blinded_message, :amount, :keyset_id, :c, :quote_id, :dleq_e, :dleq_s, :created_time) - "#, - ) - .bind(":blinded_message", message.to_bytes().to_vec()) - .bind(":amount", u64::from(signature.amount) as i64) - .bind(":keyset_id", signature.keyset_id.to_string()) - .bind(":c", signature.c.to_bytes().to_vec()) - .bind(":quote_id", quote_id.map(|q| q.hyphenated().to_string())) - .bind( - ":dleq_e", - signature.dleq.as_ref().map(|dleq| dleq.e.to_secret_hex()), - ) - .bind( - ":dleq_s", - signature.dleq.as_ref().map(|dleq| dleq.s.to_secret_hex()), - ) - .bind(":created_time", current_time as i64) - .execute(&self.inner) - .await?; - } - - Ok(()) - } - - async fn get_blind_signatures( - &mut self, - blinded_messages: &[PublicKey], - ) -> Result>, Self::Err> { - let mut blinded_signatures = query( - r#"SELECT - keyset_id, - amount, - c, - dleq_e, - dleq_s, - blinded_message - FROM - blind_signature - WHERE blinded_message IN (:y) - "#, - ) - .bind_vec( - ":y", - blinded_messages - .iter() - .map(|y| y.to_bytes().to_vec()) - .collect(), - ) - .fetch_all(&self.inner) - .await? - .into_iter() - .map(|mut row| { - Ok(( - column_as_string!( - &row.pop().ok_or(Error::InvalidDbResponse)?, - PublicKey::from_hex, - PublicKey::from_slice - ), - sqlite_row_to_blind_signature(row)?, - )) - }) - .collect::, Error>>()?; - Ok(blinded_messages - .iter() - .map(|y| blinded_signatures.remove(y)) - .collect()) - } -} - -#[async_trait] -impl MintSignaturesDatabase for MintSqliteDatabase { - type Err = database::Error; - - async fn get_blind_signatures( - &self, - blinded_messages: &[PublicKey], - ) -> Result>, Self::Err> { - let mut blinded_signatures = query( - r#"SELECT - keyset_id, - amount, - c, - dleq_e, - dleq_s, - blinded_message - FROM - blind_signature - WHERE blinded_message IN (:blinded_message) - "#, - ) - .bind_vec( - ":blinded_message", - blinded_messages - .iter() - .map(|b_| b_.to_bytes().to_vec()) - .collect(), - ) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(|mut row| { - Ok(( - column_as_string!( - &row.pop().ok_or(Error::InvalidDbResponse)?, - PublicKey::from_hex, - PublicKey::from_slice - ), - sqlite_row_to_blind_signature(row)?, - )) - }) - .collect::, Error>>()?; - Ok(blinded_messages - .iter() - .map(|y| blinded_signatures.remove(y)) - .collect()) - } - - async fn get_blind_signatures_for_keyset( - &self, - keyset_id: &Id, - ) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - keyset_id, - amount, - c, - dleq_e, - dleq_s - FROM - blind_signature - WHERE - keyset_id=:keyset_id - "#, - ) - .bind(":keyset_id", keyset_id.to_string()) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_blind_signature) - .collect::, _>>()?) - } - - /// Get [`BlindSignature`]s for quote - async fn get_blind_signatures_for_quote( - &self, - quote_id: &Uuid, - ) -> Result, Self::Err> { - Ok(query( - r#" - SELECT - keyset_id, - amount, - c, - dleq_e, - dleq_s - FROM - blind_signature - WHERE - quote_id=:quote_id - "#, - ) - .bind(":quote_id", quote_id.to_string()) - .fetch_all(&self.pool) - .await? - .into_iter() - .map(sqlite_row_to_blind_signature) - .collect::, _>>()?) - } -} - -#[async_trait] -impl MintDatabase for MintSqliteDatabase { - async fn begin_transaction<'a>( - &'a self, - ) -> Result< - Box + Send + Sync + 'a>, - database::Error, - > { - Ok(Box::new(SqliteTransaction { - inner: self.pool.begin().await?, - })) - } - - async fn get_mint_info(&self) -> Result { - Ok(self.fetch_from_config("mint_info").await?) - } - - async fn get_quote_ttl(&self) -> Result { - Ok(self.fetch_from_config("quote_ttl").await?) - } -} - -fn sqlite_row_to_keyset_info(row: Vec) -> Result { - unpack_into!( - let ( - id, - unit, - active, - valid_from, - valid_to, - derivation_path, - derivation_path_index, - max_order, - row_keyset_ppk - ) = row - ); - - Ok(MintKeySetInfo { - id: column_as_string!(id, Id::from_str, Id::from_bytes), - unit: column_as_string!(unit, CurrencyUnit::from_str), - active: matches!(active, Column::Integer(1)), - valid_from: column_as_number!(valid_from), - derivation_path: column_as_string!(derivation_path, DerivationPath::from_str), - derivation_path_index: column_as_nullable_number!(derivation_path_index), - max_order: column_as_number!(max_order), - input_fee_ppk: column_as_number!(row_keyset_ppk), - final_expiry: column_as_nullable_number!(valid_to), - }) -} - -fn sqlite_row_to_mint_quote(row: Vec) -> Result { - unpack_into!( - let ( - id, amount, unit, request, state, expiry, request_lookup_id, - pubkey, created_time, paid_time, issued_time - ) = row - ); - - let request = column_as_string!(&request); - let request_lookup_id = column_as_nullable_string!(&request_lookup_id).unwrap_or_else(|| { - Bolt11Invoice::from_str(&request) - .map(|invoice| invoice.payment_hash().to_string()) - .unwrap_or_else(|_| request.clone()) - }); - - let pubkey = column_as_nullable_string!(&pubkey) - .map(|pk| PublicKey::from_hex(&pk)) - .transpose()?; - - let id = column_as_string!(id); - let amount: u64 = column_as_number!(amount); - - Ok(MintQuote { - id: Uuid::parse_str(&id).map_err(|_| Error::InvalidUuid(id))?, - amount: Amount::from(amount), - unit: column_as_string!(unit, CurrencyUnit::from_str), - request, - state: column_as_string!(state, MintQuoteState::from_str), - expiry: column_as_number!(expiry), - request_lookup_id, - pubkey, - created_time: column_as_number!(created_time), - paid_time: column_as_nullable_number!(paid_time).map(|p| p), - issued_time: column_as_nullable_number!(issued_time).map(|p| p), - }) -} - -fn sqlite_row_to_melt_quote(row: Vec) -> Result { - unpack_into!( - let ( - id, - unit, - amount, - request, - fee_reserve, - state, - expiry, - payment_preimage, - request_lookup_id, - msat_to_pay, - created_time, - paid_time - ) = row - ); - - let id = column_as_string!(id); - let amount: u64 = column_as_number!(amount); - let fee_reserve: u64 = column_as_number!(fee_reserve); - - let request = column_as_string!(&request); - let request_lookup_id = column_as_nullable_string!(&request_lookup_id).unwrap_or_else(|| { - Bolt11Invoice::from_str(&request) - .map(|invoice| invoice.payment_hash().to_string()) - .unwrap_or_else(|_| request.clone()) - }); - let msat_to_pay: Option = column_as_nullable_number!(msat_to_pay); - - Ok(mint::MeltQuote { - id: Uuid::parse_str(&id).map_err(|_| Error::InvalidUuid(id))?, - amount: Amount::from(amount), - fee_reserve: Amount::from(fee_reserve), - unit: column_as_string!(unit, CurrencyUnit::from_str), - request, - payment_preimage: column_as_nullable_string!(payment_preimage), - msat_to_pay: msat_to_pay.map(Amount::from), - state: column_as_string!(state, QuoteState::from_str), - expiry: column_as_number!(expiry), - request_lookup_id, - created_time: column_as_number!(created_time), - paid_time: column_as_nullable_number!(paid_time).map(|p| p), - }) -} - -fn sqlite_row_to_proof(row: Vec) -> Result { - unpack_into!( - let ( - amount, - keyset_id, - secret, - c, - witness - ) = row - ); - - let amount: u64 = column_as_number!(amount); - Ok(Proof { - amount: Amount::from(amount), - keyset_id: column_as_string!(keyset_id, Id::from_str), - secret: column_as_string!(secret, Secret::from_str), - c: column_as_string!(c, PublicKey::from_hex, PublicKey::from_slice), - witness: column_as_nullable_string!(witness).and_then(|w| serde_json::from_str(&w).ok()), - dleq: None, - }) -} - -fn sqlite_row_to_proof_with_state(row: Vec) -> Result<(Proof, Option), Error> { - unpack_into!( - let ( - keyset_id, amount, secret, c, witness, state - ) = row - ); - - let amount: u64 = column_as_number!(amount); - let state = column_as_nullable_string!(state).and_then(|s| State::from_str(&s).ok()); - - Ok(( - Proof { - amount: Amount::from(amount), - keyset_id: column_as_string!(keyset_id, Id::from_str, Id::from_bytes), - secret: column_as_string!(secret, Secret::from_str), - c: column_as_string!(c, PublicKey::from_hex, PublicKey::from_slice), - witness: column_as_nullable_string!(witness) - .and_then(|w| serde_json::from_str(&w).ok()), - dleq: None, - }, - state, - )) -} - -fn sqlite_row_to_blind_signature(row: Vec) -> Result { - unpack_into!( - let ( - keyset_id, amount, c, dleq_e, dleq_s - ) = row - ); - - let dleq = match ( - column_as_nullable_string!(dleq_e), - column_as_nullable_string!(dleq_s), - ) { - (Some(e), Some(s)) => Some(BlindSignatureDleq { - e: SecretKey::from_hex(e)?, - s: SecretKey::from_hex(s)?, - }), - _ => None, - }; - - let amount: u64 = column_as_number!(amount); - - Ok(BlindSignature { - amount: Amount::from(amount), - keyset_id: column_as_string!(keyset_id, Id::from_str, Id::from_bytes), - c: column_as_string!(c, PublicKey::from_hex, PublicKey::from_slice), - dleq, - }) -} +pub type MintSqliteAuthDatabase = SQLMintAuthDatabase; #[cfg(test)] -mod tests { +mod test { use std::fs::remove_file; - use cdk_common::mint::MintKeySetInfo; - use cdk_common::{mint_db_test, Amount}; + use cdk_common::mint_db_test; + use cdk_sql_common::pool::Pool; + use cdk_sql_common::stmt::query; use super::*; + use crate::common::Config; - #[tokio::test] - async fn test_remove_spent_proofs() { - let db = memory::empty().await.unwrap(); - - // Create a keyset and add it to the database - let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); - let keyset_info = MintKeySetInfo { - id: keyset_id, - unit: CurrencyUnit::Sat, - active: true, - valid_from: 0, - derivation_path: bitcoin::bip32::DerivationPath::from_str("m/0'/0'/0'").unwrap(), - derivation_path_index: Some(0), - max_order: 32, - input_fee_ppk: 0, - final_expiry: None, - }; - let mut tx = MintKeysDatabase::begin_transaction(&db).await.unwrap(); - tx.add_keyset_info(keyset_info).await.unwrap(); - tx.commit().await.unwrap(); - - let proofs = vec![ - Proof { - amount: Amount::from(100), - keyset_id, - secret: Secret::generate(), - c: SecretKey::generate().public_key(), - witness: None, - dleq: None, - }, - Proof { - amount: Amount::from(200), - keyset_id, - secret: Secret::generate(), - c: SecretKey::generate().public_key(), - witness: None, - dleq: None, - }, - ]; - - // Add proofs to database - let mut tx = MintDatabase::begin_transaction(&db).await.unwrap(); - tx.add_proofs(proofs.clone(), None).await.unwrap(); - - // Mark one proof as spent - tx.update_proofs_states(&[proofs[0].y().unwrap()], State::Spent) - .await - .unwrap(); - - tx.commit().await.unwrap(); - - // Verify both proofs still exist - let states = db - .get_proofs_states(&[proofs[0].y().unwrap(), proofs[1].y().unwrap()]) - .await - .unwrap(); - - assert_eq!(states.len(), 2); - assert_eq!(states[0], Some(State::Spent)); - assert_eq!(states[1], Some(State::Unspent)); + async fn provide_db(_test_name: String) -> MintSqliteDatabase { + memory::empty().await.unwrap() } - #[tokio::test] - async fn test_update_spent_proofs() { - let db = memory::empty().await.unwrap(); - - // Create a keyset and add it to the database - let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); - let keyset_info = MintKeySetInfo { - id: keyset_id, - unit: CurrencyUnit::Sat, - active: true, - valid_from: 0, - derivation_path: bitcoin::bip32::DerivationPath::from_str("m/0'/0'/0'").unwrap(), - derivation_path_index: Some(0), - max_order: 32, - input_fee_ppk: 0, - final_expiry: None, - }; - let mut tx = MintKeysDatabase::begin_transaction(&db) - .await - .expect("begin"); - tx.add_keyset_info(keyset_info).await.unwrap(); - tx.commit().await.expect("commit"); - - let proofs = vec![ - Proof { - amount: Amount::from(100), - keyset_id, - secret: Secret::generate(), - c: SecretKey::generate().public_key(), - witness: None, - dleq: None, - }, - Proof { - amount: Amount::from(200), - keyset_id, - secret: Secret::generate(), - c: SecretKey::generate().public_key(), - witness: None, - dleq: None, - }, - ]; - - // Add proofs to database - let mut tx = MintDatabase::begin_transaction(&db).await.unwrap(); - tx.add_proofs(proofs.clone(), None).await.unwrap(); - - // Mark one proof as spent - tx.update_proofs_states(&[proofs[0].y().unwrap()], State::Spent) - .await - .unwrap(); - - // Try to update both proofs - should fail because one is spent - let result = tx - .update_proofs_states(&[proofs[0].y().unwrap()], State::Unspent) - .await; - - tx.commit().await.unwrap(); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - database::Error::AttemptUpdateSpentProof - )); - - // Verify states haven't changed - let states = db - .get_proofs_states(&[proofs[0].y().unwrap(), proofs[1].y().unwrap()]) - .await - .unwrap(); + mint_db_test!(provide_db); - assert_eq!(states.len(), 2); - assert_eq!(states[0], Some(State::Spent)); - assert_eq!(states[1], Some(State::Unspent)); - } + #[tokio::test] + async fn bug_opening_relative_path() { + let config: Config = "test.db".into(); - async fn provide_db() -> MintSqliteDatabase { - memory::empty().await.unwrap() + let pool = Pool::::new(config); + let db = pool.get(); + assert!(db.is_ok()); + let _ = remove_file("test.db"); } - mint_db_test!(provide_db); - #[tokio::test] async fn open_legacy_and_migrate() { let file = format!( @@ -1633,19 +51,26 @@ mod tests { { let _ = remove_file(&file); #[cfg(not(feature = "sqlcipher"))] - let legacy = create_sqlite_pool(&file); + let config: Config = file.as_str().into(); #[cfg(feature = "sqlcipher")] - let legacy = create_sqlite_pool(&file, "test".to_owned()); - let y = legacy.get().expect("pool"); - y.execute_batch(include_str!("../../tests/legacy-sqlx.sql")) + let config: Config = (file.as_str(), "test").into(); + + let pool = Pool::::new(config); + + let conn = pool.get().expect("valid connection"); + + query(include_str!("../../tests/legacy-sqlx.sql")) + .expect("query") + .execute(&*conn) + .await .expect("create former db failed"); } #[cfg(not(feature = "sqlcipher"))] - let conn = MintSqliteDatabase::new(&file).await; + let conn = MintSqliteDatabase::new(file.as_str()).await; #[cfg(feature = "sqlcipher")] - let conn = MintSqliteDatabase::new(&file, "test".to_owned()).await; + let conn = MintSqliteDatabase::new((file.as_str(), "test")).await; assert!(conn.is_ok(), "Failed with {:?}", conn.unwrap_err()); diff --git a/crates/cdk-sqlite/src/pool.rs b/crates/cdk-sqlite/src/pool.rs deleted file mode 100644 index c1b411aae..000000000 --- a/crates/cdk-sqlite/src/pool.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Very simple connection pool, to avoid an external dependency on r2d2 and other crates. If this -//! endup work it can be re-used in other parts of the project and may be promoted to its own -//! generic crate - -use std::fmt::Debug; -use std::ops::{Deref, DerefMut}; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Condvar, Mutex}; -use std::time::Duration; - -/// Pool error -#[derive(thiserror::Error, Debug)] -pub enum Error { - /// Mutex Poison Error - #[error("Internal: PoisonError")] - Poison, - - /// Timeout error - #[error("Timed out waiting for a resource")] - Timeout, - - /// Internal database error - #[error(transparent)] - Resource(#[from] E), -} - -/// Trait to manage resources -pub trait ResourceManager: Debug { - /// The resource to be pooled - type Resource: Debug; - - /// The configuration that is needed in order to create the resource - type Config: Debug; - - /// The error the resource may return when creating a new instance - type Error: Debug; - - /// Creates a new resource with a given config - fn new_resource(config: &Self::Config) -> Result>; - - /// The object is dropped - fn drop(_resource: Self::Resource) {} -} - -/// Generic connection pool of resources R -#[derive(Debug)] -pub struct Pool -where - RM: ResourceManager, -{ - config: RM::Config, - queue: Mutex>, - in_use: AtomicUsize, - max_size: usize, - default_timeout: Duration, - waiter: Condvar, -} - -/// The pooled resource -pub struct PooledResource -where - RM: ResourceManager, -{ - resource: Option, - pool: Arc>, -} - -impl Drop for PooledResource -where - RM: ResourceManager, -{ - fn drop(&mut self) { - if let Some(resource) = self.resource.take() { - let mut active_resource = self.pool.queue.lock().expect("active_resource"); - active_resource.push(resource); - self.pool.in_use.fetch_sub(1, Ordering::AcqRel); - - // Notify a waiting thread - self.pool.waiter.notify_one(); - } - } -} - -impl Deref for PooledResource -where - RM: ResourceManager, -{ - type Target = RM::Resource; - - fn deref(&self) -> &Self::Target { - self.resource.as_ref().expect("resource already dropped") - } -} - -impl DerefMut for PooledResource -where - RM: ResourceManager, -{ - fn deref_mut(&mut self) -> &mut Self::Target { - self.resource.as_mut().expect("resource already dropped") - } -} - -impl Pool -where - RM: ResourceManager, -{ - /// Creates a new pool - pub fn new(config: RM::Config, max_size: usize, default_timeout: Duration) -> Arc { - Arc::new(Self { - config, - queue: Default::default(), - in_use: Default::default(), - waiter: Default::default(), - default_timeout, - max_size, - }) - } - - /// Similar to get_timeout but uses the default timeout value. - #[inline(always)] - pub fn get(self: &Arc) -> Result, Error> { - self.get_timeout(self.default_timeout) - } - - /// Get a new resource or fail after timeout is reached. - /// - /// This function will return a free resource or create a new one if there is still room for it; - /// otherwise, it will wait for a resource to be released for reuse. - #[inline(always)] - pub fn get_timeout( - self: &Arc, - timeout: Duration, - ) -> Result, Error> { - let mut resources = self.queue.lock().map_err(|_| Error::Poison)?; - - loop { - if let Some(resource) = resources.pop() { - drop(resources); - self.in_use.fetch_add(1, Ordering::AcqRel); - - return Ok(PooledResource { - resource: Some(resource), - pool: self.clone(), - }); - } - - if self.in_use.load(Ordering::Relaxed) < self.max_size { - drop(resources); - self.in_use.fetch_add(1, Ordering::AcqRel); - - return Ok(PooledResource { - resource: Some(RM::new_resource(&self.config)?), - pool: self.clone(), - }); - } - - resources = self - .waiter - .wait_timeout(resources, timeout) - .map_err(|_| Error::Poison) - .and_then(|(lock, timeout_result)| { - if timeout_result.timed_out() { - Err(Error::Timeout) - } else { - Ok(lock) - } - })?; - } - } -} - -impl Drop for Pool -where - RM: ResourceManager, -{ - fn drop(&mut self) { - if let Ok(mut resources) = self.queue.lock() { - loop { - while let Some(resource) = resources.pop() { - RM::drop(resource); - } - - if self.in_use.load(Ordering::Relaxed) == 0 { - break; - } - - resources = if let Ok(resources) = self.waiter.wait(resources) { - resources - } else { - break; - }; - } - } - } -} diff --git a/crates/cdk-sqlite/src/stmt.rs b/crates/cdk-sqlite/src/stmt.rs deleted file mode 100644 index d578ef2b7..000000000 --- a/crates/cdk-sqlite/src/stmt.rs +++ /dev/null @@ -1,184 +0,0 @@ -use std::collections::HashMap; - -use rusqlite::{self, CachedStatement}; - -use crate::common::SqliteConnectionManager; -use crate::pool::PooledResource; - -/// The Value coming from SQLite -pub type Value = rusqlite::types::Value; - -/// The Column type -pub type Column = Value; - -/// Expected response type for a given SQL statement -#[derive(Debug, Clone, Copy, Default)] -pub enum ExpectedSqlResponse { - /// A single row - SingleRow, - /// All the rows that matches a query - #[default] - ManyRows, - /// How many rows were affected by the query - AffectedRows, - /// Return the first column of the first row - Pluck, -} - -/// Sql message -#[derive(Default, Debug)] -pub struct Statement { - /// The SQL statement - pub sql: String, - /// The list of arguments for the placeholders. It only supports named arguments for simplicity - /// sake - pub args: HashMap, - /// The expected response type - pub expected_response: ExpectedSqlResponse, -} - -impl Statement { - /// Creates a new statement - pub fn new(sql: T) -> Self - where - T: ToString, - { - Self { - sql: sql.to_string(), - ..Default::default() - } - } - - /// Binds a given placeholder to a value. - #[inline] - pub fn bind(mut self, name: C, value: V) -> Self - where - C: ToString, - V: Into, - { - self.args.insert(name.to_string(), value.into()); - self - } - - /// Binds a single variable with a vector. - /// - /// This will rewrite the function from `:foo` (where value is vec![1, 2, 3]) to `:foo0, :foo1, - /// :foo2` and binds each value from the value vector accordingly. - #[inline] - pub fn bind_vec(mut self, name: C, value: Vec) -> Self - where - C: ToString, - V: Into, - { - let mut new_sql = String::with_capacity(self.sql.len()); - let target = name.to_string(); - let mut i = 0; - - let placeholders = value - .into_iter() - .enumerate() - .map(|(key, value)| { - let key = format!("{target}{key}"); - self.args.insert(key.clone(), value.into()); - key - }) - .collect::>() - .join(","); - - while let Some(pos) = self.sql[i..].find(&target) { - let abs_pos = i + pos; - let after = abs_pos + target.len(); - let is_word_boundary = self.sql[after..] - .chars() - .next() - .map_or(true, |c| !c.is_alphanumeric() && c != '_'); - - if is_word_boundary { - new_sql.push_str(&self.sql[i..abs_pos]); - new_sql.push_str(&placeholders); - i = after; - } else { - new_sql.push_str(&self.sql[i..=abs_pos]); - i = abs_pos + 1; - } - } - - new_sql.push_str(&self.sql[i..]); - - self.sql = new_sql; - self - } - - fn get_stmt( - self, - conn: &PooledResource, - ) -> rusqlite::Result> { - let mut stmt = conn.prepare_cached(&self.sql)?; - for (name, value) in self.args { - let index = stmt - .parameter_index(&name) - .map_err(|_| rusqlite::Error::InvalidColumnName(name.clone()))? - .ok_or(rusqlite::Error::InvalidColumnName(name))?; - - stmt.raw_bind_parameter(index, value)?; - } - - Ok(stmt) - } - - /// Executes a query and returns the affected rows - pub fn plunk( - self, - conn: &PooledResource, - ) -> rusqlite::Result> { - let mut stmt = self.get_stmt(conn)?; - let mut rows = stmt.raw_query(); - rows.next()?.map(|row| row.get(0)).transpose() - } - - /// Executes a query and returns the affected rows - pub fn execute( - self, - conn: &PooledResource, - ) -> rusqlite::Result { - self.get_stmt(conn)?.raw_execute() - } - - /// Runs the query and returns the first row or None - pub fn fetch_one( - self, - conn: &PooledResource, - ) -> rusqlite::Result>> { - let mut stmt = self.get_stmt(conn)?; - let columns = stmt.column_count(); - let mut rows = stmt.raw_query(); - rows.next()? - .map(|row| { - (0..columns) - .map(|i| row.get(i)) - .collect::, _>>() - }) - .transpose() - } - - /// Runs the query and returns the first row or None - pub fn fetch_all( - self, - conn: &PooledResource, - ) -> rusqlite::Result>> { - let mut stmt = self.get_stmt(conn)?; - let columns = stmt.column_count(); - let mut rows = stmt.raw_query(); - let mut results = vec![]; - - while let Some(row) = rows.next()? { - results.push( - (0..columns) - .map(|i| row.get(i)) - .collect::, _>>()?, - ); - } - - Ok(results) - } -} diff --git a/crates/cdk-sqlite/src/wallet/memory.rs b/crates/cdk-sqlite/src/wallet/memory.rs index e916461ed..d164abb9e 100644 --- a/crates/cdk-sqlite/src/wallet/memory.rs +++ b/crates/cdk-sqlite/src/wallet/memory.rs @@ -7,8 +7,10 @@ use super::WalletSqliteDatabase; /// Creates a new in-memory [`WalletSqliteDatabase`] instance pub async fn empty() -> Result { #[cfg(not(feature = "sqlcipher"))] - let db = WalletSqliteDatabase::new(":memory:").await?; + let path = ":memory:"; + #[cfg(feature = "sqlcipher")] - let db = WalletSqliteDatabase::new(":memory:", "memory".to_owned()).await?; - Ok(db) + let path = (":memory:", "memory"); + + WalletSqliteDatabase::new(path).await } diff --git a/crates/cdk-sqlite/src/wallet/migrations.rs b/crates/cdk-sqlite/src/wallet/migrations.rs deleted file mode 100644 index dce9e2c95..000000000 --- a/crates/cdk-sqlite/src/wallet/migrations.rs +++ /dev/null @@ -1,20 +0,0 @@ -// @generated -// Auto-generated by build.rs -pub static MIGRATIONS: &[(&str, &str)] = &[ - ("20240612132920_init.sql", include_str!(r#"./migrations/20240612132920_init.sql"#)), - ("20240618200350_quote_state.sql", include_str!(r#"./migrations/20240618200350_quote_state.sql"#)), - ("20240626091921_nut04_state.sql", include_str!(r#"./migrations/20240626091921_nut04_state.sql"#)), - ("20240710144711_input_fee.sql", include_str!(r#"./migrations/20240710144711_input_fee.sql"#)), - ("20240810214105_mint_icon_url.sql", include_str!(r#"./migrations/20240810214105_mint_icon_url.sql"#)), - ("20240810233905_update_mint_url.sql", include_str!(r#"./migrations/20240810233905_update_mint_url.sql"#)), - ("20240902151515_icon_url.sql", include_str!(r#"./migrations/20240902151515_icon_url.sql"#)), - ("20240902210905_mint_time.sql", include_str!(r#"./migrations/20240902210905_mint_time.sql"#)), - ("20241011125207_mint_urls.sql", include_str!(r#"./migrations/20241011125207_mint_urls.sql"#)), - ("20241108092756_wallet_mint_quote_secretkey.sql", include_str!(r#"./migrations/20241108092756_wallet_mint_quote_secretkey.sql"#)), - ("20250214135017_mint_tos.sql", include_str!(r#"./migrations/20250214135017_mint_tos.sql"#)), - ("20250310111513_drop_nostr_last_checked.sql", include_str!(r#"./migrations/20250310111513_drop_nostr_last_checked.sql"#)), - ("20250314082116_allow_pending_spent.sql", include_str!(r#"./migrations/20250314082116_allow_pending_spent.sql"#)), - ("20250323152040_wallet_dleq_proofs.sql", include_str!(r#"./migrations/20250323152040_wallet_dleq_proofs.sql"#)), - ("20250401120000_add_transactions_table.sql", include_str!(r#"./migrations/20250401120000_add_transactions_table.sql"#)), - ("20250616144830_add_keyset_expiry.sql", include_str!(r#"./migrations/20250616144830_add_keyset_expiry.sql"#)), -]; diff --git a/crates/cdk-sqlite/src/wallet/mod.rs b/crates/cdk-sqlite/src/wallet/mod.rs index 74c57b362..183e5170c 100644 --- a/crates/cdk-sqlite/src/wallet/mod.rs +++ b/crates/cdk-sqlite/src/wallet/mod.rs @@ -1,1103 +1,18 @@ //! SQLite Wallet Database -use std::collections::HashMap; -use std::ops::DerefMut; -use std::path::Path; -use std::str::FromStr; -use std::sync::Arc; +use cdk_sql_common::SQLWalletDatabase; -use async_trait::async_trait; -use cdk_common::common::ProofInfo; -use cdk_common::database::WalletDatabase; -use cdk_common::mint_url::MintUrl; -use cdk_common::nuts::{MeltQuoteState, MintQuoteState}; -use cdk_common::secret::Secret; -use cdk_common::wallet::{self, MintQuote, Transaction, TransactionDirection, TransactionId}; -use cdk_common::{ - database, Amount, CurrencyUnit, Id, KeySet, KeySetInfo, Keys, MintInfo, Proof, ProofDleq, - PublicKey, SecretKey, SpendingConditions, State, -}; -use error::Error; -use tracing::instrument; +use crate::common::SqliteConnectionManager; -use crate::common::{create_sqlite_pool, migrate, SqliteConnectionManager}; -use crate::pool::Pool; -use crate::stmt::{Column, Statement}; -use crate::{ - column_as_binary, column_as_nullable_binary, column_as_nullable_number, - column_as_nullable_string, column_as_number, column_as_string, unpack_into, -}; - -pub mod error; pub mod memory; -#[rustfmt::skip] -mod migrations; - -/// Wallet SQLite Database -#[derive(Debug, Clone)] -pub struct WalletSqliteDatabase { - pool: Arc>, -} - -impl WalletSqliteDatabase { - /// Create new [`WalletSqliteDatabase`] - #[cfg(not(feature = "sqlcipher"))] - pub async fn new>(path: P) -> Result { - let db = Self { - pool: create_sqlite_pool(path.as_ref().to_str().ok_or(Error::InvalidDbPath)?), - }; - db.migrate()?; - Ok(db) - } - - /// Create new [`WalletSqliteDatabase`] - #[cfg(feature = "sqlcipher")] - pub async fn new>(path: P, password: String) -> Result { - let db = Self { - pool: create_sqlite_pool( - path.as_ref().to_str().ok_or(Error::InvalidDbPath)?, - password, - ), - }; - db.migrate()?; - Ok(db) - } - - /// Migrate [`WalletSqliteDatabase`] - fn migrate(&self) -> Result<(), Error> { - migrate(self.pool.get()?.deref_mut(), migrations::MIGRATIONS)?; - Ok(()) - } -} - -#[async_trait] -impl WalletDatabase for WalletSqliteDatabase { - type Err = database::Error; - - #[instrument(skip(self, mint_info))] - async fn add_mint( - &self, - mint_url: MintUrl, - mint_info: Option, - ) -> Result<(), Self::Err> { - let ( - name, - pubkey, - version, - description, - description_long, - contact, - nuts, - icon_url, - urls, - motd, - time, - tos_url, - ) = match mint_info { - Some(mint_info) => { - let MintInfo { - name, - pubkey, - version, - description, - description_long, - contact, - nuts, - icon_url, - urls, - motd, - time, - tos_url, - } = mint_info; - - ( - name, - pubkey.map(|p| p.to_bytes().to_vec()), - version.map(|v| serde_json::to_string(&v).ok()), - description, - description_long, - contact.map(|c| serde_json::to_string(&c).ok()), - serde_json::to_string(&nuts).ok(), - icon_url, - urls.map(|c| serde_json::to_string(&c).ok()), - motd, - time, - tos_url, - ) - } - None => ( - None, None, None, None, None, None, None, None, None, None, None, None, - ), - }; - - Statement::new( - r#" -INSERT INTO mint -( - mint_url, name, pubkey, version, description, description_long, - contact, nuts, icon_url, urls, motd, mint_time, tos_url -) -VALUES -( - :mint_url, :name, :pubkey, :version, :description, :description_long, - :contact, :nuts, :icon_url, :urls, :motd, :mint_time, :tos_url -) -ON CONFLICT(mint_url) DO UPDATE SET - name = excluded.name, - pubkey = excluded.pubkey, - version = excluded.version, - description = excluded.description, - description_long = excluded.description_long, - contact = excluded.contact, - nuts = excluded.nuts, - icon_url = excluded.icon_url, - urls = excluded.urls, - motd = excluded.motd, - mint_time = excluded.mint_time, - tos_url = excluded.tos_url -; - "#, - ) - .bind(":mint_url", mint_url.to_string()) - .bind(":name", name) - .bind(":pubkey", pubkey) - .bind(":version", version) - .bind(":description", description) - .bind(":description_long", description_long) - .bind(":contact", contact) - .bind(":nuts", nuts) - .bind(":icon_url", icon_url) - .bind(":urls", urls) - .bind(":motd", motd) - .bind(":mint_time", time.map(|v| v as i64)) - .bind(":tos_url", tos_url) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self))] - async fn remove_mint(&self, mint_url: MintUrl) -> Result<(), Self::Err> { - let conn = self.pool.get().map_err(Error::Pool)?; - - Statement::new(r#"DELETE FROM mint WHERE mint_url=:mint_url"#) - .bind(":mint_url", mint_url.to_string()) - .execute(&conn) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self))] - async fn get_mint(&self, mint_url: MintUrl) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - name, - pubkey, - version, - description, - description_long, - contact, - nuts, - icon_url, - motd, - urls, - mint_time, - tos_url - FROM - mint - WHERE mint_url = :mint_url - "#, - ) - .bind(":mint_url", mint_url.to_string()) - .fetch_one(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .map(sqlite_row_to_mint_info) - .transpose()?) - } - - #[instrument(skip(self))] - async fn get_mints(&self) -> Result>, Self::Err> { - Ok(Statement::new( - r#" - SELECT - name, - pubkey, - version, - description, - description_long, - contact, - nuts, - icon_url, - motd, - urls, - mint_time, - tos_url, - mint_url - FROM - mint - "#, - ) - .fetch_all(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .into_iter() - .map(|mut row| { - let url = column_as_string!( - row.pop().ok_or(Error::MissingColumn(0, 1))?, - MintUrl::from_str - ); - - Ok((url, sqlite_row_to_mint_info(row).ok())) - }) - .collect::, Error>>()?) - } - - #[instrument(skip(self))] - async fn update_mint_url( - &self, - old_mint_url: MintUrl, - new_mint_url: MintUrl, - ) -> Result<(), Self::Err> { - let tables = ["mint_quote", "proof"]; - let conn = self.pool.get().map_err(Error::Pool)?; - - for table in &tables { - let query = format!( - r#" - UPDATE {table} - SET mint_url = :new_mint_url - WHERE mint_url = :old_mint_url - "# - ); - - Statement::new(query) - .bind(":new_mint_url", new_mint_url.to_string()) - .bind(":old_mint_url", old_mint_url.to_string()) - .execute(&conn) - .map_err(Error::Sqlite)?; - } - - Ok(()) - } - - #[instrument(skip(self, keysets))] - async fn add_mint_keysets( - &self, - mint_url: MintUrl, - keysets: Vec, - ) -> Result<(), Self::Err> { - let conn = self.pool.get().map_err(Error::Pool)?; - for keyset in keysets { - Statement::new( - r#" - INSERT INTO keyset - (mint_url, id, unit, active, input_fee_ppk, final_expiry) - VALUES - (:mint_url, :id, :unit, :active, :input_fee_ppk, :final_expiry) - ON CONFLICT(id) DO UPDATE SET - mint_url = excluded.mint_url, - unit = excluded.unit, - active = excluded.active, - input_fee_ppk = excluded.input_fee_ppk, - final_expiry = excluded.final_expiry; - "#, - ) - .bind(":mint_url", mint_url.to_string()) - .bind(":id", keyset.id.to_string()) - .bind(":unit", keyset.unit.to_string()) - .bind(":active", keyset.active) - .bind(":input_fee_ppk", keyset.input_fee_ppk as i64) - .bind(":final_expiry", keyset.final_expiry.map(|v| v as i64)) - .execute(&conn) - .map_err(Error::Sqlite)?; - } - - Ok(()) - } - - #[instrument(skip(self))] - async fn get_mint_keysets( - &self, - mint_url: MintUrl, - ) -> Result>, Self::Err> { - let keysets = Statement::new( - r#" - SELECT - id, - unit, - active, - input_fee_ppk, - final_expiry - FROM - keyset - WHERE mint_url = :mint_url - "#, - ) - .bind(":mint_url", mint_url.to_string()) - .fetch_all(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .into_iter() - .map(sqlite_row_to_keyset) - .collect::, Error>>()?; - - match keysets.is_empty() { - false => Ok(Some(keysets)), - true => Ok(None), - } - } - - #[instrument(skip(self), fields(keyset_id = %keyset_id))] - async fn get_keyset_by_id(&self, keyset_id: &Id) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - id, - unit, - active, - input_fee_ppk, - final_expiry - FROM - keyset - WHERE id = :id - "#, - ) - .bind(":id", keyset_id.to_string()) - .fetch_one(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .map(sqlite_row_to_keyset) - .transpose()?) - } - - #[instrument(skip_all)] - async fn add_mint_quote(&self, quote: MintQuote) -> Result<(), Self::Err> { - Statement::new( - r#" -INSERT INTO mint_quote -(id, mint_url, amount, unit, request, state, expiry, secret_key) -VALUES -(:id, :mint_url, :amount, :unit, :request, :state, :expiry, :secret_key) -ON CONFLICT(id) DO UPDATE SET - mint_url = excluded.mint_url, - amount = excluded.amount, - unit = excluded.unit, - request = excluded.request, - state = excluded.state, - expiry = excluded.expiry, - secret_key = excluded.secret_key -; - "#, - ) - .bind(":id", quote.id.to_string()) - .bind(":mint_url", quote.mint_url.to_string()) - .bind(":amount", u64::from(quote.amount) as i64) - .bind(":unit", quote.unit.to_string()) - .bind(":request", quote.request) - .bind(":state", quote.state.to_string()) - .bind(":expiry", quote.expiry as i64) - .bind(":secret_key", quote.secret_key.map(|p| p.to_string())) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self))] - async fn get_mint_quote(&self, quote_id: &str) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - id, - mint_url, - amount, - unit, - request, - state, - expiry, - secret_key - FROM - mint_quote - WHERE - id = :id - "#, - ) - .bind(":id", quote_id.to_string()) - .fetch_one(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .map(sqlite_row_to_mint_quote) - .transpose()?) - } - - #[instrument(skip(self))] - async fn get_mint_quotes(&self) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - id, - mint_url, - amount, - unit, - request, - state, - expiry, - secret_key - FROM - mint_quote - "#, - ) - .fetch_all(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .into_iter() - .map(sqlite_row_to_mint_quote) - .collect::>()?) - } - - #[instrument(skip(self))] - async fn remove_mint_quote(&self, quote_id: &str) -> Result<(), Self::Err> { - Statement::new(r#"DELETE FROM mint_quote WHERE id=:id"#) - .bind(":id", quote_id.to_string()) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip_all)] - async fn add_melt_quote(&self, quote: wallet::MeltQuote) -> Result<(), Self::Err> { - Statement::new( - r#" -INSERT INTO melt_quote -(id, unit, amount, request, fee_reserve, state, expiry) -VALUES -(:id, :unit, :amount, :request, :fee_reserve, :state, :expiry) -ON CONFLICT(id) DO UPDATE SET - unit = excluded.unit, - amount = excluded.amount, - request = excluded.request, - fee_reserve = excluded.fee_reserve, - state = excluded.state, - expiry = excluded.expiry -; - "#, - ) - .bind(":id", quote.id.to_string()) - .bind(":unit", quote.unit.to_string()) - .bind(":amount", u64::from(quote.amount) as i64) - .bind(":request", quote.request) - .bind(":fee_reserve", u64::from(quote.fee_reserve) as i64) - .bind(":state", quote.state.to_string()) - .bind(":expiry", quote.expiry as i64) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self))] - async fn get_melt_quote(&self, quote_id: &str) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - id, - unit, - amount, - request, - fee_reserve, - state, - expiry, - payment_preimage - FROM - melt_quote - WHERE - id=:id - "#, - ) - .bind(":id", quote_id.to_owned()) - .fetch_one(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .map(sqlite_row_to_melt_quote) - .transpose()?) - } - - #[instrument(skip(self))] - async fn remove_melt_quote(&self, quote_id: &str) -> Result<(), Self::Err> { - Statement::new(r#"DELETE FROM melt_quote WHERE id=:id"#) - .bind(":id", quote_id.to_owned()) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip_all)] - async fn add_keys(&self, keyset: KeySet) -> Result<(), Self::Err> { - // Recompute ID for verification - keyset.verify_id()?; - - Statement::new( - r#" - INSERT INTO key - (id, keys) - VALUES - (:id, :keys) - ON CONFLICT(id) DO UPDATE SET - keys = excluded.keys - "#, - ) - .bind(":id", keyset.id.to_string()) - .bind( - ":keys", - serde_json::to_string(&keyset.keys).map_err(Error::from)?, - ) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self), fields(keyset_id = %keyset_id))] - async fn get_keys(&self, keyset_id: &Id) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - keys - FROM key - WHERE id = :id - "#, - ) - .bind(":id", keyset_id.to_string()) - .plunk(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .map(|keys| { - let keys = column_as_string!(keys); - serde_json::from_str(&keys).map_err(Error::from) - }) - .transpose()?) - } - - #[instrument(skip(self))] - async fn remove_keys(&self, id: &Id) -> Result<(), Self::Err> { - Statement::new(r#"DELETE FROM key WHERE id = :id"#) - .bind(":id", id.to_string()) - .plunk(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - async fn update_proofs( - &self, - added: Vec, - removed_ys: Vec, - ) -> Result<(), Self::Err> { - // TODO: Use a transaction for all these operations - for proof in added { - Statement::new( - r#" - INSERT INTO proof - (y, mint_url, state, spending_condition, unit, amount, keyset_id, secret, c, witness, dleq_e, dleq_s, dleq_r) - VALUES - (:y, :mint_url, :state, :spending_condition, :unit, :amount, :keyset_id, :secret, :c, :witness, :dleq_e, :dleq_s, :dleq_r) - ON CONFLICT(y) DO UPDATE SET - mint_url = excluded.mint_url, - state = excluded.state, - spending_condition = excluded.spending_condition, - unit = excluded.unit, - amount = excluded.amount, - keyset_id = excluded.keyset_id, - secret = excluded.secret, - c = excluded.c, - witness = excluded.witness, - dleq_e = excluded.dleq_e, - dleq_s = excluded.dleq_s, - dleq_r = excluded.dleq_r - ; - "#, - ) - .bind(":y", proof.y.to_bytes().to_vec()) - .bind(":mint_url", proof.mint_url.to_string()) - .bind(":state",proof.state.to_string()) - .bind( - ":spending_condition", - proof - .spending_condition - .map(|s| serde_json::to_string(&s).ok()), - ) - .bind(":unit", proof.unit.to_string()) - .bind(":amount", u64::from(proof.proof.amount) as i64) - .bind(":keyset_id", proof.proof.keyset_id.to_string()) - .bind(":secret", proof.proof.secret.to_string()) - .bind(":c", proof.proof.c.to_bytes().to_vec()) - .bind( - ":witness", - proof - .proof - .witness - .map(|w| serde_json::to_string(&w).unwrap()), - ) - .bind( - ":dleq_e", - proof.proof.dleq.as_ref().map(|dleq| dleq.e.to_secret_bytes().to_vec()), - ) - .bind( - ":dleq_s", - proof.proof.dleq.as_ref().map(|dleq| dleq.s.to_secret_bytes().to_vec()), - ) - .bind( - ":dleq_r", - proof.proof.dleq.as_ref().map(|dleq| dleq.r.to_secret_bytes().to_vec()), - ) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - } - - Statement::new(r#"DELETE FROM proof WHERE y IN (:ys)"#) - .bind_vec( - ":ys", - removed_ys.iter().map(|y| y.to_bytes().to_vec()).collect(), - ) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self, state, spending_conditions))] - async fn get_proofs( - &self, - mint_url: Option, - unit: Option, - state: Option>, - spending_conditions: Option>, - ) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - amount, - unit, - keyset_id, - secret, - c, - witness, - dleq_e, - dleq_s, - dleq_r, - y, - mint_url, - state, - spending_condition - FROM proof - "#, - ) - .fetch_all(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .into_iter() - .filter_map(|row| { - let row = sqlite_row_to_proof_info(row).ok()?; - - if row.matches_conditions(&mint_url, &unit, &state, &spending_conditions) { - Some(row) - } else { - None - } - }) - .collect::>()) - } - - async fn update_proofs_state(&self, ys: Vec, state: State) -> Result<(), Self::Err> { - Statement::new("UPDATE proof SET state = :state WHERE y IN (:ys)") - .bind_vec(":ys", ys.iter().map(|y| y.to_bytes().to_vec()).collect()) - .bind(":state", state.to_string()) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self), fields(keyset_id = %keyset_id))] - async fn increment_keyset_counter(&self, keyset_id: &Id, count: u32) -> Result<(), Self::Err> { - Statement::new( - r#" - UPDATE keyset - SET counter=counter+:count - WHERE id=:id - "#, - ) - .bind(":count", count) - .bind(":id", keyset_id.to_string()) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self), fields(keyset_id = %keyset_id))] - async fn get_keyset_counter(&self, keyset_id: &Id) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - counter - FROM - keyset - WHERE - id=:id - "#, - ) - .bind(":id", keyset_id.to_string()) - .plunk(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .map(|n| Ok::<_, Error>(column_as_number!(n))) - .transpose()?) - } - - #[instrument(skip(self))] - async fn add_transaction(&self, transaction: Transaction) -> Result<(), Self::Err> { - let mint_url = transaction.mint_url.to_string(); - let direction = transaction.direction.to_string(); - let unit = transaction.unit.to_string(); - let amount = u64::from(transaction.amount) as i64; - let fee = u64::from(transaction.fee) as i64; - let ys = transaction - .ys - .iter() - .flat_map(|y| y.to_bytes().to_vec()) - .collect::>(); - - Statement::new( - r#" -INSERT INTO transactions -(id, mint_url, direction, unit, amount, fee, ys, timestamp, memo, metadata) -VALUES -(:id, :mint_url, :direction, :unit, :amount, :fee, :ys, :timestamp, :memo, :metadata) -ON CONFLICT(id) DO UPDATE SET - mint_url = excluded.mint_url, - direction = excluded.direction, - unit = excluded.unit, - amount = excluded.amount, - fee = excluded.fee, - ys = excluded.ys, - timestamp = excluded.timestamp, - memo = excluded.memo, - metadata = excluded.metadata -; - "#, - ) - .bind(":id", transaction.id().as_slice().to_vec()) - .bind(":mint_url", mint_url) - .bind(":direction", direction) - .bind(":unit", unit) - .bind(":amount", amount) - .bind(":fee", fee) - .bind(":ys", ys) - .bind(":timestamp", transaction.timestamp as i64) - .bind(":memo", transaction.memo) - .bind( - ":metadata", - serde_json::to_string(&transaction.metadata).map_err(Error::from)?, - ) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } - - #[instrument(skip(self))] - async fn get_transaction( - &self, - transaction_id: TransactionId, - ) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - mint_url, - direction, - unit, - amount, - fee, - ys, - timestamp, - memo, - metadata - FROM - transactions - WHERE - id = :id - "#, - ) - .bind(":id", transaction_id.as_slice().to_vec()) - .fetch_one(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .map(sqlite_row_to_transaction) - .transpose()?) - } - - #[instrument(skip(self))] - async fn list_transactions( - &self, - mint_url: Option, - direction: Option, - unit: Option, - ) -> Result, Self::Err> { - Ok(Statement::new( - r#" - SELECT - mint_url, - direction, - unit, - amount, - fee, - ys, - timestamp, - memo, - metadata - FROM - transactions - "#, - ) - .fetch_all(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)? - .into_iter() - .filter_map(|row| { - // TODO: Avoid a table scan by passing the heavy lifting of checking to the DB engine - let transaction = sqlite_row_to_transaction(row).ok()?; - if transaction.matches_conditions(&mint_url, &direction, &unit) { - Some(transaction) - } else { - None - } - }) - .collect::>()) - } - - #[instrument(skip(self))] - async fn remove_transaction(&self, transaction_id: TransactionId) -> Result<(), Self::Err> { - Statement::new(r#"DELETE FROM transactions WHERE id=:id"#) - .bind(":id", transaction_id.as_slice().to_vec()) - .execute(&self.pool.get().map_err(Error::Pool)?) - .map_err(Error::Sqlite)?; - - Ok(()) - } -} - -fn sqlite_row_to_mint_info(row: Vec) -> Result { - unpack_into!( - let ( - name, - pubkey, - version, - description, - description_long, - contact, - nuts, - icon_url, - motd, - urls, - mint_time, - tos_url - ) = row - ); - - Ok(MintInfo { - name: column_as_nullable_string!(&name), - pubkey: column_as_nullable_string!(&pubkey, |v| serde_json::from_str(v).ok(), |v| { - serde_json::from_slice(v).ok() - }), - version: column_as_nullable_string!(&version).and_then(|v| serde_json::from_str(&v).ok()), - description: column_as_nullable_string!(description), - description_long: column_as_nullable_string!(description_long), - contact: column_as_nullable_string!(contact, |v| serde_json::from_str(&v).ok()), - nuts: column_as_nullable_string!(nuts, |v| serde_json::from_str(&v).ok()) - .unwrap_or_default(), - urls: column_as_nullable_string!(urls, |v| serde_json::from_str(&v).ok()), - icon_url: column_as_nullable_string!(icon_url), - motd: column_as_nullable_string!(motd), - time: column_as_nullable_number!(mint_time).map(|t| t), - tos_url: column_as_nullable_string!(tos_url), - }) -} - -#[instrument(skip_all)] -fn sqlite_row_to_keyset(row: Vec) -> Result { - unpack_into!( - let ( - id, - unit, - active, - input_fee_ppk, - final_expiry - ) = row - ); - - Ok(KeySetInfo { - id: column_as_string!(id, Id::from_str, Id::from_bytes), - unit: column_as_string!(unit, CurrencyUnit::from_str), - active: matches!(active, Column::Integer(1)), - input_fee_ppk: column_as_nullable_number!(input_fee_ppk).unwrap_or_default(), - final_expiry: column_as_nullable_number!(final_expiry), - }) -} - -fn sqlite_row_to_mint_quote(row: Vec) -> Result { - unpack_into!( - let ( - id, - mint_url, - amount, - unit, - request, - state, - expiry, - secret_key - ) = row - ); - - let amount: u64 = column_as_number!(amount); - - Ok(MintQuote { - id: column_as_string!(id), - mint_url: column_as_string!(mint_url, MintUrl::from_str), - amount: Amount::from(amount), - unit: column_as_string!(unit, CurrencyUnit::from_str), - request: column_as_string!(request), - state: column_as_string!(state, MintQuoteState::from_str), - expiry: column_as_number!(expiry), - secret_key: column_as_nullable_string!(secret_key) - .map(|v| SecretKey::from_str(&v)) - .transpose()?, - }) -} - -fn sqlite_row_to_melt_quote(row: Vec) -> Result { - unpack_into!( - let ( - id, - unit, - amount, - request, - fee_reserve, - state, - expiry, - payment_preimage - ) = row - ); - - let amount: u64 = column_as_number!(amount); - let fee_reserve: u64 = column_as_number!(fee_reserve); - - Ok(wallet::MeltQuote { - id: column_as_string!(id), - amount: Amount::from(amount), - unit: column_as_string!(unit, CurrencyUnit::from_str), - request: column_as_string!(request), - fee_reserve: Amount::from(fee_reserve), - state: column_as_string!(state, MeltQuoteState::from_str), - expiry: column_as_number!(expiry), - payment_preimage: column_as_nullable_string!(payment_preimage), - }) -} - -fn sqlite_row_to_proof_info(row: Vec) -> Result { - unpack_into!( - let ( - amount, - unit, - keyset_id, - secret, - c, - witness, - dleq_e, - dleq_s, - dleq_r, - y, - mint_url, - state, - spending_condition - ) = row - ); - - let dleq = match ( - column_as_nullable_binary!(dleq_e), - column_as_nullable_binary!(dleq_s), - column_as_nullable_binary!(dleq_r), - ) { - (Some(e), Some(s), Some(r)) => { - let e_key = SecretKey::from_slice(&e)?; - let s_key = SecretKey::from_slice(&s)?; - let r_key = SecretKey::from_slice(&r)?; - - Some(ProofDleq::new(e_key, s_key, r_key)) - } - _ => None, - }; - - let amount: u64 = column_as_number!(amount); - let proof = Proof { - amount: Amount::from(amount), - keyset_id: column_as_string!(keyset_id, Id::from_str), - secret: column_as_string!(secret, Secret::from_str), - witness: column_as_nullable_string!(witness, |v| { serde_json::from_str(&v).ok() }, |v| { - serde_json::from_slice(&v).ok() - }), - c: column_as_string!(c, PublicKey::from_str, PublicKey::from_slice), - dleq, - }; - - Ok(ProofInfo { - proof, - y: column_as_string!(y, PublicKey::from_str, PublicKey::from_slice), - mint_url: column_as_string!(mint_url, MintUrl::from_str), - state: column_as_string!(state, State::from_str), - spending_condition: column_as_nullable_string!( - spending_condition, - |r| { serde_json::from_str(&r).ok() }, - |r| { serde_json::from_slice(&r).ok() } - ), - unit: column_as_string!(unit, CurrencyUnit::from_str), - }) -} - -fn sqlite_row_to_transaction(row: Vec) -> Result { - unpack_into!( - let ( - mint_url, - direction, - unit, - amount, - fee, - ys, - timestamp, - memo, - metadata - ) = row - ); - - let amount: u64 = column_as_number!(amount); - let fee: u64 = column_as_number!(fee); - - Ok(Transaction { - mint_url: column_as_string!(mint_url, MintUrl::from_str), - direction: column_as_string!(direction, TransactionDirection::from_str), - unit: column_as_string!(unit, CurrencyUnit::from_str), - amount: Amount::from(amount), - fee: Amount::from(fee), - ys: column_as_binary!(ys) - .chunks(33) - .map(PublicKey::from_slice) - .collect::, _>>()?, - timestamp: column_as_number!(timestamp), - memo: column_as_nullable_string!(memo), - metadata: column_as_nullable_string!(metadata, |v| serde_json::from_str(&v).ok(), |v| { - serde_json::from_slice(&v).ok() - }) - .unwrap_or_default(), - }) -} +/// Mint SQLite implementation with rusqlite +pub type WalletSqliteDatabase = SQLWalletDatabase; #[cfg(test)] mod tests { + use std::str::FromStr; + use cdk_common::database::WalletDatabase; use cdk_common::nuts::{ProofDleq, State}; use cdk_common::secret::Secret; @@ -1114,7 +29,7 @@ mod tests { let path = std::env::temp_dir() .to_path_buf() .join(format!("cdk-test-{}.sqlite", uuid::Uuid::new_v4())); - let db = WalletSqliteDatabase::new(path, "password".to_string()) + let db = WalletSqliteDatabase::new((path, "password".to_string())) .await .unwrap(); @@ -1132,8 +47,6 @@ mod tests { #[tokio::test] async fn test_proof_with_dleq() { - use std::str::FromStr; - use cdk_common::common::ProofInfo; use cdk_common::mint_url::MintUrl; use cdk_common::nuts::{CurrencyUnit, Id, Proof, PublicKey, SecretKey}; @@ -1145,7 +58,7 @@ mod tests { .join(format!("cdk-test-dleq-{}.sqlite", uuid::Uuid::new_v4())); #[cfg(feature = "sqlcipher")] - let db = WalletSqliteDatabase::new(path, "password".to_string()) + let db = WalletSqliteDatabase::new((path, "password".to_string())) .await .unwrap(); @@ -1211,4 +124,148 @@ mod tests { assert_eq!(retrieved_dleq.s.to_string(), s.to_string()); assert_eq!(retrieved_dleq.r.to_string(), r.to_string()); } + + #[tokio::test] + async fn test_mint_quote_payment_method_read_and_write() { + use cdk_common::mint_url::MintUrl; + use cdk_common::nuts::{CurrencyUnit, MintQuoteState, PaymentMethod}; + use cdk_common::wallet::MintQuote; + use cdk_common::Amount; + + // Create a temporary database + let path = std::env::temp_dir().to_path_buf().join(format!( + "cdk-test-migration-{}.sqlite", + uuid::Uuid::new_v4() + )); + + #[cfg(feature = "sqlcipher")] + let db = WalletSqliteDatabase::new((path, "password".to_string())) + .await + .unwrap(); + + #[cfg(not(feature = "sqlcipher"))] + let db = WalletSqliteDatabase::new(path).await.unwrap(); + + // Test PaymentMethod variants + let mint_url = MintUrl::from_str("https://example.com").unwrap(); + let payment_methods = [ + PaymentMethod::Bolt11, + PaymentMethod::Bolt12, + PaymentMethod::Custom("custom".to_string()), + ]; + + for (i, payment_method) in payment_methods.iter().enumerate() { + let quote = MintQuote { + id: format!("test_quote_{}", i), + mint_url: mint_url.clone(), + amount: Some(Amount::from(100)), + unit: CurrencyUnit::Sat, + request: "test_request".to_string(), + state: MintQuoteState::Unpaid, + expiry: 1000000000, + secret_key: None, + payment_method: payment_method.clone(), + amount_issued: Amount::from(0), + amount_paid: Amount::from(0), + }; + + // Store the quote + db.add_mint_quote(quote.clone()).await.unwrap(); + + // Retrieve and verify + let retrieved = db.get_mint_quote("e.id).await.unwrap().unwrap(); + assert_eq!(retrieved.payment_method, *payment_method); + assert_eq!(retrieved.amount_issued, Amount::from(0)); + assert_eq!(retrieved.amount_paid, Amount::from(0)); + } + } + + #[tokio::test] + async fn test_get_proofs_by_ys() { + use cdk_common::common::ProofInfo; + use cdk_common::mint_url::MintUrl; + use cdk_common::nuts::{CurrencyUnit, Id, Proof, SecretKey}; + use cdk_common::Amount; + + // Create a temporary database + let path = std::env::temp_dir().to_path_buf().join(format!( + "cdk-test-proofs-by-ys-{}.sqlite", + uuid::Uuid::new_v4() + )); + + #[cfg(feature = "sqlcipher")] + let db = WalletSqliteDatabase::new((path, "password".to_string())) + .await + .unwrap(); + + #[cfg(not(feature = "sqlcipher"))] + let db = WalletSqliteDatabase::new(path).await.unwrap(); + + // Create multiple proofs + let keyset_id = Id::from_str("00deadbeef123456").unwrap(); + let mint_url = MintUrl::from_str("https://example.com").unwrap(); + + let mut proof_infos = vec![]; + let mut expected_ys = vec![]; + + // Generate valid public keys using SecretKey + for _i in 0..5 { + let secret = Secret::generate(); + + // Generate a valid public key from a secret key + let secret_key = SecretKey::generate(); + let c = secret_key.public_key(); + + let proof = Proof::new(Amount::from(64), keyset_id, secret, c); + + let proof_info = + ProofInfo::new(proof, mint_url.clone(), State::Unspent, CurrencyUnit::Sat).unwrap(); + + expected_ys.push(proof_info.y); + proof_infos.push(proof_info); + } + + // Store all proofs in the database + db.update_proofs(proof_infos.clone(), vec![]).await.unwrap(); + + // Test 1: Retrieve all proofs by their Y values + let retrieved_proofs = db.get_proofs_by_ys(expected_ys.clone()).await.unwrap(); + + assert_eq!(retrieved_proofs.len(), 5); + for retrieved_proof in &retrieved_proofs { + assert!(expected_ys.contains(&retrieved_proof.y)); + } + + // Test 2: Retrieve subset of proofs (first 3) + let subset_ys = expected_ys[0..3].to_vec(); + let subset_proofs = db.get_proofs_by_ys(subset_ys.clone()).await.unwrap(); + + assert_eq!(subset_proofs.len(), 3); + for retrieved_proof in &subset_proofs { + assert!(subset_ys.contains(&retrieved_proof.y)); + } + + // Test 3: Retrieve with non-existent Y values + let non_existent_secret_key = SecretKey::generate(); + let non_existent_y = non_existent_secret_key.public_key(); + let mixed_ys = vec![expected_ys[0], non_existent_y, expected_ys[1]]; + let mixed_proofs = db.get_proofs_by_ys(mixed_ys).await.unwrap(); + + // Should only return the 2 that exist + assert_eq!(mixed_proofs.len(), 2); + + // Test 4: Empty input returns empty result + let empty_result = db.get_proofs_by_ys(vec![]).await.unwrap(); + assert_eq!(empty_result.len(), 0); + + // Test 5: Verify retrieved proof data matches original + let single_y = vec![expected_ys[2]]; + let single_proof = db.get_proofs_by_ys(single_y).await.unwrap(); + + assert_eq!(single_proof.len(), 1); + assert_eq!(single_proof[0].y, proof_infos[2].y); + assert_eq!(single_proof[0].proof.amount, proof_infos[2].proof.amount); + assert_eq!(single_proof[0].mint_url, proof_infos[2].mint_url); + assert_eq!(single_proof[0].state, proof_infos[2].state); + } } diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 8b9625f81..44c0bda5c 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -11,23 +11,38 @@ license.workspace = true [features] -default = ["mint", "wallet", "auth"] -wallet = ["dep:reqwest", "cdk-common/wallet", "dep:rustls"] +default = ["mint", "wallet", "auth", "nostr", "bip353"] +wallet = ["dep:futures", "dep:reqwest", "cdk-common/wallet", "dep:rustls"] +nostr = ["wallet", "dep:nostr-sdk"] mint = ["dep:futures", "dep:reqwest", "cdk-common/mint", "cdk-signatory"] auth = ["dep:jsonwebtoken", "cdk-common/auth", "cdk-common/auth"] +bip353 = ["dep:hickory-resolver"] # We do not commit to a MSRV with swagger enabled swagger = ["mint", "dep:utoipa", "cdk-common/swagger"] bench = [] http_subscription = [] - +tor = [ + "wallet", + "dep:arti-client", + "dep:arti-hyper", + "dep:hyper", + "dep:http", + "dep:rustls", + "dep:tor-rtcompat", + "dep:tls-api", + "dep:tls-api-native-tls", +] +prometheus = ["dep:cdk-prometheus"] [dependencies] +arc-swap = "1.7.1" cdk-common.workspace = true cbor-diag.workspace = true async-trait.workspace = true anyhow.workspace = true bitcoin.workspace = true ciborium.workspace = true +lightning.workspace = true lightning-invoice.workspace = true regex.workspace = true reqwest = { workspace = true, optional = true } @@ -36,18 +51,20 @@ serde_json.workspace = true serde_with.workspace = true tracing.workspace = true thiserror.workspace = true + futures = { workspace = true, optional = true, features = ["alloc"] } url.workspace = true utoipa = { workspace = true, optional = true } uuid.workspace = true jsonwebtoken = { workspace = true, optional = true } - -# -Z minimal-versions -sync_wrapper = "0.1.2" -bech32 = "0.9.1" -arc-swap = "1.7.1" +nostr-sdk = { workspace = true, optional = true } +cdk-prometheus = {workspace = true, optional = true} +web-time.workspace = true +zeroize = "1" +tokio-util.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] +hickory-resolver = { version = "0.25.2", optional = true, features = ["dnssec-ring"] } tokio = { workspace = true, features = [ "rt-multi-thread", "time", @@ -61,13 +78,25 @@ tokio-tungstenite = { workspace = true, features = [ "rustls-tls-native-roots", "connect" ] } +# Tor dependencies (optional; enabled by feature "tor") +hyper = { version = "0.14", optional = true, features = ["client", "http1", "http2"] } +http = { version = "0.2", optional = true } +arti-client = { version = "0.19.0", optional = true, default-features = false, features = ["tokio", "rustls"] } +arti-hyper = { version = "0.19.0", optional = true } rustls = { workspace = true, optional = true } +tor-rtcompat = { version = "0.19.0", optional = true, features = ["tokio", "rustls"] } +tls-api = { version = "0.9", optional = true } +tls-api-native-tls = { version = "0.9", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] tokio = { workspace = true, features = ["rt", "macros", "sync", "time"] } cdk-signatory = { workspace = true, default-features = false } getrandom = { version = "0.2", features = ["js"] } ring = { version = "0.17.14", features = ["wasm32_unknown_unknown_js"] } +rustls = { workspace = true, optional = true } + +uuid = { workspace = true, features = ["js"] } +gloo-timers = { version = "0.3", features = ["futures"] } [[example]] name = "mint-token" @@ -93,13 +122,41 @@ required-features = ["wallet"] name = "auth_wallet" required-features = ["wallet", "auth"] +[[example]] +name = "bip353" +required-features = ["wallet", "bip353"] + +[[example]] +name = "mint-token-bolt12-with-stream" +required-features = ["wallet"] + +[[example]] +name = "mint-token-bolt12-with-custom-http" +required-features = ["wallet"] + +[[example]] +name = "mint-token-bolt12" +required-features = ["wallet"] + +[[example]] +name = "human_readable_payment" +required-features = ["wallet", "bip353"] + +[[example]] +name = "token-proofs" +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 = "0.6.0" +criterion.workspace = true reqwest = { workspace = true } +anyhow.workspace = true +ureq = { version = "3.1.0", features = ["json"] } +tokio = { workspace = true, features = ["full"] } [[bench]] diff --git a/crates/cdk/README.md b/crates/cdk/README.md index 670a90479..486deea29 100644 --- a/crates/cdk/README.md +++ b/crates/cdk/README.md @@ -66,7 +66,7 @@ use tokio::time::sleep; async fn main() { #[cfg(feature = "wallet")] { - let seed = random::<[u8; 32]>(); + let seed = random::<[u8; 64]>(); let mint_url = "https://fake.thesimplekid.dev"; let unit = CurrencyUnit::Sat; @@ -74,7 +74,7 @@ async fn main() { let localstore = memory::empty().await.unwrap(); - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap(); let quote = wallet.mint_quote(amount, None).await.unwrap(); @@ -101,7 +101,7 @@ async fn main() { // Send the token let prepared_send = wallet.prepare_send(Amount::ONE, SendOptions::default()).await.unwrap(); - let token = wallet.send(prepared_send, None).await.unwrap(); + let token = prepared_send.confirm(None).await.unwrap(); println!("{}", token); } diff --git a/crates/cdk/examples/auth_wallet.rs b/crates/cdk/examples/auth_wallet.rs index afb58b377..203c92a03 100644 --- a/crates/cdk/examples/auth_wallet.rs +++ b/crates/cdk/examples/auth_wallet.rs @@ -1,10 +1,11 @@ use std::sync::Arc; +use std::time::Duration; -use cdk::amount::SplitTarget; use cdk::error::Error; -use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload}; -use cdk::wallet::{SendOptions, Wallet, WalletSubscription}; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::{SendOptions, Wallet}; use cdk::{Amount, OidcClient}; +use cdk_common::amount::SplitTarget; use cdk_common::{MintInfo, ProofsMethods}; use cdk_sqlite::wallet::memory; use rand::Rng; @@ -25,7 +26,7 @@ async fn main() -> Result<(), Error> { let localstore = memory::empty().await?; // Generate a random seed for the wallet - let seed = rand::rng().random::<[u8; 32]>(); + let seed = rand::rng().random::<[u8; 64]>(); // Define the mint URL and currency unit let mint_url = "http://127.0.0.1:8085"; @@ -33,10 +34,10 @@ async fn main() -> Result<(), Error> { let amount = Amount::from(50); // Create a new wallet - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None)?; let mint_info = wallet - .get_mint_info() + .fetch_mint_info() .await .expect("mint info") .expect("could not get mint info"); @@ -57,29 +58,13 @@ async fn main() -> Result<(), Error> { .await .expect("Could not mint blind auth"); - // Request a mint quote from the wallet - let quote = wallet.mint_quote(amount, None).await?; - - // Subscribe to updates on the mint quote state - let mut subscription = wallet - .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote - .id - .clone()])) - .await; - - // Wait for the mint quote to be paid - while let Some(msg) = subscription.recv().await { - if let NotificationPayload::MintQuoteBolt11Response(response) = msg { - if response.state == MintQuoteState::Paid { - break; - } - } - } - - // Mint the received amount - let receive_amount = wallet.mint("e.id, SplitTarget::default(), None).await?; + let quote = wallet.mint_quote(amount, None).await.unwrap(); + let proofs = wallet + .wait_and_mint_quote(quote, SplitTarget::default(), None, Duration::from_secs(10)) + .await + .unwrap(); - println!("Received: {}", receive_amount.total_amount()?); + println!("Received: {}", proofs.total_amount()?); // Get the total balance of the wallet let balance = wallet.total_balance().await?; @@ -88,7 +73,7 @@ async fn main() -> Result<(), Error> { let prepared_send = wallet .prepare_send(10.into(), SendOptions::default()) .await?; - let token = wallet.send(prepared_send, None).await?; + let token = prepared_send.confirm(None).await?; println!("Created token: {}", token); @@ -112,7 +97,7 @@ async fn get_access_token(mint_info: &MintInfo) -> String { .expect("Nut21 defined") .openid_discovery; - let oidc_client = OidcClient::new(openid_discovery); + let oidc_client = OidcClient::new(openid_discovery, None); // Get the token endpoint from the OIDC configuration let token_url = oidc_client @@ -120,11 +105,16 @@ async fn get_access_token(mint_info: &MintInfo) -> String { .await .expect("Failed to get OIDC config") .token_endpoint; + let client_id = oidc_client + .get_oidc_config() + .await + .expect("Failed to get OIDC config") + .token_endpoint; // Create the request parameters let params = [ ("grant_type", "password"), - ("client_id", "cashu-client"), + ("client_id", &client_id), ("username", TEST_USERNAME), ("password", TEST_PASSWORD), ]; diff --git a/crates/cdk/examples/bip353.rs b/crates/cdk/examples/bip353.rs new file mode 100644 index 000000000..b617241d8 --- /dev/null +++ b/crates/cdk/examples/bip353.rs @@ -0,0 +1,143 @@ +//! # BIP-353 CDK Example +//! +//! This example demonstrates how to use BIP-353 (Human Readable Bitcoin Payment Instructions) +//! with the CDK wallet. BIP-353 allows users to share simple email-like addresses such as +//! `user@domain.com` instead of complex Bitcoin addresses or Lightning invoices. +//! +//! ## How it works +//! +//! 1. Parse a human-readable address like `alice@example.com` +//! 2. Query DNS TXT records at `alice.user._bitcoin-payment.example.com` +//! 3. Extract Bitcoin URIs from the TXT records +//! 4. Parse payment instructions (Lightning offers, on-chain addresses) +//! 5. Use CDK wallet to execute payments +//! +//! ## Usage +//! +//! ```bash +//! cargo run --example bip353 --features="wallet bip353" +//! ``` +//! +//! Note: The example uses a placeholder address that will fail DNS resolution. +//! To test with real addresses, you need a domain with proper BIP-353 DNS records. + +use std::sync::Arc; +use std::time::Duration; + +use cdk::amount::SplitTarget; +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::{CurrencyUnit, MintQuoteState}; +use cdk::wallet::Wallet; +use cdk::Amount; +use cdk_sqlite::wallet::memory; +use rand::random; +use tokio::time::sleep; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + println!("BIP-353 CDK Example"); + println!("==================="); + + // Example BIP-353 address - replace with a real one that has BOLT12 offer + // For testing, you might need to set up your own DNS records + let bip353_address = "tsk@thesimplekid.com"; // This is just an example + + println!("Attempting to use BIP-353 address: {}", bip353_address); + + // Generate a random seed for the wallet + let seed = random::<[u8; 64]>(); + + // Mint URL and currency unit + let mint_url = "https://fake.thesimplekid.dev"; + let unit = CurrencyUnit::Sat; + let initial_amount = Amount::from(1000); // Start with 1000 sats + + // Initialize the memory store + let localstore = Arc::new(memory::empty().await?); + + // Create a new wallet + let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?; + + // First, we need to fund the wallet + println!("Requesting mint quote for {} sats...", initial_amount); + let mint_quote = wallet.mint_quote(initial_amount, None).await?; + println!( + "Pay this invoice to fund the wallet: {}", + mint_quote.request + ); + + // In a real application, you would wait for the payment + // For this example, we'll just demonstrate the BIP353 melt process + println!("Waiting for payment... (in real use, pay the above invoice)"); + + // Check quote state (with timeout for demo purposes) + let timeout = Duration::from_secs(30); + let start = std::time::Instant::now(); + + while start.elapsed() < timeout { + let status = wallet.mint_quote_state(&mint_quote.id).await?; + + if status.state == MintQuoteState::Paid { + break; + } + + println!("Quote state: {} (waiting...)", status.state); + sleep(Duration::from_secs(2)).await; + } + + // Mint the tokens + let proofs = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await?; + let received_amount = proofs.total_amount()?; + println!("Successfully minted {} sats", received_amount); + + // Now prepare to pay using the BIP353 address + let payment_amount_sats = 100; // Example: paying 100 sats + + println!( + "Attempting to pay {} sats using BIP-353 address...", + payment_amount_sats + ); + + // Use the new wallet method to resolve BIP353 address and get melt quote + match wallet + .melt_bip353_quote(bip353_address, payment_amount_sats * 1_000) + .await + { + Ok(melt_quote) => { + println!("BIP-353 melt quote received:"); + println!(" Quote ID: {}", melt_quote.id); + println!(" Amount: {} sats", melt_quote.amount); + println!(" Fee Reserve: {} sats", melt_quote.fee_reserve); + println!(" State: {}", melt_quote.state); + + // Execute the payment + match wallet.melt(&melt_quote.id).await { + Ok(melt_result) => { + println!("BIP-353 payment successful!"); + println!(" State: {}", melt_result.state); + println!(" Amount paid: {} sats", melt_result.amount); + println!(" Fee paid: {} sats", melt_result.fee_paid); + + if let Some(preimage) = melt_result.preimage { + println!(" Payment preimage: {}", preimage); + } + } + Err(e) => { + println!("BIP-353 payment failed: {}", e); + } + } + } + Err(e) => { + println!("Failed to get BIP-353 melt quote: {}", e); + println!("This could be because:"); + println!("1. The BIP-353 address format is invalid"); + println!("2. DNS resolution failed (expected for this example)"); + println!("3. No Lightning offer found in the DNS records"); + println!("4. DNSSEC validation failed"); + } + } + + Ok(()) +} diff --git a/crates/cdk/examples/human_readable_payment.rs b/crates/cdk/examples/human_readable_payment.rs new file mode 100644 index 000000000..69446442c --- /dev/null +++ b/crates/cdk/examples/human_readable_payment.rs @@ -0,0 +1,300 @@ +//! # Human Readable Payment Example +//! +//! This example demonstrates how to use both BIP-353 and Lightning Address (LNURL-pay) +//! with the CDK wallet. Both allow users to share simple email-like addresses instead +//! of complex Bitcoin addresses or Lightning invoices. +//! +//! ## BIP-353 (Bitcoin URI Payment Instructions) +//! +//! BIP-353 uses DNS TXT records to resolve human-readable addresses to BOLT12 offers. +//! 1. Parse a human-readable address like `user@domain.com` +//! 2. Query DNS TXT records at `user.user._bitcoin-payment.domain.com` +//! 3. Extract Lightning offers (BOLT12) from the TXT records +//! 4. Use the offer to create a melt quote +//! +//! ## Lightning Address (LNURL-pay) +//! +//! Lightning Address uses HTTPS to fetch BOLT11 invoices. +//! 1. Parse a Lightning address like `user@domain.com` +//! 2. Query HTTPS endpoint at `https://domain.com/.well-known/lnurlp/user` +//! 3. Get callback URL and amount constraints +//! 4. Request BOLT11 invoice with the specified amount +//! +//! ## Unified API +//! +//! The `melt_human_readable_quote()` method automatically tries BIP-353 first +//! (if the mint supports BOLT12), then falls back to Lightning Address if needed. +//! +//! ## Usage +//! +//! ```bash +//! cargo run --example human_readable_payment --features="wallet bip353" +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use cdk::amount::SplitTarget; +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::Wallet; +use cdk::Amount; +use cdk_sqlite::wallet::memory; +use rand::random; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + println!("Human Readable Payment Example"); + println!("================================\n"); + + // Example addresses + let bip353_address = "tsk@thesimplekid.com"; + let lnurl_address = + "npub1qjgcmlpkeyl8mdkvp4s0xls4ytcux6my606tgfx9xttut907h0zs76lgjw@npubx.cash"; + + // Generate a random seed for the wallet + let seed = random::<[u8; 64]>(); + + // Mint URL and currency unit + let mint_url = "https://fake.thesimplekid.dev"; + let unit = CurrencyUnit::Sat; + let initial_amount = Amount::from(2000); // Start with 2000 sats (enough for both payments) + + // Initialize the memory store + let localstore = Arc::new(memory::empty().await?); + + // Create a new wallet + let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?; + + println!("Step 1: Funding the wallet"); + println!("---------------------------"); + + // First, we need to fund the wallet + println!("Requesting mint quote for {} sats...", initial_amount); + let mint_quote = wallet.mint_quote(initial_amount, None).await?; + println!( + "Pay this invoice to fund the wallet:\n{}", + mint_quote.request + ); + println!("\nQuote ID: {}", mint_quote.id); + + // Wait for payment and mint tokens automatically + println!("\nWaiting for payment... (in real use, pay the above invoice)"); + let proofs = wallet + .wait_and_mint_quote( + mint_quote, + SplitTarget::default(), + None, + Duration::from_secs(300), // 5 minutes timeout + ) + .await?; + + let received_amount = proofs.total_amount()?; + println!("✓ Successfully minted {} sats\n", received_amount); + + // ============================================================================ + // Part 1: BIP-353 Payment + // ============================================================================ + + println!("\n╔════════════════════════════════════════════════════════════════╗"); + println!("║ Part 1: BIP-353 Payment (BOLT12 Offer via DNS) ║"); + println!("╚════════════════════════════════════════════════════════════════╝\n"); + + let bip353_amount_sats = 100; // Example: paying 100 sats + println!("BIP-353 Address: {}", bip353_address); + println!("Payment Amount: {} sats", bip353_amount_sats); + println!("\nHow BIP-353 works:"); + println!("1. Parse address into user@domain"); + println!("2. Query DNS TXT records at: tsk.user._bitcoin-payment.thesimplekid.com"); + println!("3. Extract BOLT12 offer from DNS records"); + println!("4. Create melt quote with the offer\n"); + + // Use the specific BIP353 method + println!("Attempting BIP-353 payment..."); + match wallet + .melt_bip353_quote(bip353_address, bip353_amount_sats * 1_000) + .await + { + Ok(melt_quote) => { + println!("✓ BIP-353 melt quote received:"); + println!(" Quote ID: {}", melt_quote.id); + println!(" Amount: {} sats", melt_quote.amount); + println!(" Fee Reserve: {} sats", melt_quote.fee_reserve); + println!(" State: {}", melt_quote.state); + println!(" Payment Method: {}", melt_quote.payment_method); + + // Execute the payment + println!("\nExecuting payment..."); + match wallet.melt(&melt_quote.id).await { + Ok(melt_result) => { + println!("✓ BIP-353 payment successful!"); + println!(" State: {}", melt_result.state); + println!(" Amount paid: {} sats", melt_result.amount); + println!(" Fee paid: {} sats", melt_result.fee_paid); + + if let Some(preimage) = melt_result.preimage { + println!(" Payment preimage: {}", preimage); + } + } + Err(e) => { + println!("✗ BIP-353 payment failed: {}", e); + } + } + } + Err(e) => { + println!("✗ Failed to get BIP-353 melt quote: {}", e); + println!("\nPossible reasons:"); + println!(" • DNS resolution failed or no DNS records found"); + println!(" • No Lightning offer (BOLT12) in DNS TXT records"); + println!(" • DNSSEC validation failed"); + println!(" • Mint doesn't support BOLT12"); + println!(" • Network connectivity issues"); + } + } + + // ============================================================================ + // Part 2: Lightning Address (LNURL-pay) Payment + // ============================================================================ + + println!("\n\n╔════════════════════════════════════════════════════════════════╗"); + println!("║ Part 2: Lightning Address Payment (BOLT11 via LNURL-pay) ║"); + println!("╚════════════════════════════════════════════════════════════════╝\n"); + + let lnurl_amount_sats = 100; // Example: paying 100 sats + println!("Lightning Address: {}", lnurl_address); + println!("Payment Amount: {} sats", lnurl_amount_sats); + println!("\nHow Lightning Address works:"); + println!("1. Parse address into user@domain"); + println!("2. Query HTTPS: https://npubx.cash/.well-known/lnurlp/npub1qj..."); + println!("3. Get callback URL and amount constraints"); + println!("4. Request BOLT11 invoice for the amount"); + println!("5. Create melt quote with the invoice\n"); + + // Use the specific Lightning Address method + println!("Attempting Lightning Address payment..."); + match wallet + .melt_lightning_address_quote(lnurl_address, lnurl_amount_sats * 1_000) + .await + { + Ok(melt_quote) => { + println!("✓ Lightning Address melt quote received:"); + println!(" Quote ID: {}", melt_quote.id); + println!(" Amount: {} sats", melt_quote.amount); + println!(" Fee Reserve: {} sats", melt_quote.fee_reserve); + println!(" State: {}", melt_quote.state); + println!(" Payment Method: {}", melt_quote.payment_method); + + // Execute the payment + println!("\nExecuting payment..."); + match wallet.melt(&melt_quote.id).await { + Ok(melt_result) => { + println!("✓ Lightning Address payment successful!"); + println!(" State: {}", melt_result.state); + println!(" Amount paid: {} sats", melt_result.amount); + println!(" Fee paid: {} sats", melt_result.fee_paid); + + if let Some(preimage) = melt_result.preimage { + println!(" Payment preimage: {}", preimage); + } + } + Err(e) => { + println!("✗ Lightning Address payment failed: {}", e); + } + } + } + Err(e) => { + println!("✗ Failed to get Lightning Address melt quote: {}", e); + println!("\nPossible reasons:"); + println!(" • HTTPS request to .well-known/lnurlp failed"); + println!(" • Invalid Lightning Address format"); + println!(" • Amount outside min/max constraints"); + println!(" • Service unavailable or network issues"); + } + } + + // ============================================================================ + // Part 3: Unified Human Readable API (Smart Fallback) + // ============================================================================ + + println!("\n\n╔════════════════════════════════════════════════════════════════╗"); + println!("║ Part 3: Unified API (Automatic BIP-353 → LNURL Fallback) ║"); + println!("╚════════════════════════════════════════════════════════════════╝\n"); + + println!("The `melt_human_readable_quote()` method intelligently chooses:"); + println!("1. If mint supports BOLT12 AND address has BIP-353 DNS: Use BIP-353"); + println!("2. If BIP-353 DNS fails OR address has no DNS: Fall back to LNURL"); + println!("3. If mint doesn't support BOLT12: Use LNURL directly\n"); + + // Test 1: Address with BIP-353 support (has DNS records) + let unified_amount_sats = 50; + println!("Test 1: Address with BIP-353 DNS support"); + println!("Address: {}", bip353_address); + println!("Payment Amount: {} sats", unified_amount_sats); + println!("Expected: BIP-353 (BOLT12) via DNS resolution\n"); + + println!("Attempting unified payment..."); + match wallet + .melt_human_readable_quote(bip353_address, unified_amount_sats * 1_000) + .await + { + Ok(melt_quote) => { + println!("✓ Unified melt quote received:"); + println!(" Quote ID: {}", melt_quote.id); + println!(" Amount: {} sats", melt_quote.amount); + println!(" Fee Reserve: {} sats", melt_quote.fee_reserve); + println!(" Payment Method: {}", melt_quote.payment_method); + + let method_str = melt_quote.payment_method.to_string().to_lowercase(); + let used_method = if method_str.contains("bolt12") { + "BIP-353 (BOLT12)" + } else if method_str.contains("bolt11") { + "Lightning Address (LNURL-pay)" + } else { + "Unknown method" + }; + println!("\n → Used: {}", used_method); + } + Err(e) => { + println!("✗ Failed to get unified melt quote: {}", e); + println!(" Both BIP-353 and Lightning Address resolution failed"); + } + } + + // Test 2: Address without BIP-353 support (LNURL only) + println!("\n\nTest 2: Address without BIP-353 (LNURL-only)"); + println!("Address: {}", lnurl_address); + println!("Payment Amount: {} sats", unified_amount_sats); + println!("Expected: Lightning Address (LNURL-pay) fallback\n"); + + println!("Attempting unified payment..."); + match wallet + .melt_human_readable_quote(lnurl_address, unified_amount_sats * 1_000) + .await + { + Ok(melt_quote) => { + println!("✓ Unified melt quote received:"); + println!(" Quote ID: {}", melt_quote.id); + println!(" Amount: {} sats", melt_quote.amount); + println!(" Fee Reserve: {} sats", melt_quote.fee_reserve); + println!(" Payment Method: {}", melt_quote.payment_method); + + let method_str = melt_quote.payment_method.to_string().to_lowercase(); + let used_method = if method_str.contains("bolt12") { + "BIP-353 (BOLT12)" + } else if method_str.contains("bolt11") { + "Lightning Address (LNURL-pay)" + } else { + "Unknown method" + }; + println!("\n → Used: {}", used_method); + println!("\n Note: This address doesn't have BIP-353 DNS records,"); + println!(" so it automatically fell back to LNURL-pay."); + } + Err(e) => { + println!("✗ Failed to get unified melt quote: {}", e); + println!(" Both BIP-353 and Lightning Address resolution failed"); + } + } + + Ok(()) +} diff --git a/crates/cdk/examples/melt-token.rs b/crates/cdk/examples/melt-token.rs index dacdfb97e..8ebb44bff 100644 --- a/crates/cdk/examples/melt-token.rs +++ b/crates/cdk/examples/melt-token.rs @@ -1,13 +1,13 @@ use std::sync::Arc; +use std::time::Duration; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::prelude::FromHex; use bitcoin::secp256k1::Secp256k1; -use cdk::amount::SplitTarget; use cdk::error::Error; use cdk::nuts::nut00::ProofsMethods; -use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload, SecretKey}; -use cdk::wallet::{Wallet, WalletSubscription}; +use cdk::nuts::{CurrencyUnit, SecretKey}; +use cdk::wallet::Wallet; use cdk::Amount; use cdk_sqlite::wallet::memory; use lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret}; @@ -19,7 +19,7 @@ async fn main() -> Result<(), Error> { let localstore = memory::empty().await?; // Generate a random seed for the wallet - let seed = rand::rng().random::<[u8; 32]>(); + let seed = rand::rng().random::<[u8; 64]>(); // Define the mint URL and currency unit let mint_url = "https://fake.thesimplekid.dev"; @@ -27,30 +27,18 @@ async fn main() -> Result<(), Error> { let amount = Amount::from(10); // Create a new wallet - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None)?; - // Request a mint quote from the wallet let quote = wallet.mint_quote(amount, None).await?; - println!("Quote: {:#?}", quote); + let proofs = wallet + .wait_and_mint_quote( + quote, + Default::default(), + Default::default(), + Duration::from_secs(10), + ) + .await?; - // Subscribe to updates on the mint quote state - let mut subscription = wallet - .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote - .id - .clone()])) - .await; - - // Wait for the mint quote to be paid - while let Some(msg) = subscription.recv().await { - if let NotificationPayload::MintQuoteBolt11Response(response) = msg { - if response.state == MintQuoteState::Paid { - break; - } - } - } - - // Mint the received amount - let proofs = wallet.mint("e.id, SplitTarget::default(), None).await?; let receive_amount = proofs.total_amount()?; println!("Received {} from mint {}", receive_amount, mint_url); diff --git a/crates/cdk/examples/mint-token-bolt12-with-custom-http.rs b/crates/cdk/examples/mint-token-bolt12-with-custom-http.rs new file mode 100644 index 000000000..03f215a0a --- /dev/null +++ b/crates/cdk/examples/mint-token-bolt12-with-custom-http.rs @@ -0,0 +1,166 @@ +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +use cdk::error::Error; +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::{BaseHttpClient, HttpTransport, SendOptions, WalletBuilder}; +use cdk::{Amount, StreamExt}; +use cdk_common::mint_url::MintUrl; +use cdk_common::AuthToken; +use cdk_sqlite::wallet::memory; +use rand::random; +use serde::de::DeserializeOwned; +use serde::Serialize; +use tracing_subscriber::EnvFilter; +use ureq::config::Config; +use ureq::Agent; +use url::Url; + +#[derive(Debug, Clone)] +pub struct CustomHttp { + agent: Agent, +} + +impl Default for CustomHttp { + fn default() -> Self { + Self { + agent: Agent::new_with_config( + Config::builder() + .timeout_global(Some(Duration::from_secs(5))) + .no_delay(true) + .user_agent("Custom HTTP Transport") + .build(), + ), + } + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl HttpTransport for CustomHttp { + #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] + async fn resolve_dns_txt(&self, _domain: &str) -> Result, Error> { + panic!("Not supported"); + } + + fn with_proxy( + &mut self, + _proxy: Url, + _host_matcher: Option<&str>, + _accept_invalid_certs: bool, + ) -> Result<(), Error> { + panic!("Not supported"); + } + + async fn http_get(&self, url: Url, _auth: Option) -> Result + where + R: DeserializeOwned, + { + self.agent + .get(url.as_str()) + .call() + .map_err(|e| Error::HttpError(None, e.to_string()))? + .body_mut() + .read_json() + .map_err(|e| Error::HttpError(None, e.to_string())) + } + + /// HTTP Post request + async fn http_post( + &self, + url: Url, + _auth_token: Option, + payload: &P, + ) -> Result + where + P: Serialize + ?Sized + Send + Sync, + R: DeserializeOwned, + { + self.agent + .post(url.as_str()) + .send_json(payload) + .map_err(|e| Error::HttpError(None, e.to_string()))? + .body_mut() + .read_json() + .map_err(|e| Error::HttpError(None, e.to_string())) + } +} + +type CustomConnector = BaseHttpClient; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let default_filter = "debug"; + + let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn"; + + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + + // Parse input + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + + // Initialize the memory store for the wallet + let localstore = Arc::new(memory::empty().await?); + + // Generate a random seed for the wallet + let seed = random::<[u8; 64]>(); + + // Define the mint URL and currency unit + let mint_url = "https://fake.thesimplekid.dev"; + let unit = CurrencyUnit::Sat; + let amount = Amount::from(10); + + let mint_url = MintUrl::from_str(mint_url)?; + #[cfg(feature = "auth")] + let http_client = CustomConnector::new(mint_url.clone(), None); + + #[cfg(not(feature = "auth"))] + let http_client = CustomConnector::new(mint_url.clone()); + + // Create a new wallet + let wallet = WalletBuilder::new() + .mint_url(mint_url) + .unit(unit) + .localstore(localstore) + .seed(seed) + .target_proof_count(3) + .client(http_client) + .build()?; + + let quotes = vec![ + wallet.mint_bolt12_quote(None, None).await?, + wallet.mint_bolt12_quote(None, None).await?, + wallet.mint_bolt12_quote(None, None).await?, + ]; + + let mut stream = wallet.mints_proof_stream(quotes, Default::default(), None); + + let stop = stream.get_cancel_token(); + + let mut processed = 0; + + while let Some(proofs) = stream.next().await { + let (mint_quote, proofs) = proofs?; + + // Mint the received amount + let receive_amount = proofs.total_amount()?; + tracing::info!("Received {} from mint {}", receive_amount, mint_quote.id); + + // Send a token with the specified amount + let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?; + let token = prepared_send.confirm(None).await?; + tracing::info!("Token: {}", token); + + processed += 1; + + if processed == 3 { + stop.cancel() + } + } + + tracing::info!("Stopped the loop after {} quotes being minted", processed); + + Ok(()) +} diff --git a/crates/cdk/examples/mint-token-bolt12-with-stream.rs b/crates/cdk/examples/mint-token-bolt12-with-stream.rs new file mode 100644 index 000000000..2e83f5ff7 --- /dev/null +++ b/crates/cdk/examples/mint-token-bolt12-with-stream.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use cdk::error::Error; +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::{SendOptions, Wallet}; +use cdk::{Amount, StreamExt}; +use cdk_sqlite::wallet::memory; +use rand::random; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let default_filter = "debug"; + + let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn"; + + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + + // Parse input + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + + // Initialize the memory store for the wallet + let localstore = Arc::new(memory::empty().await?); + + // Generate a random seed for the wallet + let seed = random::<[u8; 64]>(); + + // Define the mint URL and currency unit + let mint_url = "https://fake.thesimplekid.dev"; + let unit = CurrencyUnit::Sat; + let amount = Amount::from(10); + + // Create a new wallet + let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?; + + let quotes = vec![ + wallet.mint_bolt12_quote(None, None).await?, + wallet.mint_bolt12_quote(None, None).await?, + wallet.mint_bolt12_quote(None, None).await?, + ]; + + let mut stream = wallet.mints_proof_stream(quotes, Default::default(), None); + + let stop = stream.get_cancel_token(); + + let mut processed = 0; + + while let Some(proofs) = stream.next().await { + let (mint_quote, proofs) = proofs?; + + // Mint the received amount + let receive_amount = proofs.total_amount()?; + println!("Received {} from mint {}", receive_amount, mint_quote.id); + + // Send a token with the specified amount + let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?; + let token = prepared_send.confirm(None).await?; + println!("Token:"); + println!("{}", token); + + processed += 1; + + if processed == 3 { + stop.cancel() + } + } + + println!("Stopped the loop after {} quotes being minted", processed); + + Ok(()) +} diff --git a/crates/cdk/examples/mint-token-bolt12.rs b/crates/cdk/examples/mint-token-bolt12.rs new file mode 100644 index 000000000..275d73a64 --- /dev/null +++ b/crates/cdk/examples/mint-token-bolt12.rs @@ -0,0 +1,59 @@ +use std::sync::Arc; +use std::time::Duration; + +use cdk::error::Error; +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::{SendOptions, Wallet}; +use cdk::Amount; +use cdk_sqlite::wallet::memory; +use rand::random; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> Result<(), Error> { + let default_filter = "debug"; + + let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn"; + + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + + // Parse input + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + + // Initialize the memory store for the wallet + let localstore = Arc::new(memory::empty().await?); + + // Generate a random seed for the wallet + let seed = random::<[u8; 64]>(); + + // Define the mint URL and currency unit + let mint_url = "https://fake.thesimplekid.dev"; + let unit = CurrencyUnit::Sat; + let amount = Amount::from(10); + + // Create a new wallet + let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?; + + let quote = wallet.mint_bolt12_quote(None, None).await?; + let proofs = wallet + .wait_and_mint_quote( + quote, + Default::default(), + Default::default(), + Duration::from_secs(10), + ) + .await?; + + // Mint the received amount + let receive_amount = proofs.total_amount()?; + println!("Received {} from mint {}", receive_amount, mint_url); + + // Send a token with the specified amount + let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?; + let token = prepared_send.confirm(None).await?; + println!("Token:"); + println!("{}", token); + + Ok(()) +} diff --git a/crates/cdk/examples/mint-token.rs b/crates/cdk/examples/mint-token.rs index 0c9bf466e..a00f7d97c 100644 --- a/crates/cdk/examples/mint-token.rs +++ b/crates/cdk/examples/mint-token.rs @@ -1,10 +1,10 @@ use std::sync::Arc; +use std::time::Duration; -use cdk::amount::SplitTarget; use cdk::error::Error; use cdk::nuts::nut00::ProofsMethods; -use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload}; -use cdk::wallet::{SendOptions, Wallet, WalletSubscription}; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::{SendOptions, Wallet}; use cdk::Amount; use cdk_sqlite::wallet::memory; use rand::random; @@ -25,7 +25,7 @@ async fn main() -> Result<(), Error> { let localstore = Arc::new(memory::empty().await?); // Generate a random seed for the wallet - let seed = random::<[u8; 32]>(); + let seed = random::<[u8; 64]>(); // Define the mint URL and currency unit let mint_url = "https://fake.thesimplekid.dev"; @@ -33,36 +33,25 @@ async fn main() -> Result<(), Error> { let amount = Amount::from(10); // Create a new wallet - let wallet = Wallet::new(mint_url, unit, localstore, &seed, None)?; + let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?; - // Request a mint quote from the wallet let quote = wallet.mint_quote(amount, None).await?; - println!("Quote: {:#?}", quote); - - // Subscribe to updates on the mint quote state - let mut subscription = wallet - .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote - .id - .clone()])) - .await; - - // Wait for the mint quote to be paid - while let Some(msg) = subscription.recv().await { - if let NotificationPayload::MintQuoteBolt11Response(response) = msg { - if response.state == MintQuoteState::Paid { - break; - } - } - } + let proofs = wallet + .wait_and_mint_quote( + quote, + Default::default(), + Default::default(), + Duration::from_secs(10), + ) + .await?; // Mint the received amount - let proofs = wallet.mint("e.id, SplitTarget::default(), None).await?; let receive_amount = proofs.total_amount()?; println!("Received {} from mint {}", receive_amount, mint_url); // Send a token with the specified amount let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?; - let token = wallet.send(prepared_send, None).await?; + let token = prepared_send.confirm(None).await?; println!("Token:"); println!("{}", token); diff --git a/crates/cdk/examples/p2pk.rs b/crates/cdk/examples/p2pk.rs index 2fb741e20..702a1c927 100644 --- a/crates/cdk/examples/p2pk.rs +++ b/crates/cdk/examples/p2pk.rs @@ -1,9 +1,9 @@ use std::sync::Arc; +use std::time::Duration; -use cdk::amount::SplitTarget; use cdk::error::Error; -use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload, SecretKey, SpendingConditions}; -use cdk::wallet::{ReceiveOptions, SendOptions, Wallet, WalletSubscription}; +use cdk::nuts::{CurrencyUnit, SecretKey, SpendingConditions}; +use cdk::wallet::{ReceiveOptions, SendOptions, Wallet}; use cdk::Amount; use cdk_sqlite::wallet::memory; use rand::random; @@ -24,7 +24,7 @@ async fn main() -> Result<(), Error> { let localstore = Arc::new(memory::empty().await?); // Generate a random seed for the wallet - let seed = random::<[u8; 32]>(); + let seed = random::<[u8; 64]>(); // Define the mint URL and currency unit let mint_url = "https://fake.thesimplekid.dev"; @@ -32,37 +32,22 @@ async fn main() -> Result<(), Error> { let amount = Amount::from(100); // Create a new wallet - let wallet = Wallet::new(mint_url, unit, localstore, &seed, None).unwrap(); + let wallet = Wallet::new(mint_url, unit, localstore, seed, None).unwrap(); - // Request a mint quote from the wallet let quote = wallet.mint_quote(amount, None).await?; - - println!("Minting nuts ..."); - - // Subscribe to updates on the mint quote state - let mut subscription = wallet - .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote - .id - .clone()])) - .await; - - // Wait for the mint quote to be paid - while let Some(msg) = subscription.recv().await { - if let NotificationPayload::MintQuoteBolt11Response(response) = msg { - if response.state == MintQuoteState::Paid { - break; - } - } - } + let proofs = wallet + .wait_and_mint_quote( + quote, + Default::default(), + Default::default(), + Duration::from_secs(10), + ) + .await?; // Mint the received amount - let received_proofs = wallet.mint("e.id, SplitTarget::default(), None).await?; println!( "Minted nuts: {:?}", - received_proofs - .into_iter() - .map(|p| p.amount) - .collect::>() + proofs.into_iter().map(|p| p.amount).collect::>() ); // Generate a secret key for spending conditions @@ -75,10 +60,12 @@ async fn main() -> Result<(), Error> { let bal = wallet.total_balance().await?; println!("Total balance: {}", bal); + let token_amount_to_send = Amount::from(10); + // Send a token with the specified amount and spending conditions let prepared_send = wallet .prepare_send( - 10.into(), + token_amount_to_send, SendOptions { conditions: Some(spending_conditions), include_fee: true, @@ -86,8 +73,12 @@ async fn main() -> Result<(), Error> { }, ) .await?; - println!("Fee: {}", prepared_send.fee()); - let token = wallet.send(prepared_send, None).await?; + + let swap_fee = prepared_send.swap_fee(); + + println!("Fee: {}", swap_fee); + + let token = prepared_send.confirm(None).await?; println!("Created token locked to pubkey: {}", secret.public_key()); println!("{}", token); @@ -103,6 +94,8 @@ async fn main() -> Result<(), Error> { ) .await?; + assert!(amount == token_amount_to_send); + println!("Redeemed locked token worth: {}", u64::from(amount)); Ok(()) diff --git a/crates/cdk/examples/proof-selection.rs b/crates/cdk/examples/proof-selection.rs index 1532de613..a0b106f34 100644 --- a/crates/cdk/examples/proof-selection.rs +++ b/crates/cdk/examples/proof-selection.rs @@ -2,19 +2,20 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; -use cdk::amount::SplitTarget; use cdk::nuts::nut00::ProofsMethods; -use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload}; -use cdk::wallet::{Wallet, WalletSubscription}; +use cdk::nuts::CurrencyUnit; +use cdk::wallet::Wallet; use cdk::Amount; +use cdk_common::nut02::KeySetInfosMethods; use cdk_sqlite::wallet::memory; use rand::random; #[tokio::main] async fn main() -> Result<(), Box> { // Generate a random seed for the wallet - let seed = random::<[u8; 32]>(); + let seed = random::<[u8; 64]>(); // Mint URL and currency unit let mint_url = "https://fake.thesimplekid.dev"; @@ -24,34 +25,23 @@ async fn main() -> Result<(), Box> { let localstore = Arc::new(memory::empty().await?); // Create a new wallet - let wallet = Wallet::new(mint_url, unit, localstore, &seed, None)?; + let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?; // Amount to mint for amount in [64] { let amount = Amount::from(amount); - // Request a mint quote from the wallet let quote = wallet.mint_quote(amount, None).await?; - println!("Pay request: {}", quote.request); - - // Subscribe to the wallet for updates on the mint quote state - let mut subscription = wallet - .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote - .id - .clone()])) - .await; - - // Wait for the mint quote to be paid - while let Some(msg) = subscription.recv().await { - if let NotificationPayload::MintQuoteBolt11Response(response) = msg { - if response.state == MintQuoteState::Paid { - break; - } - } - } + let proofs = wallet + .wait_and_mint_quote( + quote, + Default::default(), + Default::default(), + Duration::from_secs(10), + ) + .await?; // Mint the received amount - let proofs = wallet.mint("e.id, SplitTarget::default(), None).await?; let receive_amount = proofs.total_amount()?; println!("Minted {}", receive_amount); } @@ -62,9 +52,9 @@ async fn main() -> Result<(), Box> { // Select proofs to send let amount = Amount::from(64); let active_keyset_ids = wallet - .get_active_mint_keysets() + .get_mint_keysets() .await? - .into_iter() + .active() .map(|keyset| keyset.id) .collect(); let selected = diff --git a/crates/cdk/examples/token-proofs.rs b/crates/cdk/examples/token-proofs.rs new file mode 100644 index 000000000..7ea1de5c7 --- /dev/null +++ b/crates/cdk/examples/token-proofs.rs @@ -0,0 +1,96 @@ +//! Example: Decoding a token and getting proofs using MultiMintWallet +//! +//! This example demonstrates how to: +//! 1. Create a MultiMintWallet +//! 2. Decode a cashu token +//! 3. Use `get_token_data` to extract mint URL and proofs in one call +//! 4. Alternatively, get keysets manually and extract proofs + +use std::str::FromStr; +use std::sync::Arc; + +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::{CurrencyUnit, Token}; +use cdk::wallet::MultiMintWallet; +use cdk_sqlite::wallet::memory; +use rand::random; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Generate a random seed for the wallet + let seed = random::<[u8; 64]>(); + + // Initialize the memory store + let localstore = Arc::new(memory::empty().await?); + + // Create a new multi-mint wallet for satoshis + let wallet = MultiMintWallet::new(localstore, seed, CurrencyUnit::Sat).await?; + + // Example: A cashu token string (in practice, this would come from user input) + let token = Token::from_str("cashuBo2FteB1odHRwczovL2Zha2UudGhlc2ltcGxla2lkLmRldmF1Y3NhdGF0gaJhaUgAlNWndMQKMmFwg6RhYRkIAGFzeEAwYjk0ZjU5ZjU0OTBkNTkzMzI4ZTIwNDllZTNlZmFjYjM5NzljZjU5NzA5ZTM3N2U5YzBmMDQyNDBmZTUyZTVhYWNYIQNGQCYyf1j996pS-LuP_7VsUE-uzRpAm-K4rZiDEFFc1GFko2FlWCBbuMkhvz39ytCzm7xPaY5vdTbqxlxTzXOsks_8S3sf1GFzWCBg22l0CXH5-QLcfJtUJZ2lfylNfC6_o9FTfKClLzthaGFyWCCP2nJ6Qzd8mwLa_85cu8TrwRIprElVgrhqJeoHJwXmSKRhYRkCAGFzeEBhNmMyODliMjMwMTdlMDhjYTFhOTc4ZjAwNGRiNjI4ZDk1NWI5ZTlmNjMwMjY0MjNjZDc4OGExNDBhOWJiYjgxYWNYIQPMXkT68L8Y0a6royMbkoUTbvxOUgsyDwvRZRNTvwUsWWFko2FlWCCj9BFXexBOrlUyUiY_1qEIEHvd1YphWA2l3YhdFwVRh2FzWCBTNgyGeXvGSFtvYKj3MnJCXA8qjI9fzZHFsIw-F_OAGmFyWCDRHiDbVysUuQZucifYx5zMvOKyVIz7zvcJcfd01FoI3KRhYQhhc3hAMWJjOWQ1MjE5ZTZhYzNjZmZhNTM0NTRkY2JjMzE1YzZjZjY5MmM5MDEzYTUzYTA1YzIzN2YwZTBiOTViZTkwMWFjWCEDXd5sxFgxYgUHctpLENYStcr50UtJ4QRojy0g7mkdvWRhZKNhZVggZzSifCUG692E2sW4L6DT_FuKwLZdUFoMnds3tQyMlAdhc1ggtIo0BS2-6arws5fJx_w0phOiCZZcHIFknlrDXSh3C0NhclggM2dDF0kQyuRoOqrOOMHFrmNnvtGiXWxuvqtD7HidR8I")?; + + // Get the mint URL from the token + let mint_url = token.mint_url()?; + println!("Token mint URL: {}", mint_url); + + // Get token value + let value = token.value()?; + println!("Token value: {} sats", value); + + // Get token memo if present + if let Some(memo) = token.memo() { + println!("Token memo: {}", memo); + } + + // Add the mint to our wallet so we can fetch keysets + wallet.add_mint(mint_url.clone()).await?; + + // ========================================================================= + // Method 1: Use get_token_data() for a simple one-call approach + // ========================================================================= + println!("\n--- Using get_token_data() ---"); + + let token_data = wallet.get_token_data(&token).await?; + println!("Mint URL: {}", token_data.mint_url); + println!("Number of proofs: {}", token_data.proofs.len()); + + for (i, proof) in token_data.proofs.iter().enumerate() { + println!( + " Proof {}: {} sats, keyset: {}", + i + 1, + proof.amount, + proof.keyset_id + ); + } + + // ========================================================================= + // Method 2: Manual approach - get keysets first, then extract proofs + // ========================================================================= + println!("\n--- Using manual keyset lookup ---"); + + // Get the keysets for this mint + let keysets = wallet.get_mint_keysets(&mint_url).await?; + println!("Found {} keysets for mint", keysets.len()); + + for keyset in &keysets { + println!( + " - Keyset ID: {}, Unit: {:?}, Active: {}", + keyset.id, keyset.unit, keyset.active + ); + } + + // Extract proofs from the token using the keysets + let proofs = token.proofs(&keysets)?; + println!("\nToken contains {} proofs:", proofs.len()); + + // Calculate total amount from proofs + let total = proofs.total_amount()?; + println!("Total amount from proofs: {} sats", total); + + // Verify total matches token value + assert_eq!(total, value, "Proof total should match token value"); + + println!("\nSuccessfully decoded token and extracted proofs!"); + + Ok(()) +} diff --git a/crates/cdk/examples/wallet.rs b/crates/cdk/examples/wallet.rs index 3bffe8efb..37c5653dd 100644 --- a/crates/cdk/examples/wallet.rs +++ b/crates/cdk/examples/wallet.rs @@ -1,19 +1,17 @@ use std::sync::Arc; use std::time::Duration; -use cdk::amount::SplitTarget; use cdk::nuts::nut00::ProofsMethods; -use cdk::nuts::{CurrencyUnit, MintQuoteState}; +use cdk::nuts::CurrencyUnit; use cdk::wallet::{SendOptions, Wallet}; use cdk::Amount; use cdk_sqlite::wallet::memory; use rand::random; -use tokio::time::sleep; #[tokio::main] async fn main() -> Result<(), Box> { // Generate a random seed for the wallet - let seed = random::<[u8; 32]>(); + let seed = random::<[u8; 64]>(); // Mint URL and currency unit let mint_url = "https://fake.thesimplekid.dev"; @@ -24,42 +22,25 @@ async fn main() -> Result<(), Box> { let localstore = Arc::new(memory::empty().await?); // Create a new wallet - let wallet = Wallet::new(mint_url, unit, localstore, &seed, None)?; + let wallet = Wallet::new(mint_url, unit, localstore, seed, None)?; - // Request a mint quote from the wallet let quote = wallet.mint_quote(amount, None).await?; - - println!("Pay request: {}", quote.request); - - // Check the quote state in a loop with a timeout - let timeout = Duration::from_secs(60); // Set a timeout duration - let start = std::time::Instant::now(); - - loop { - let status = wallet.mint_quote_state("e.id).await?; - - if status.state == MintQuoteState::Paid { - break; - } - - if start.elapsed() >= timeout { - eprintln!("Timeout while waiting for mint quote to be paid"); - return Err("Timeout while waiting for mint quote to be paid".into()); - } - - println!("Quote state: {}", status.state); - - sleep(Duration::from_secs(5)).await; - } + let proofs = wallet + .wait_and_mint_quote( + quote, + Default::default(), + Default::default(), + Duration::from_secs(10), + ) + .await?; // Mint the received amount - let proofs = wallet.mint("e.id, SplitTarget::default(), None).await?; let receive_amount = proofs.total_amount()?; println!("Minted {}", receive_amount); // Send the token let prepared_send = wallet.prepare_send(amount, SendOptions::default()).await?; - let token = wallet.send(prepared_send, None).await?; + let token = prepared_send.confirm(None).await?; println!("{}", token); diff --git a/crates/cdk/src/bip353.rs b/crates/cdk/src/bip353.rs new file mode 100644 index 000000000..5b3ed8a24 --- /dev/null +++ b/crates/cdk/src/bip353.rs @@ -0,0 +1,271 @@ +//! BIP-353: Human Readable Bitcoin Payment Instructions +//! +//! This module provides functionality for resolving human-readable Bitcoin addresses +//! according to BIP-353. It allows users to share simple email-like addresses such as +//! `user@domain.com` instead of complex Bitcoin addresses or Lightning invoices. + +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{bail, Result}; + +use crate::wallet::MintConnector; + +/// BIP-353 human-readable Bitcoin address +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Bip353Address { + /// The user part of the address (before @) + pub user: String, + /// The domain part of the address (after @) + pub domain: String, +} + +impl Bip353Address { + /// Resolve a human-readable Bitcoin address to payment instructions + /// + /// This method performs the following steps: + /// 1. Constructs the DNS name according to BIP-353 format + /// 2. Queries TXT records with DNSSEC validation + /// 3. Extracts Bitcoin URIs from the records + /// 4. Parses the URIs into payment instructions + /// + /// # Errors + /// + /// This method will return an error if: + /// - DNS resolution fails + /// - DNSSEC validation fails + /// - No Bitcoin URI is found + /// - Multiple Bitcoin URIs are found (BIP-353 requires exactly one) + /// - The URI format is invalid + pub(crate) async fn resolve( + self, + client: &Arc, + ) -> Result { + // Construct DNS name according to BIP-353 + let dns_name = format!("{}.user._bitcoin-payment.{}", self.user, self.domain); + + let bitcoin_uris = client + .resolve_dns_txt(&dns_name) + .await? + .into_iter() + .filter(|txt_data| txt_data.to_lowercase().starts_with("bitcoin:")) + .collect::>(); + + // BIP-353 requires exactly one Bitcoin URI + match bitcoin_uris.len() { + 0 => bail!("No Bitcoin URI found"), + 1 => PaymentInstruction::from_uri(&bitcoin_uris[0]), + _ => bail!("Multiple Bitcoin URIs found"), + } + } +} + +impl FromStr for Bip353Address { + type Err = anyhow::Error; + + /// Parse a human-readable Bitcoin address from string format + /// + /// Accepts formats: + /// - `user@domain.com` + /// - `₿user@domain.com` (with Bitcoin symbol prefix) + /// + /// # Errors + /// + /// Returns an error if: + /// - The format is not `user@domain` + /// - User or domain parts are empty + fn from_str(address: &str) -> Result { + let addr = address.trim(); + + // Remove Bitcoin prefix if present + let addr = addr.strip_prefix("₿").unwrap_or(addr); + + // Split by @ + let parts: Vec<&str> = addr.split('@').collect(); + if parts.len() != 2 { + bail!("Address is not formatted correctly") + } + + let user = parts[0].trim(); + let domain = parts[1].trim(); + + if user.is_empty() || domain.is_empty() { + bail!("User name and domain must not be empty") + } + + Ok(Self { + user: user.to_string(), + domain: domain.to_string(), + }) + } +} + +impl std::fmt::Display for Bip353Address { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.user, self.domain) + } +} + +/// Payment instruction type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PaymentType { + /// On-chain Bitcoin address + OnChain, + /// Lightning Offer (BOLT12) + LightningOffer, +} + +/// BIP-353 payment instruction containing parsed payment methods +#[derive(Debug, Clone)] +pub struct PaymentInstruction { + /// Map of payment types to their corresponding values + pub parameters: HashMap, +} + +impl PaymentInstruction { + /// Create a new empty payment instruction + pub fn new() -> Self { + Self { + parameters: HashMap::new(), + } + } + + /// Parse a payment instruction from a Bitcoin URI + /// + /// Extracts various payment methods from the URI: + /// - Lightning offers (parameters containing "lno") + /// - On-chain addresses (address part of the URI) + /// + /// # Errors + /// + /// Returns an error if the URI doesn't start with "bitcoin:" + pub fn from_uri(uri: &str) -> Result { + if !uri.to_lowercase().starts_with("bitcoin:") { + bail!("URI must start with 'bitcoin:'") + } + + let mut parameters = HashMap::new(); + + // Parse URI parameters + if let Some(query_start) = uri.find('?') { + let query = &uri[query_start + 1..]; + for pair in query.split('&') { + if let Some(eq_pos) = pair.find('=') { + let key = pair[..eq_pos].to_string(); + let value = pair[eq_pos + 1..].to_string(); + + // Determine payment type based on parameter key + if key.contains("lno") { + parameters.insert(PaymentType::LightningOffer, value); + } + // Could add more payment types here as needed + } + } + } + + // Check if we have an on-chain address (address part after bitcoin:) + if let Some(query_start) = uri.find('?') { + let addr_part = &uri[8..query_start]; // Skip "bitcoin:" + if !addr_part.is_empty() { + parameters.insert(PaymentType::OnChain, addr_part.to_string()); + } + } else { + // No query parameters, check if there's just an address + let addr_part = &uri[8..]; // Skip "bitcoin:" + if !addr_part.is_empty() { + parameters.insert(PaymentType::OnChain, addr_part.to_string()); + } + } + + Ok(PaymentInstruction { parameters }) + } + + /// Get a payment method by type + pub fn get(&self, payment_type: &PaymentType) -> Option<&String> { + self.parameters.get(payment_type) + } +} + +impl Default for PaymentInstruction { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl PaymentInstruction { + /// Check if a payment type is available + pub fn has_payment_type(&self, payment_type: &PaymentType) -> bool { + self.parameters.contains_key(payment_type) + } + } + + #[test] + fn test_bip353_address_parsing() { + // Test basic parsing + let addr = Bip353Address::from_str("alice@example.com").unwrap(); + assert_eq!(addr.user, "alice"); + assert_eq!(addr.domain, "example.com"); + + // Test with Bitcoin symbol + let addr = Bip353Address::from_str("₿bob@bitcoin.org").unwrap(); + assert_eq!(addr.user, "bob"); + assert_eq!(addr.domain, "bitcoin.org"); + + // Test with whitespace + let addr = Bip353Address::from_str(" charlie@test.net ").unwrap(); + assert_eq!(addr.user, "charlie"); + assert_eq!(addr.domain, "test.net"); + + // Test display + let addr = Bip353Address { + user: "test".to_string(), + domain: "example.com".to_string(), + }; + assert_eq!(addr.to_string(), "test@example.com"); + } + + #[test] + fn test_bip353_address_parsing_errors() { + // Test invalid formats + assert!(Bip353Address::from_str("invalid").is_err()); + assert!(Bip353Address::from_str("@example.com").is_err()); + assert!(Bip353Address::from_str("user@").is_err()); + assert!(Bip353Address::from_str("user@domain@extra").is_err()); + assert!(Bip353Address::from_str("").is_err()); + } + + #[test] + fn test_payment_instruction_parsing() { + // Test Lightning offer URI + let uri = "bitcoin:?lno=lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pxqrjszs5v2a5m5xwc4mxv6rdjdcn2d3kxccnjdgecf7fz3rf5g4t7gdxhkzm8mpsq5q"; + let instruction = PaymentInstruction::from_uri(uri).unwrap(); + assert!(instruction.has_payment_type(&PaymentType::LightningOffer)); + + // Test on-chain address URI + let uri = "bitcoin:bc1qexampleaddress"; + let instruction = PaymentInstruction::from_uri(uri).unwrap(); + assert!(instruction.has_payment_type(&PaymentType::OnChain)); + assert_eq!( + instruction.get(&PaymentType::OnChain).unwrap(), + "bc1qexampleaddress" + ); + + // Test combined URI + let uri = "bitcoin:bc1qexampleaddress?lno=lno1qcp4256ypqpq86q2pucnq42ngssx2an9wfujqerp0y2pxqrjszs5v2a5m5xwc4mxv6rdjdcn2d3kxccnjdgecf7fz3rf5g4t7gdxhkzm8mpsq5q"; + let instruction = PaymentInstruction::from_uri(uri).unwrap(); + assert!(instruction.has_payment_type(&PaymentType::OnChain)); + assert!(instruction.has_payment_type(&PaymentType::LightningOffer)); + } + + #[test] + fn test_payment_instruction_errors() { + // Test invalid URI + assert!(PaymentInstruction::from_uri("invalid:uri").is_err()); + assert!(PaymentInstruction::from_uri("").is_err()); + } +} diff --git a/crates/cdk/src/event.rs b/crates/cdk/src/event.rs new file mode 100644 index 000000000..1c4fee676 --- /dev/null +++ b/crates/cdk/src/event.rs @@ -0,0 +1,127 @@ +//! Mint event types +use std::fmt::Debug; +use std::hash::Hash; +use std::ops::Deref; + +use cdk_common::nut17::NotificationId; +use cdk_common::pub_sub::Event; +use cdk_common::{ + MeltQuoteBolt11Response, MintQuoteBolt11Response, MintQuoteBolt12Response, NotificationPayload, + ProofState, +}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +/// Simple wrapper over `NotificationPayload` which is a foreign type +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(bound = "T: Serialize + DeserializeOwned")] +pub struct MintEvent(NotificationPayload) +where + T: Clone + Eq + PartialEq; + +impl From> for NotificationPayload +where + T: Clone + Eq + PartialEq, +{ + fn from(value: MintEvent) -> Self { + value.0 + } +} + +impl Deref for MintEvent +where + T: Clone + Eq + PartialEq, +{ + type Target = NotificationPayload; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for MintEvent +where + T: Clone + Eq + PartialEq, +{ + fn from(value: ProofState) -> Self { + Self(NotificationPayload::ProofState(value)) + } +} + +impl MintEvent +where + T: Clone + Eq + PartialEq, +{ + /// New instance + pub fn new(t: NotificationPayload) -> Self { + Self(t) + } + + /// Get inner + pub fn inner(&self) -> &NotificationPayload { + &self.0 + } + + /// Into inner + pub fn into_inner(self) -> NotificationPayload { + self.0 + } +} + +impl From> for MintEvent +where + T: Clone + Eq + PartialEq, +{ + fn from(value: NotificationPayload) -> Self { + Self(value) + } +} + +impl From> for MintEvent +where + T: Clone + Eq + PartialEq, +{ + fn from(value: MintQuoteBolt11Response) -> Self { + Self(NotificationPayload::MintQuoteBolt11Response(value)) + } +} + +impl From> for MintEvent +where + T: Clone + Eq + PartialEq, +{ + fn from(value: MeltQuoteBolt11Response) -> Self { + Self(NotificationPayload::MeltQuoteBolt11Response(value)) + } +} + +impl From> for MintEvent +where + T: Clone + Eq + PartialEq, +{ + fn from(value: MintQuoteBolt12Response) -> Self { + Self(NotificationPayload::MintQuoteBolt12Response(value)) + } +} + +impl Event for MintEvent +where + T: Clone + Serialize + DeserializeOwned + Debug + Ord + Hash + Send + Sync + Eq + PartialEq, +{ + type Topic = NotificationId; + + fn get_topics(&self) -> Vec { + vec![match &self.0 { + NotificationPayload::MeltQuoteBolt11Response(r) => { + NotificationId::MeltQuoteBolt11(r.quote.to_owned()) + } + NotificationPayload::MintQuoteBolt11Response(r) => { + NotificationId::MintQuoteBolt11(r.quote.to_owned()) + } + NotificationPayload::MintQuoteBolt12Response(r) => { + NotificationId::MintQuoteBolt12(r.quote.to_owned()) + } + NotificationPayload::ProofState(p) => NotificationId::ProofState(p.y.to_owned()), + }] + } +} diff --git a/crates/cdk/src/fees.rs b/crates/cdk/src/fees.rs index 40d4a2395..5eadf57e6 100644 --- a/crates/cdk/src/fees.rs +++ b/crates/cdk/src/fees.rs @@ -82,4 +82,136 @@ mod tests { let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); assert_eq!(sum_fee, 8.into()); } + + #[test] + fn test_fee_calculation_with_ppk_200() { + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + let fee_ppk = 200; + + let mut keyset_fees = HashMap::new(); + keyset_fees.insert(keyset_id, fee_ppk); + + let mut proofs_count = HashMap::new(); + + proofs_count.insert(keyset_id, 1); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 1.into(), "1 proof: ceil(200/1000) = 1 sat"); + + proofs_count.insert(keyset_id, 3); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 1.into(), "3 proofs: ceil(600/1000) = 1 sat"); + + proofs_count.insert(keyset_id, 5); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 1.into(), "5 proofs: ceil(1000/1000) = 1 sat"); + + proofs_count.insert(keyset_id, 6); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 2.into(), "6 proofs: ceil(1200/1000) = 2 sats"); + } + + #[test] + fn test_fee_calculation_with_ppk_1000() { + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + let fee_ppk = 1000; + + let mut keyset_fees = HashMap::new(); + keyset_fees.insert(keyset_id, fee_ppk); + + let mut proofs_count = HashMap::new(); + + proofs_count.insert(keyset_id, 1); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 1.into(), "1 proof at 1000 ppk = 1 sat"); + + proofs_count.insert(keyset_id, 2); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 2.into(), "2 proofs at 1000 ppk = 2 sats"); + + proofs_count.insert(keyset_id, 10); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 10.into(), "10 proofs at 1000 ppk = 10 sats"); + } + + #[test] + fn test_fee_calculation_zero_fee() { + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + let fee_ppk = 0; + + let mut keyset_fees = HashMap::new(); + keyset_fees.insert(keyset_id, fee_ppk); + + let mut proofs_count = HashMap::new(); + + proofs_count.insert(keyset_id, 100); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 0.into(), "0 ppk means no fee: ceil(0/1000) = 0"); + } + + #[test] + fn test_fee_calculation_with_ppk_100() { + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + + let fee_ppk = 100; + + let mut keyset_fees = HashMap::new(); + keyset_fees.insert(keyset_id, fee_ppk); + + let mut proofs_count = HashMap::new(); + + proofs_count.insert(keyset_id, 1); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 1.into(), "1 proof: ceil(100/1000) = 1 sat"); + + proofs_count.insert(keyset_id, 10); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 1.into(), "10 proofs: ceil(1000/1000) = 1 sat"); + + proofs_count.insert(keyset_id, 11); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 2.into(), "11 proofs: ceil(1100/1000) = 2 sats"); + + proofs_count.insert(keyset_id, 91); + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!(sum_fee, 10.into(), "91 proofs: ceil(9100/1000) = 10 sats"); + } + + #[test] + fn test_fee_calculation_unknown_keyset() { + let keyset_id = Id::from_str("001711afb1de20cb").unwrap(); + let unknown_keyset_id = Id::from_str("001711afb1de20cc").unwrap(); + + let mut keyset_fees = HashMap::new(); + keyset_fees.insert(keyset_id, 100); + + let mut proofs_count = HashMap::new(); + proofs_count.insert(unknown_keyset_id, 1); + + let result = calculate_fee(&proofs_count, &keyset_fees); + assert!(result.is_err(), "Unknown keyset should return error"); + } + + #[test] + fn test_fee_calculation_multiple_keysets() { + let keyset_id_1 = Id::from_str("001711afb1de20cb").unwrap(); + let keyset_id_2 = Id::from_str("001711afb1de20cc").unwrap(); + + let mut keyset_fees = HashMap::new(); + keyset_fees.insert(keyset_id_1, 200); + keyset_fees.insert(keyset_id_2, 500); + + let mut proofs_count = HashMap::new(); + proofs_count.insert(keyset_id_1, 3); + proofs_count.insert(keyset_id_2, 2); + + let sum_fee = calculate_fee(&proofs_count, &keyset_fees).unwrap(); + assert_eq!( + sum_fee, + 2.into(), + "3*200 + 2*500 = 1600, ceil(1600/1000) = 2" + ); + } } diff --git a/crates/cdk/src/invoice.rs b/crates/cdk/src/invoice.rs new file mode 100644 index 000000000..ff9e4badb --- /dev/null +++ b/crates/cdk/src/invoice.rs @@ -0,0 +1,129 @@ +//! Invoice and offer decoding utilities +//! +//! Provides standalone functions to decode bolt11 invoices and bolt12 offers +//! without requiring a wallet instance or creating melt quotes. + +use std::str::FromStr; + +use lightning::offers::offer::Offer; +use lightning_invoice::Bolt11Invoice; + +use crate::error::Error; + +/// Type of Lightning payment request +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum PaymentType { + /// Bolt11 invoice + Bolt11, + /// Bolt12 offer + Bolt12, +} + +/// Decoded invoice or offer information +#[derive(Debug, Clone)] +pub struct DecodedInvoice { + /// Type of payment request (Bolt11 or Bolt12) + pub payment_type: PaymentType, + /// Amount in millisatoshis, if specified + pub amount_msat: Option, + /// Expiry timestamp (Unix timestamp), if specified + pub expiry: Option, + /// Description or offer description, if specified + pub description: Option, +} + +/// Decode a bolt11 invoice or bolt12 offer from a string +/// +/// This function attempts to parse the input as a bolt11 invoice first, +/// then as a bolt12 offer if bolt11 parsing fails. +/// +/// # Arguments +/// +/// * `invoice_str` - The invoice or offer string to decode +/// +/// # Returns +/// +/// * `Ok(DecodedInvoice)` - Successfully decoded invoice/offer information +/// * `Err(Error)` - Failed to parse as either bolt11 or bolt12 +/// +/// # Example +/// +/// ```ignore +/// let decoded = decode_invoice("lnbc...")?; +/// match decoded.payment_type { +/// PaymentType::Bolt11 => println!("Bolt11 invoice"), +/// PaymentType::Bolt12 => println!("Bolt12 offer"), +/// } +/// ``` +pub fn decode_invoice(invoice_str: &str) -> Result { + // Try to parse as Bolt11 first + if let Ok(invoice) = Bolt11Invoice::from_str(invoice_str) { + let amount_msat = invoice.amount_milli_satoshis(); + + let expiry = invoice.expires_at().map(|duration| duration.as_secs()); + + let description = match invoice.description() { + lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(desc) => Some(desc.to_string()), + lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(hash) => { + Some(format!("Hash: {}", hash.0)) + } + }; + + return Ok(DecodedInvoice { + payment_type: PaymentType::Bolt11, + amount_msat, + expiry, + description, + }); + } + + // Try to parse as Bolt12 + if let Ok(offer) = Offer::from_str(invoice_str) { + let amount_msat = offer.amount().and_then(|amount| { + // Bolt12 amounts can be in different currencies + // For now, we only extract if it's in Bitcoin (millisatoshis) + match amount { + lightning::offers::offer::Amount::Bitcoin { amount_msats } => Some(amount_msats), + _ => None, + } + }); + + let expiry = offer.absolute_expiry().map(|duration| duration.as_secs()); + + let description = offer.description().map(|d| d.to_string()); + + return Ok(DecodedInvoice { + payment_type: PaymentType::Bolt12, + amount_msat, + expiry, + description, + }); + } + + // If both parsing attempts failed + Err(Error::InvalidInvoice) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_bolt11() { + // This is a valid bolt11 invoice for 100 sats + let bolt11 = "lnbc1u1p53kkd9pp5ve8pd9zr60yjyvs6tn77mndavzrl5lwd2gx5hk934f6q8jwguzgsdqqcqzzsxqyz5vqrzjqvueefmrckfdwyyu39m0lf24sqzcr9vcrmxrvgfn6empxz7phrjxvrttncqq0lcqqyqqqqlgqqqqqqgq2qsp5482y73fxmlvg4t66nupdaph93h7dcmfsg2ud72wajf0cpk3a96rq9qxpqysgqujexd0l89u5dutn8hxnsec0c7jrt8wz0z67rut0eah0g7p6zhycn2vff0ts5vwn2h93kx8zzqy3tzu4gfhkya2zpdmqelg0ceqnjztcqma65pr"; + + let result = decode_invoice(bolt11); + assert!(result.is_ok()); + + let decoded = result.unwrap(); + assert_eq!(decoded.payment_type, PaymentType::Bolt11); + assert_eq!(decoded.amount_msat, Some(100000)); + } + + #[test] + fn test_invalid_invoice() { + let result = decode_invoice("invalid_string"); + assert!(result.is_err()); + } +} diff --git a/crates/cdk/src/lib.rs b/crates/cdk/src/lib.rs index 4cc809a7f..3493e74cf 100644 --- a/crates/cdk/src/lib.rs +++ b/crates/cdk/src/lib.rs @@ -3,6 +3,10 @@ #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] +// Disallow enabling `tor` feature on wasm32 with a clear error. +#[cfg(all(target_arch = "wasm32", feature = "tor"))] +compile_error!("The 'tor' feature is not supported on wasm32 targets (browser). Disable the 'tor' feature or use a non-wasm32 target."); + pub mod cdk_database { //! CDK Database pub use cdk_common::database::Error; @@ -12,8 +16,8 @@ pub mod cdk_database { pub use cdk_common::database::WalletDatabase; #[cfg(feature = "mint")] pub use cdk_common::database::{ - MintDatabase, MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, - MintSignaturesDatabase, MintTransaction, + MintDatabase, MintKVStore, MintKVStoreDatabase, MintKVStoreTransaction, MintKeysDatabase, + MintProofsDatabase, MintQuotesDatabase, MintSignaturesDatabase, MintTransaction, }; } @@ -22,14 +26,21 @@ pub mod mint; #[cfg(feature = "wallet")] pub mod wallet; -#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))] -mod oidc_client; +#[cfg(test)] +mod test_helpers; -#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))] -pub use oidc_client::OidcClient; +#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] +mod bip353; -pub mod pub_sub; +#[cfg(feature = "wallet")] +mod lightning_address; + +#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))] +mod oidc_client; +#[cfg(feature = "mint")] +#[doc(hidden)] +pub use cdk_common::payment as cdk_payment; /// Re-export amount type #[doc(hidden)] pub use cdk_common::{ @@ -37,11 +48,13 @@ pub use cdk_common::{ error::{self, Error}, lightning_invoice, mint_url, nuts, secret, util, ws, Amount, Bolt11Invoice, }; -#[cfg(feature = "mint")] -#[doc(hidden)] -pub use cdk_common::{payment as cdk_payment, subscription}; +#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))] +pub use oidc_client::OidcClient; +#[cfg(any(feature = "wallet", feature = "mint"))] +pub mod event; pub mod fees; +pub mod invoice; #[doc(hidden)] pub use bitcoin::secp256k1; @@ -61,3 +74,12 @@ pub use self::wallet::HttpClient; /// Result #[doc(hidden)] pub type Result> = std::result::Result; + +/// Re-export subscription +pub use cdk_common::subscription; +/// Re-export futures::Stream +#[cfg(any(feature = "wallet", feature = "mint"))] +pub use futures::{Stream, StreamExt}; +/// Payment Request +#[cfg(feature = "wallet")] +pub use wallet::payment_request; diff --git a/crates/cdk/src/lightning_address.rs b/crates/cdk/src/lightning_address.rs new file mode 100644 index 000000000..8ec01b5b5 --- /dev/null +++ b/crates/cdk/src/lightning_address.rs @@ -0,0 +1,238 @@ +//! Lightning Address Implementation +//! +//! This module provides functionality for resolving Lightning addresses +//! to obtain Lightning invoices. Lightning addresses are user-friendly +//! identifiers that look like email addresses (e.g., user@domain.com). +//! +//! Lightning addresses are converted to LNURL-pay endpoints following the spec: +//! + +use std::str::FromStr; +use std::sync::Arc; + +use lightning_invoice::Bolt11Invoice; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use tracing::instrument; +use url::Url; + +use crate::wallet::MintConnector; +use crate::Amount; + +/// Lightning Address Error +#[derive(Debug, Error)] +pub enum Error { + /// Invalid Lightning address format + #[error("Invalid Lightning address format: {0}")] + InvalidFormat(String), + /// Invalid URL + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + /// Failed to fetch pay request data + #[error("Failed to fetch pay request data: {0}")] + FetchPayRequest(#[from] crate::Error), + /// Lightning address service error + #[error("Lightning address service error: {0}")] + Service(String), + /// Amount below minimum + #[error("Amount {amount} msat is below minimum {min} msat")] + AmountBelowMinimum { amount: u64, min: u64 }, + /// Amount above maximum + #[error("Amount {amount} msat is above maximum {max} msat")] + AmountAboveMaximum { amount: u64, max: u64 }, + /// No invoice in response + #[error("No invoice in response")] + NoInvoice, + /// Failed to parse invoice + #[error("Failed to parse invoice: {0}")] + InvoiceParse(String), +} + +/// Lightning address - represents a user@domain.com address +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LightningAddress { + /// The user part of the address (before @) + user: String, + /// The domain part of the address (after @) + domain: String, +} + +impl LightningAddress { + /// Convert the Lightning address to an HTTPS URL for the LNURL-pay endpoint + fn to_url(&self) -> Result { + // Lightning address spec: https://domain.com/.well-known/lnurlp/user + let url_str = format!("https://{}/.well-known/lnurlp/{}", self.domain, self.user); + Ok(Url::parse(&url_str)?) + } + + /// Fetch the LNURL-pay metadata from the service + #[instrument(skip(client))] + async fn fetch_pay_request_data( + &self, + client: &Arc, + ) -> Result { + let url = self.to_url()?; + + tracing::debug!("Fetching Lightning address pay data from: {}", url); + + // Make HTTP GET request to fetch the pay request data + let lnurl_response = client.fetch_lnurl_pay_request(url.as_str()).await?; + + // Validate the response + if let Some(ref reason) = lnurl_response.reason { + return Err(Error::Service(reason.clone())); + } + + Ok(lnurl_response) + } + + /// Request an invoice from the Lightning address service with a specific amount + #[instrument(skip(client))] + pub(crate) async fn request_invoice( + &self, + client: &Arc, + amount_msat: Amount, + ) -> Result { + let pay_data = self.fetch_pay_request_data(client).await?; + + // Validate amount is within acceptable range + let amount_msat_u64: u64 = amount_msat.into(); + if amount_msat_u64 < pay_data.min_sendable { + return Err(Error::AmountBelowMinimum { + amount: amount_msat_u64, + min: pay_data.min_sendable, + }); + } + if amount_msat_u64 > pay_data.max_sendable { + return Err(Error::AmountAboveMaximum { + amount: amount_msat_u64, + max: pay_data.max_sendable, + }); + } + + // Build callback URL with amount parameter + let mut callback_url = Url::parse(&pay_data.callback)?; + + callback_url + .query_pairs_mut() + .append_pair("amount", &amount_msat_u64.to_string()); + + tracing::debug!("Requesting invoice from callback: {}", callback_url); + + // Fetch the invoice + let invoice_response = client.fetch_lnurl_invoice(callback_url.as_str()).await?; + + // Check for errors + if let Some(ref reason) = invoice_response.reason { + return Err(Error::Service(reason.clone())); + } + + // Parse and return the invoice + let pr = invoice_response.pr.ok_or(Error::NoInvoice)?; + + Bolt11Invoice::from_str(&pr).map_err(|e| Error::InvoiceParse(e.to_string())) + } +} + +impl FromStr for LightningAddress { + type Err = Error; + + fn from_str(s: &str) -> Result { + let trimmed = s.trim(); + + // Parse Lightning address (user@domain) + if !trimmed.contains('@') { + return Err(Error::InvalidFormat("must contain '@'".to_string())); + } + + let parts: Vec<&str> = trimmed.split('@').collect(); + if parts.len() != 2 { + return Err(Error::InvalidFormat("must be user@domain".to_string())); + } + + let user = parts[0].trim(); + let domain = parts[1].trim(); + + if user.is_empty() || domain.is_empty() { + return Err(Error::InvalidFormat( + "user and domain must not be empty".to_string(), + )); + } + + Ok(LightningAddress { + user: user.to_string(), + domain: domain.to_string(), + }) + } +} + +impl std::fmt::Display for LightningAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.user, self.domain) + } +} + +/// LNURL-pay response from the initial request +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LnurlPayResponse { + /// Callback URL for requesting invoice + pub callback: String, + /// Minimum amount in millisatoshis + #[serde(rename = "minSendable")] + pub min_sendable: u64, + /// Maximum amount in millisatoshis + #[serde(rename = "maxSendable")] + pub max_sendable: u64, + /// Metadata string (JSON stringified) + pub metadata: String, + /// Short description tag (should be "payRequest") + pub tag: Option, + /// Optional error reason + pub reason: Option, +} + +/// LNURL-pay invoice response from the callback +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LnurlPayInvoiceResponse { + /// The BOLT11 payment request (invoice) + pub pr: Option, + /// Optional success action + pub success_action: Option, + /// Optional routes (deprecated) + pub routes: Option>, + /// Optional error reason + pub reason: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lightning_address_parsing() { + let addr = LightningAddress::from_str("satoshi@bitcoin.org").unwrap(); + assert_eq!(addr.user, "satoshi"); + assert_eq!(addr.domain, "bitcoin.org"); + assert_eq!(addr.to_string(), "satoshi@bitcoin.org"); + } + + #[test] + fn test_lightning_address_to_url() { + let addr = LightningAddress { + user: "alice".to_string(), + domain: "example.com".to_string(), + }; + + let url = addr.to_url().unwrap(); + assert_eq!(url.as_str(), "https://example.com/.well-known/lnurlp/alice"); + } + + #[test] + fn test_invalid_lightning_address() { + assert!(LightningAddress::from_str("invalid").is_err()); + assert!(LightningAddress::from_str("@example.com").is_err()); + assert!(LightningAddress::from_str("user@").is_err()); + assert!(LightningAddress::from_str("user").is_err()); + } +} diff --git a/crates/cdk/src/mint/auth/mod.rs b/crates/cdk/src/mint/auth/mod.rs index eb7d73a7e..faaf45526 100644 --- a/crates/cdk/src/mint/auth/mod.rs +++ b/crates/cdk/src/mint/auth/mod.rs @@ -23,6 +23,18 @@ impl Mint { /// Verify Clear auth #[instrument(skip_all, fields(token_len = token.len()))] pub async fn verify_clear_auth(&self, token: String) -> Result<(), Error> { + // Check static token first if configured + if let Some(static_token) = &self.static_auth_token { + if token == *static_token { + // tracing::debug!("Static auth token verified successfully"); + return Ok(()); + } + // If static token is set but doesn't match, fail + tracing::warn!("Static auth token mismatch"); + return Err(Error::StaticAuthTokenMismatch); + } + + // Fall back to OIDC verification Ok(self .oidc_client .as_ref() @@ -49,14 +61,14 @@ impl Mint { endpoint: &ProtectedEndpoint, ) -> Result<(), Error> { let auth_required = if let Some(auth_required) = self.is_protected(endpoint).await? { - tracing::info!( + tracing::trace!( "Auth required for endpoint: {:?}, type: {:?}", endpoint, auth_required ); auth_required } else { - tracing::debug!("No auth required for endpoint: {:?}", endpoint); + tracing::trace!("No auth required for endpoint: {:?}", endpoint); return Ok(()); }; diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 4f85e0575..ecf201e8b 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -3,119 +3,145 @@ use std::collections::HashMap; use std::sync::Arc; -use anyhow::anyhow; use bitcoin::bip32::DerivationPath; -use cdk_common::database::{self, MintDatabase, MintKeysDatabase}; +use cdk_common::common::UnitMetadata; +use cdk_common::database::{DynMintDatabase, MintKeysDatabase}; use cdk_common::error::Error; use cdk_common::nut04::MintMethodOptions; use cdk_common::nut05::MeltMethodOptions; -use cdk_common::payment::Bolt11Settings; -use cdk_common::{nut21, nut22}; +use cdk_common::payment::{Bolt11Settings, DynMintPayment}; +#[cfg(feature = "auth")] +use cdk_common::{database::DynMintAuthDatabase, nut21, nut22}; use cdk_signatory::signatory::Signatory; use super::nut17::SupportedMethods; use super::nut19::{self, CachedEndpoint}; -#[cfg(feature = "auth")] -use super::MintAuthDatabase; use super::Nuts; use crate::amount::Amount; -#[cfg(feature = "auth")] use crate::cdk_database; -use crate::cdk_payment::{self, MintPayment}; use crate::mint::Mint; +#[cfg(feature = "auth")] +use crate::nuts::ProtectedEndpoint; use crate::nuts::{ ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings, MintVersion, MppMethodSettings, PaymentMethod, }; use crate::types::PaymentProcessorKey; -/// Cashu Mint -#[derive(Default)] +/// Cashu Mint Builder pub struct MintBuilder { - /// Mint Info - pub mint_info: MintInfo, - /// Mint Storage backend - pub localstore: Option + Send + Sync>>, - /// Database for the Signatory - keystore: Option + Send + Sync>>, - /// Mint Storage backend + mint_info: MintInfo, + localstore: DynMintDatabase, #[cfg(feature = "auth")] - auth_localstore: Option + Send + Sync>>, - /// Ln backends for mint - ln: Option< - HashMap + Send + Sync>>, - >, - seed: Option>, + auth_localstore: Option, + #[cfg(feature = "auth")] + static_auth_token: Option, + payment_processors: HashMap, supported_units: HashMap, custom_paths: HashMap, - // protected_endpoints: HashMap, - openid_discovery: Option, - signatory: Option>, + keys_metadata: HashMap, } impl MintBuilder { - /// New mint builder - pub fn new() -> MintBuilder { - let mut builder = MintBuilder::default(); - - let nuts = Nuts::new() - .nut07(true) - .nut08(true) - .nut09(true) - .nut10(true) - .nut11(true) - .nut12(true) - .nut14(true) - .nut20(true); - - builder.mint_info.nuts = nuts; - - builder - } + /// New [`MintBuilder`] + pub fn new(localstore: DynMintDatabase) -> MintBuilder { + let mint_info = MintInfo { + nuts: Nuts::new() + .nut07(true) + .nut08(true) + .nut09(true) + .nut10(true) + .nut11(true) + .nut12(true) + .nut14(true) + .nut20(true), + ..Default::default() + }; - /// Set signatory service - pub fn with_signatory(mut self, signatory: Arc) -> Self { - self.signatory = Some(signatory); - self + MintBuilder { + mint_info, + localstore, + #[cfg(feature = "auth")] + auth_localstore: None, + #[cfg(feature = "auth")] + static_auth_token: None, + payment_processors: HashMap::new(), + supported_units: HashMap::new(), + custom_paths: HashMap::new(), + keys_metadata: HashMap::new(), + } } - /// Set seed - pub fn with_seed(mut self, seed: Vec) -> Self { - self.seed = Some(seed); + /// Set clear auth settings + #[cfg(feature = "auth")] + pub fn with_auth( + mut self, + auth_localstore: DynMintAuthDatabase, + openid_discovery: String, + client_id: String, + protected_endpoints: Vec, + ) -> Self { + self.auth_localstore = Some(auth_localstore); + self.mint_info.nuts.nut21 = Some(nut21::Settings::new( + openid_discovery, + client_id, + protected_endpoints, + )); self } - /// Set localstore - pub fn with_localstore( - mut self, - localstore: Arc + Send + Sync>, - ) -> MintBuilder { - self.localstore = Some(localstore); - self + /// Initialize builder's MintInfo from the database if present. + /// If not present or parsing fails, keeps the current MintInfo. + pub async fn init_from_db_if_present(&mut self) -> Result<(), cdk_database::Error> { + // Attempt to read existing mint_info from the KV store + let bytes_opt = self + .localstore + .kv_read( + super::CDK_MINT_PRIMARY_NAMESPACE, + super::CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + super::CDK_MINT_CONFIG_KV_KEY, + ) + .await?; + + if let Some(bytes) = bytes_opt { + if let Ok(info) = serde_json::from_slice::(&bytes) { + self.mint_info = info; + } else { + // If parsing fails, leave the current builder state untouched + tracing::warn!("Failed to parse existing mint_info from DB; using builder state"); + } + } + + Ok(()) } - /// Set keystore database - pub fn with_keystore( + /// Set blind auth settings + #[cfg(feature = "auth")] + pub fn with_blind_auth( mut self, - keystore: Arc + Send + Sync>, - ) -> MintBuilder { - self.keystore = Some(keystore); + bat_max_mint: u64, + protected_endpoints: Vec, + ) -> Self { + let mut nuts = self.mint_info.nuts; + + nuts.nut22 = Some(nut22::Settings::new(bat_max_mint, protected_endpoints)); + + self.mint_info.nuts = nuts; + self } - /// Set auth localstore + /// Set static auth token for clear auth verification #[cfg(feature = "auth")] - pub fn with_auth_localstore( - mut self, - localstore: Arc + Send + Sync>, - ) -> MintBuilder { - self.auth_localstore = Some(localstore); + pub fn with_static_auth_token(mut self, token: String) -> Self { + self.static_auth_token = Some(token); self } + - /// Set Openid discovery url - pub fn with_openid_discovery(mut self, openid_discovery: String) -> Self { - self.openid_discovery = Some(openid_discovery); + /// Set mint info + pub fn with_mint_info(mut self, mint_info: MintInfo) -> Self { + self.mint_info = mint_info; self } @@ -143,6 +169,14 @@ impl MintBuilder { self } + /// Get a clone of the current MintInfo configured on the builder + /// This allows using config-derived settings to initialize persistent state + /// before any attempt to read from the database, which avoids first-run + /// failures when the DB is empty. + pub fn current_mint_info(&self) -> MintInfo { + self.mint_info.clone() + } + /// Set terms of service URL pub fn with_tos_url(mut self, tos_url: String) -> Self { self.mint_info.tos_url = Some(tos_url); @@ -168,87 +202,13 @@ impl MintBuilder { } /// Set contact info - pub fn add_contact_info(mut self, contact_info: ContactInfo) -> Self { + pub fn with_contact_info(mut self, contact_info: ContactInfo) -> Self { let mut contacts = self.mint_info.contact.clone().unwrap_or_default(); contacts.push(contact_info); self.mint_info.contact = Some(contacts); self } - /// Add ln backend - pub async fn add_ln_backend( - mut self, - unit: CurrencyUnit, - method: PaymentMethod, - limits: MintMeltLimits, - ln_backend: Arc + Send + Sync>, - ) -> Result { - let ln_key = PaymentProcessorKey { - unit: unit.clone(), - method: method.clone(), - }; - - tracing::debug!("Adding ln backed for {}, {}", unit, method); - tracing::debug!("with limits {:?}", limits); - - let mut ln = self.ln.unwrap_or_default(); - - let settings = ln_backend.get_settings().await?; - - let settings: Bolt11Settings = settings.try_into()?; - - if settings.mpp { - let mpp_settings = MppMethodSettings { - method: method.clone(), - unit: unit.clone(), - }; - - let mut mpp = self.mint_info.nuts.nut15.clone(); - - mpp.methods.push(mpp_settings); - - self.mint_info.nuts.nut15 = mpp; - } - - if method == PaymentMethod::Bolt11 { - let mint_method_settings = MintMethodSettings { - method: method.clone(), - unit: unit.clone(), - min_amount: Some(limits.mint_min), - max_amount: Some(limits.mint_max), - options: Some(MintMethodOptions::Bolt11 { - description: settings.invoice_description, - }), - }; - - self.mint_info.nuts.nut04.methods.push(mint_method_settings); - self.mint_info.nuts.nut04.disabled = false; - - let melt_method_settings = MeltMethodSettings { - method, - unit, - min_amount: Some(limits.melt_min), - max_amount: Some(limits.melt_max), - options: Some(MeltMethodOptions::Bolt11 { - amountless: settings.amountless, - }), - }; - self.mint_info.nuts.nut05.methods.push(melt_method_settings); - self.mint_info.nuts.nut05.disabled = false; - } - - ln.insert(ln_key.clone(), ln_backend); - - let mut supported_units = self.supported_units.clone(); - - supported_units.insert(ln_key.unit, (0, 32)); - self.supported_units = supported_units; - - self.ln = Some(ln); - - Ok(self) - } - /// Set pubkey pub fn with_pubkey(mut self, pubkey: crate::nuts::PublicKey) -> Self { self.mint_info.pubkey = Some(pubkey); @@ -257,7 +217,7 @@ impl MintBuilder { } /// Support websockets - pub fn add_supported_websockets(mut self, supported_method: SupportedMethods) -> Self { + pub fn with_supported_websockets(mut self, supported_method: SupportedMethods) -> Self { let mut supported_settings = self.mint_info.nuts.nut17.supported.clone(); if !supported_settings.contains(&supported_method) { @@ -270,7 +230,7 @@ impl MintBuilder { } /// Add support for NUT19 - pub fn add_cache(mut self, ttl: Option, cached_endpoints: Vec) -> Self { + pub fn with_cache(mut self, ttl: Option, cached_endpoints: Vec) -> Self { let nut19_settings = nut19::Settings { ttl, cached_endpoints, @@ -282,7 +242,7 @@ impl MintBuilder { } /// Set custom derivation paths for mint units - pub fn add_custom_derivation_paths( + pub fn with_custom_derivation_paths( mut self, custom_paths: HashMap, ) -> Self { @@ -290,98 +250,137 @@ impl MintBuilder { self } - /// Set clear auth settings - pub fn set_clear_auth_settings(mut self, openid_discovery: String, client_id: String) -> Self { - let mut nuts = self.mint_info.nuts; + /// Add payment processor + pub async fn add_payment_processor( + &mut self, + unit: CurrencyUnit, + method: PaymentMethod, + limits: MintMeltLimits, + payment_processor: DynMintPayment, + ) -> Result<(), Error> { + let key = PaymentProcessorKey { + unit: unit.clone(), + method: method.clone(), + }; - nuts.nut21 = Some(nut21::Settings::new( - openid_discovery.clone(), - client_id, - vec![], - )); + let settings = payment_processor.get_settings().await?; - self.openid_discovery = Some(openid_discovery); + let settings: Bolt11Settings = settings.try_into()?; - self.mint_info.nuts = nuts; + if settings.mpp { + let mpp_settings = MppMethodSettings { + method: method.clone(), + unit: unit.clone(), + }; - self - } + let mut mpp = self.mint_info.nuts.nut15.clone(); - /// Set blind auth settings - pub fn set_blind_auth_settings(mut self, bat_max_mint: u64) -> Self { - let mut nuts = self.mint_info.nuts; + mpp.methods.push(mpp_settings); - nuts.nut22 = Some(nut22::Settings::new(bat_max_mint, vec![])); + self.mint_info.nuts.nut15 = mpp; + } - self.mint_info.nuts = nuts; + let mint_method_settings = MintMethodSettings { + method: method.clone(), + unit: unit.clone(), + min_amount: Some(limits.mint_min), + max_amount: Some(limits.mint_max), + options: Some(MintMethodOptions::Bolt11 { + description: settings.invoice_description, + }), + }; - self - } + self.mint_info.nuts.nut04.methods.push(mint_method_settings); + self.mint_info.nuts.nut04.disabled = false; + + let melt_method_settings = MeltMethodSettings { + method, + unit, + min_amount: Some(limits.melt_min), + max_amount: Some(limits.melt_max), + options: Some(MeltMethodOptions::Bolt11 { + amountless: settings.amountless, + }), + }; + self.mint_info.nuts.nut05.methods.push(melt_method_settings); + self.mint_info.nuts.nut05.disabled = false; + + let mut supported_units = self.supported_units.clone(); + supported_units.insert(key.unit.clone(), (0, 32)); + self.supported_units = supported_units; + + self.payment_processors.insert(key, payment_processor); + Ok(()) + } /// Sets the input fee ppk for a given unit /// /// The unit **MUST** already have been added with a ln backend - pub fn set_unit_fee(mut self, unit: &CurrencyUnit, input_fee_ppk: u64) -> Result { - let (input_fee, _max_order) = self + pub fn set_unit_fee(&mut self, unit: &CurrencyUnit, input_fee_ppk: u64) -> Result<(), Error> { + let (input_fee, _) = self .supported_units .get_mut(unit) .ok_or(Error::UnsupportedUnit)?; *input_fee = input_fee_ppk; - Ok(self) + Ok(()) } - /// Build mint - pub async fn build(&self) -> anyhow::Result { - let localstore = self - .localstore - .clone() - .ok_or(anyhow!("Localstore not set"))?; - let ln = self.ln.clone().ok_or(anyhow!("Ln backends not set"))?; - - let signatory = if let Some(signatory) = self.signatory.as_ref() { - signatory.clone() - } else { - let seed = self.seed.as_ref().ok_or(anyhow!("Mint seed not set"))?; - let in_memory_signatory = cdk_signatory::db_signatory::DbSignatory::new( - self.keystore.clone().ok_or(anyhow!("keystore not set"))?, - seed, - self.supported_units.clone(), - HashMap::new(), - ) - .await?; + /// Set unit metadata + pub fn set_unit_metadata(mut self, unit: &CurrencyUnit, metadata: UnitMetadata) -> Self { + self.keys_metadata.insert(unit.clone(), metadata); + self + } - Arc::new(cdk_signatory::embedded::Service::new(Arc::new( - in_memory_signatory, - ))) - }; + /// Build the mint with the provided signatory + pub async fn build_with_signatory( + self, + signatory: Arc, + ) -> Result { #[cfg(feature = "auth")] - if let Some(openid_discovery) = &self.openid_discovery { - let auth_localstore = self - .auth_localstore - .clone() - .ok_or(anyhow!("Auth localstore not set"))?; - - return Ok(Mint::new_with_auth( + if let Some(auth_localstore) = self.auth_localstore { + return Mint::new_with_auth( + self.mint_info, signatory, - localstore, - auth_localstore, - ln, - openid_discovery.clone(), + self.localstore, + Some(auth_localstore), + self.static_auth_token, + self.payment_processors, + self.keys_metadata, ) - .await?); - } - - #[cfg(not(feature = "auth"))] - if self.openid_discovery.is_some() { - return Err(anyhow!( - "OpenID discovery URL provided but auth feature is not enabled" - )); + .await; } + Mint::new( + self.mint_info, + signatory, + self.localstore, + self.payment_processors, + self.keys_metadata, + ) + .await + } - Ok(Mint::new(signatory, localstore, ln).await?) + /// Build the mint with the provided keystore and seed + pub async fn build_with_seed( + self, + keystore: Arc + Send + Sync>, + seed: &[u8], + ) -> Result { + let in_memory_signatory = cdk_signatory::db_signatory::DbSignatory::new( + keystore, + seed, + self.supported_units.clone(), + HashMap::new(), + ) + .await?; + + let signatory = Arc::new(cdk_signatory::embedded::Service::new(Arc::new( + in_memory_signatory, + ))); + + self.build_with_signatory(signatory).await } } @@ -409,3 +408,52 @@ impl MintMeltLimits { } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use cdk_sqlite::mint::memory; + + use super::*; + + #[tokio::test] + async fn test_mint_builder_default_nuts_support() { + let localstore = Arc::new(memory::empty().await.unwrap()); + let builder = MintBuilder::new(localstore); + let mint_info = builder.current_mint_info(); + + assert!( + mint_info.nuts.nut07.supported, + "NUT-07 should be supported by default" + ); + assert!( + mint_info.nuts.nut08.supported, + "NUT-08 should be supported by default" + ); + assert!( + mint_info.nuts.nut09.supported, + "NUT-09 should be supported by default" + ); + assert!( + mint_info.nuts.nut10.supported, + "NUT-10 should be supported by default" + ); + assert!( + mint_info.nuts.nut11.supported, + "NUT-11 should be supported by default" + ); + assert!( + mint_info.nuts.nut12.supported, + "NUT-12 should be supported by default" + ); + assert!( + mint_info.nuts.nut14.supported, + "NUT-14 (HTLC) should be supported by default" + ); + assert!( + mint_info.nuts.nut20.supported, + "NUT-20 should be supported by default" + ); + } +} diff --git a/crates/cdk/src/mint/issue/issue_nut04.rs b/crates/cdk/src/mint/issue/issue_nut04.rs deleted file mode 100644 index 5b01aeb71..000000000 --- a/crates/cdk/src/mint/issue/issue_nut04.rs +++ /dev/null @@ -1,323 +0,0 @@ -use cdk_common::payment::Bolt11Settings; -use tracing::instrument; -use uuid::Uuid; - -use crate::mint::{ - CurrencyUnit, MintQuote, MintQuoteBolt11Request, MintQuoteBolt11Response, MintQuoteState, - MintRequest, MintResponse, NotificationPayload, PublicKey, Verification, -}; -use crate::nuts::PaymentMethod; -use crate::util::unix_time; -use crate::{ensure_cdk, Amount, Error, Mint}; - -impl Mint { - /// Checks that minting is enabled, request is supported unit and within range - async fn check_mint_request_acceptable( - &self, - amount: Amount, - unit: &CurrencyUnit, - ) -> Result<(), Error> { - let mint_info = self.localstore.get_mint_info().await?; - let nut04 = &mint_info.nuts.nut04; - - ensure_cdk!(!nut04.disabled, Error::MintingDisabled); - - let settings = nut04 - .get_settings(unit, &PaymentMethod::Bolt11) - .ok_or(Error::UnsupportedUnit)?; - - let is_above_max = settings - .max_amount - .is_some_and(|max_amount| amount > max_amount); - let is_below_min = settings - .min_amount - .is_some_and(|min_amount| amount < min_amount); - let is_out_of_range = is_above_max || is_below_min; - - ensure_cdk!( - !is_out_of_range, - Error::AmountOutofLimitRange( - settings.min_amount.unwrap_or_default(), - settings.max_amount.unwrap_or_default(), - amount, - ) - ); - - Ok(()) - } - - /// Create new mint bolt11 quote - #[instrument(skip_all)] - pub async fn get_mint_bolt11_quote( - &self, - mint_quote_request: MintQuoteBolt11Request, - ) -> Result, Error> { - let MintQuoteBolt11Request { - amount, - unit, - description, - pubkey, - } = mint_quote_request; - - self.check_mint_request_acceptable(amount, &unit).await?; - - let ln = self.get_payment_processor(unit.clone(), PaymentMethod::Bolt11)?; - - let mint_ttl = self.localstore.get_quote_ttl().await?.mint_ttl; - - let quote_expiry = unix_time() + mint_ttl; - - let settings = ln.get_settings().await?; - let settings: Bolt11Settings = serde_json::from_value(settings)?; - - if description.is_some() && !settings.invoice_description { - tracing::error!("Backend does not support invoice description"); - return Err(Error::InvoiceDescriptionUnsupported); - } - - let create_invoice_response = ln - .create_incoming_payment_request( - amount, - &unit, - description.unwrap_or("".to_string()), - Some(quote_expiry), - ) - .await - .map_err(|err| { - tracing::error!("Could not create invoice: {}", err); - Error::InvalidPaymentRequest - })?; - - let quote = MintQuote::new( - create_invoice_response.request.to_string(), - unit.clone(), - amount, - create_invoice_response.expiry.unwrap_or(0), - create_invoice_response.request_lookup_id.clone(), - pubkey, - ); - - tracing::debug!( - "New mint quote {} for {} {} with request id {}", - quote.id, - amount, - unit, - create_invoice_response.request_lookup_id, - ); - - let mut tx = self.localstore.begin_transaction().await?; - tx.add_or_replace_mint_quote(quote.clone()).await?; - tx.commit().await?; - - let quote: MintQuoteBolt11Response = quote.into(); - - self.pubsub_manager - .broadcast(NotificationPayload::MintQuoteBolt11Response(quote.clone())); - - Ok(quote) - } - - /// Check mint quote - #[instrument(skip(self))] - pub async fn check_mint_quote( - &self, - quote_id: &Uuid, - ) -> Result, Error> { - let mut tx = self.localstore.begin_transaction().await?; - let mut mint_quote = tx - .get_mint_quote(quote_id) - .await? - .ok_or(Error::UnknownQuote)?; - - // Since the pending state is not part of the NUT it should not be part of the - // response. In practice the wallet should not be checking the state of - // a quote while waiting for the mint response. - if mint_quote.state == MintQuoteState::Unpaid { - self.check_mint_quote_paid(tx, &mut mint_quote) - .await? - .commit() - .await?; - } - - Ok(MintQuoteBolt11Response { - quote: mint_quote.id, - request: mint_quote.request, - state: mint_quote.state, - expiry: Some(mint_quote.expiry), - pubkey: mint_quote.pubkey, - amount: Some(mint_quote.amount), - unit: Some(mint_quote.unit.clone()), - }) - } - - /// Get mint quotes - #[instrument(skip_all)] - pub async fn mint_quotes(&self) -> Result, Error> { - let quotes = self.localstore.get_mint_quotes().await?; - Ok(quotes) - } - - /// Get pending mint quotes - #[instrument(skip_all)] - pub async fn get_pending_mint_quotes(&self) -> Result, Error> { - let mint_quotes = self - .localstore - .get_mint_quotes_with_state(MintQuoteState::Pending) - .await?; - - Ok(mint_quotes) - } - - /// Get pending mint quotes - #[instrument(skip_all)] - pub async fn get_unpaid_mint_quotes(&self) -> Result, Error> { - let mint_quotes = self - .localstore - .get_mint_quotes_with_state(MintQuoteState::Unpaid) - .await?; - - Ok(mint_quotes) - } - - /// Remove mint quote - #[instrument(skip_all)] - pub async fn remove_mint_quote(&self, quote_id: &Uuid) -> Result<(), Error> { - let mut tx = self.localstore.begin_transaction().await?; - tx.remove_mint_quote(quote_id).await?; - tx.commit().await?; - - Ok(()) - } - - /// Flag mint quote as paid - #[instrument(skip_all)] - pub async fn pay_mint_quote_for_request_id( - &self, - request_lookup_id: &str, - ) -> Result<(), Error> { - if let Ok(Some(mint_quote)) = self - .localstore - .get_mint_quote_by_request_lookup_id(request_lookup_id) - .await - { - self.pay_mint_quote(&mint_quote).await?; - } - Ok(()) - } - - /// Mark mint quote as paid - #[instrument(skip_all)] - pub async fn pay_mint_quote(&self, mint_quote: &MintQuote) -> Result<(), Error> { - tracing::debug!( - "Received payment notification for mint quote {}", - mint_quote.id - ); - if mint_quote.state != MintQuoteState::Issued && mint_quote.state != MintQuoteState::Paid { - let mut tx = self.localstore.begin_transaction().await?; - tx.update_mint_quote_state(&mint_quote.id, MintQuoteState::Paid) - .await?; - tx.commit().await?; - } else { - tracing::debug!( - "{} Quote already {} continuing", - mint_quote.id, - mint_quote.state - ); - } - - self.pubsub_manager - .mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Paid); - - Ok(()) - } - - /// Process mint request - #[instrument(skip_all)] - pub async fn process_mint_request( - &self, - mint_request: MintRequest, - ) -> Result { - let mut tx = self.localstore.begin_transaction().await?; - - let mut mint_quote = tx - .get_mint_quote(&mint_request.quote) - .await? - .ok_or(Error::UnknownQuote)?; - - let mut tx = if mint_quote.state == MintQuoteState::Unpaid { - self.check_mint_quote_paid(tx, &mut mint_quote).await? - } else { - tx - }; - - match mint_quote.state { - MintQuoteState::Unpaid => { - return Err(Error::UnpaidQuote); - } - MintQuoteState::Pending => { - return Err(Error::PendingQuote); - } - MintQuoteState::Issued => { - return Err(Error::IssuedQuote); - } - MintQuoteState::Paid => (), - } - - // If the there is a public key provoided in mint quote request - // verify the signature is provided for the mint request - if let Some(pubkey) = mint_quote.pubkey { - mint_request.verify_signature(pubkey)?; - } - - let Verification { amount, unit } = - match self.verify_outputs(&mut tx, &mint_request.outputs).await { - Ok(verification) => verification, - Err(err) => { - tracing::debug!("Could not verify mint outputs"); - return Err(err); - } - }; - - // We check the total value of blinded messages == mint quote - if amount != mint_quote.amount { - return Err(Error::TransactionUnbalanced( - mint_quote.amount.into(), - mint_request.total_amount()?.into(), - 0, - )); - } - - let unit = unit.ok_or(Error::UnsupportedUnit)?; - ensure_cdk!(unit == mint_quote.unit, Error::UnsupportedUnit); - - let mut blind_signatures = Vec::with_capacity(mint_request.outputs.len()); - - for blinded_message in mint_request.outputs.iter() { - let blind_signature = self.blind_sign(blinded_message.clone()).await?; - blind_signatures.push(blind_signature); - } - - tx.add_blind_signatures( - &mint_request - .outputs - .iter() - .map(|p| p.blinded_secret) - .collect::>(), - &blind_signatures, - Some(mint_request.quote), - ) - .await?; - - tx.update_mint_quote_state(&mint_request.quote, MintQuoteState::Issued) - .await?; - - tx.commit().await?; - - self.pubsub_manager - .mint_quote_bolt11_status(mint_quote, MintQuoteState::Issued); - - Ok(MintResponse { - signatures: blind_signatures, - }) - } -} diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs index 9c3f84439..a916d5098 100644 --- a/crates/cdk/src/mint/issue/mod.rs +++ b/crates/cdk/src/mint/issue/mod.rs @@ -1,3 +1,702 @@ +use cdk_common::mint::{MintQuote, Operation}; +use cdk_common::payment::{ + Bolt11IncomingPaymentOptions, Bolt11Settings, Bolt12IncomingPaymentOptions, + IncomingPaymentOptions, WaitPaymentResponse, +}; +use cdk_common::quote_id::QuoteId; +use cdk_common::util::unix_time; +use cdk_common::{ + database, ensure_cdk, Amount, CurrencyUnit, Error, MintQuoteBolt11Request, + MintQuoteBolt11Response, MintQuoteBolt12Request, MintQuoteBolt12Response, MintQuoteState, + MintRequest, MintResponse, NotificationPayload, PaymentMethod, PublicKey, +}; +#[cfg(feature = "prometheus")] +use cdk_prometheus::METRICS; +use tracing::instrument; + +use crate::mint::Verification; +use crate::Mint; + #[cfg(feature = "auth")] mod auth; -mod issue_nut04; + +/// Request for creating a mint quote +/// +/// This enum represents the different types of payment requests that can be used +/// to create a mint quote. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MintQuoteRequest { + /// Lightning Network BOLT11 invoice request + Bolt11(MintQuoteBolt11Request), + /// Lightning Network BOLT12 offer request + Bolt12(MintQuoteBolt12Request), +} + +impl From for MintQuoteRequest { + fn from(request: MintQuoteBolt11Request) -> Self { + MintQuoteRequest::Bolt11(request) + } +} + +impl From for MintQuoteRequest { + fn from(request: MintQuoteBolt12Request) -> Self { + MintQuoteRequest::Bolt12(request) + } +} + +impl MintQuoteRequest { + /// Get the amount from the mint quote request + /// + /// For Bolt11 requests, this returns `Some(amount)` as the amount is required. + /// For Bolt12 requests, this returns the optional amount. + pub fn amount(&self) -> Option { + match self { + MintQuoteRequest::Bolt11(request) => Some(request.amount), + MintQuoteRequest::Bolt12(request) => request.amount, + } + } + + /// Get the currency unit from the mint quote request + pub fn unit(&self) -> CurrencyUnit { + match self { + MintQuoteRequest::Bolt11(request) => request.unit.clone(), + MintQuoteRequest::Bolt12(request) => request.unit.clone(), + } + } + + /// Get the payment method for the mint quote request + pub fn payment_method(&self) -> PaymentMethod { + match self { + MintQuoteRequest::Bolt11(_) => PaymentMethod::Bolt11, + MintQuoteRequest::Bolt12(_) => PaymentMethod::Bolt12, + } + } + + /// Get the pubkey from the mint quote request + /// + /// For Bolt11 requests, this returns the optional pubkey. + /// For Bolt12 requests, this returns `Some(pubkey)` as the pubkey is required. + pub fn pubkey(&self) -> Option { + match self { + MintQuoteRequest::Bolt11(request) => request.pubkey, + MintQuoteRequest::Bolt12(request) => Some(request.pubkey), + } + } +} + +/// Response for a mint quote request +/// +/// This enum represents the different types of payment responses that can be returned +/// when creating a mint quote. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MintQuoteResponse { + /// Lightning Network BOLT11 invoice response + Bolt11(MintQuoteBolt11Response), + /// Lightning Network BOLT12 offer response + Bolt12(MintQuoteBolt12Response), +} + +impl TryFrom for MintQuoteBolt11Response { + type Error = Error; + + fn try_from(response: MintQuoteResponse) -> Result { + match response { + MintQuoteResponse::Bolt11(bolt11_response) => Ok(bolt11_response), + _ => Err(Error::InvalidPaymentMethod), + } + } +} + +impl TryFrom for MintQuoteBolt12Response { + type Error = Error; + + fn try_from(response: MintQuoteResponse) -> Result { + match response { + MintQuoteResponse::Bolt12(bolt12_response) => Ok(bolt12_response), + _ => Err(Error::InvalidPaymentMethod), + } + } +} + +impl TryFrom for MintQuoteResponse { + type Error = Error; + + fn try_from(quote: MintQuote) -> Result { + match quote.payment_method { + PaymentMethod::Bolt11 => { + let bolt11_response: MintQuoteBolt11Response = quote.into(); + Ok(MintQuoteResponse::Bolt11(bolt11_response)) + } + PaymentMethod::Bolt12 => { + let bolt12_response = MintQuoteBolt12Response::try_from(quote)?; + Ok(MintQuoteResponse::Bolt12(bolt12_response)) + } + PaymentMethod::Custom(_) => Err(Error::InvalidPaymentMethod), + } + } +} + +impl From for MintQuoteBolt11Response { + fn from(response: MintQuoteResponse) -> Self { + match response { + MintQuoteResponse::Bolt11(bolt11_response) => MintQuoteBolt11Response { + quote: bolt11_response.quote.to_string(), + state: bolt11_response.state, + request: bolt11_response.request, + expiry: bolt11_response.expiry, + pubkey: bolt11_response.pubkey, + amount: bolt11_response.amount, + unit: bolt11_response.unit, + }, + _ => panic!("Expected Bolt11 response"), + } + } +} + +impl Mint { + /// Validates that a mint request meets all requirements + /// + /// Checks that: + /// - Minting is enabled for the requested payment method + /// - The currency unit is supported + /// - The amount (if provided) is within the allowed range for the payment method + /// + /// # Returns + /// * `Ok(())` if the request is acceptable + /// * `Error` if any validation fails + pub async fn check_mint_request_acceptable( + &self, + mint_quote_request: &MintQuoteRequest, + ) -> Result<(), Error> { + let mint_info = self.mint_info().await?; + + let unit = mint_quote_request.unit(); + let amount = mint_quote_request.amount(); + let payment_method = mint_quote_request.payment_method(); + + let nut04 = &mint_info.nuts.nut04; + ensure_cdk!(!nut04.disabled, Error::MintingDisabled); + + let disabled = nut04.disabled; + + ensure_cdk!(!disabled, Error::MintingDisabled); + + let settings = nut04 + .get_settings(&unit, &payment_method) + .ok_or(Error::UnsupportedUnit)?; + + let min_amount = settings.min_amount; + let max_amount = settings.max_amount; + + // Check amount limits if an amount is provided + if let Some(amount) = amount { + let is_above_max = max_amount.is_some_and(|max_amount| amount > max_amount); + let is_below_min = min_amount.is_some_and(|min_amount| amount < min_amount); + let is_out_of_range = is_above_max || is_below_min; + + ensure_cdk!( + !is_out_of_range, + Error::AmountOutofLimitRange( + min_amount.unwrap_or_default(), + max_amount.unwrap_or_default(), + amount, + ) + ); + } + + Ok(()) + } + + /// Creates a new mint quote for the specified payment request + /// + /// Handles both Bolt11 and Bolt12 payment requests by: + /// 1. Validating the request parameters + /// 2. Creating an appropriate payment request via the payment processor + /// 3. Storing the quote in the database + /// 4. Broadcasting a notification about the new quote + /// + /// # Arguments + /// * `mint_quote_request` - The request containing payment details + /// + /// # Returns + /// * `MintQuoteResponse` - Response with payment details if successful + /// * `Error` - If the request is invalid or payment creation fails + #[instrument(skip_all)] + pub async fn get_mint_quote( + &self, + mint_quote_request: MintQuoteRequest, + ) -> Result { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("get_mint_quote"); + + let result = async { + // Use the new getters for cleaner code + let unit = mint_quote_request.unit(); + let amount = mint_quote_request.amount(); + let payment_method = mint_quote_request.payment_method(); + + // Validate the request before processing + self.check_mint_request_acceptable(&mint_quote_request) + .await?; + + // Extract pubkey using the getter + let pubkey = mint_quote_request.pubkey(); + + let ln = self.get_payment_processor(unit.clone(), payment_method.clone())?; + + let payment_options = match mint_quote_request { + MintQuoteRequest::Bolt11(bolt11_request) => { + let mint_ttl = self.quote_ttl().await?.mint_ttl; + + let quote_expiry = unix_time() + mint_ttl; + + let settings = ln.get_settings().await?; + let settings: Bolt11Settings = serde_json::from_value(settings)?; + + let description = bolt11_request.description; + + if description.is_some() && !settings.invoice_description { + tracing::error!("Backend does not support invoice description"); + return Err(Error::InvoiceDescriptionUnsupported); + } + + let bolt11_options = Bolt11IncomingPaymentOptions { + description, + amount: bolt11_request.amount, + unix_expiry: Some(quote_expiry), + }; + + IncomingPaymentOptions::Bolt11(bolt11_options) + } + MintQuoteRequest::Bolt12(bolt12_request) => { + let description = bolt12_request.description; + + let bolt12_options = Bolt12IncomingPaymentOptions { + description, + amount, + unix_expiry: None, + }; + + IncomingPaymentOptions::Bolt12(Box::new(bolt12_options)) + } + }; + + let create_invoice_response = ln + .create_incoming_payment_request(&unit, payment_options) + .await + .map_err(|err| { + tracing::error!("Could not create invoice: {}", err); + Error::InvalidPaymentRequest + })?; + + let quote = MintQuote::new( + None, + create_invoice_response.request.to_string(), + unit.clone(), + amount, + create_invoice_response.expiry.unwrap_or(0), + create_invoice_response.request_lookup_id.clone(), + pubkey, + Amount::ZERO, + Amount::ZERO, + payment_method.clone(), + unix_time(), + vec![], + vec![], + ); + + tracing::debug!( + "New {} mint quote {} for {:?} {} with request id {:?}", + payment_method, + quote.id, + amount, + unit, + create_invoice_response.request_lookup_id.to_string(), + ); + + let mut tx = self.localstore.begin_transaction().await?; + tx.add_mint_quote(quote.clone()).await?; + tx.commit().await?; + + match payment_method { + PaymentMethod::Bolt11 => { + let res: MintQuoteBolt11Response = quote.clone().into(); + self.pubsub_manager + .publish(NotificationPayload::MintQuoteBolt11Response(res)); + } + PaymentMethod::Bolt12 => { + let res: MintQuoteBolt12Response = quote.clone().try_into()?; + self.pubsub_manager + .publish(NotificationPayload::MintQuoteBolt12Response(res)); + } + PaymentMethod::Custom(_) => {} + } + + quote.try_into() + } + .await; + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("get_mint_quote"); + METRICS.record_mint_operation("get_mint_quote", result.is_ok()); + if result.is_err() { + METRICS.record_error(); + } + } + + result + } + + /// Retrieves all mint quotes from the database + /// + /// # Returns + /// * `Vec` - List of all mint quotes + /// * `Error` if database access fails + #[instrument(skip_all)] + pub async fn mint_quotes(&self) -> Result, Error> { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("mint_quotes"); + + let result = async { + let quotes = self.localstore.get_mint_quotes().await?; + Ok(quotes) + } + .await; + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("mint_quotes"); + METRICS.record_mint_operation("mint_quotes", result.is_ok()); + if result.is_err() { + METRICS.record_error(); + } + } + + result + } + + /// Marks a mint quote as paid based on the payment request ID + /// + /// Looks up the mint quote by the payment request ID and marks it as paid + /// if found. + /// + /// # Arguments + /// * `wait_payment_response` - Payment response containing payment details + /// + /// # Returns + /// * `Ok(())` if the quote was found and updated + /// * `Error` if the update fails + #[instrument(skip_all)] + pub async fn pay_mint_quote_for_request_id( + &self, + wait_payment_response: WaitPaymentResponse, + ) -> Result<(), Error> { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("pay_mint_quote_for_request_id"); + let result = async { + if wait_payment_response.payment_amount == Amount::ZERO { + tracing::warn!( + "Received payment response with 0 amount with payment id {}.", + wait_payment_response.payment_id.to_string() + ); + return Err(Error::AmountUndefined); + } + + let mut tx = self.localstore.begin_transaction().await?; + + if let Ok(Some(mint_quote)) = tx + .get_mint_quote_by_request_lookup_id(&wait_payment_response.payment_identifier) + .await + { + self.pay_mint_quote(&mut tx, &mint_quote, wait_payment_response) + .await?; + } else { + tracing::warn!( + "Could not get request for request lookup id {:?}.", + wait_payment_response.payment_identifier + ); + } + + tx.commit().await?; + + Ok(()) + } + .await; + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("pay_mint_quote_for_request_id"); + METRICS.record_mint_operation("pay_mint_quote_for_request_id", result.is_ok()); + if result.is_err() { + METRICS.record_error(); + } + } + + result + } + + /// Marks a specific mint quote as paid + /// + /// Updates the mint quote with payment information and broadcasts + /// a notification about the payment status change. + /// + /// # Arguments + /// * `mint_quote` - The mint quote to mark as paid + /// * `wait_payment_response` - Payment response containing payment details + /// + /// # Returns + /// * `Ok(())` if the update was successful + /// * `Error` if the update fails + #[instrument(skip_all)] + pub async fn pay_mint_quote( + &self, + tx: &mut Box + Send + Sync + '_>, + mint_quote: &MintQuote, + wait_payment_response: WaitPaymentResponse, + ) -> Result<(), Error> { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("pay_mint_quote"); + + let result = async { + Self::handle_mint_quote_payment( + tx, + mint_quote, + wait_payment_response, + &self.pubsub_manager, + ) + .await + } + .await; + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("pay_mint_quote"); + METRICS.record_mint_operation("pay_mint_quote", result.is_ok()); + if result.is_err() { + METRICS.record_error(); + } + } + + result + } + + /// Checks the status of a mint quote and updates it if necessary + /// + /// If the quote is unpaid, this will check if payment has been received. + /// Returns the current state of the quote. + /// + /// # Arguments + /// * `quote_id` - The UUID of the quote to check + /// + /// # Returns + /// * `MintQuoteResponse` - The current state of the quote + /// * `Error` if the quote doesn't exist or checking fails + #[instrument(skip(self))] + pub async fn check_mint_quote(&self, quote_id: &QuoteId) -> Result { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("check_mint_quote"); + let result = async { + let mut quote = self + .localstore + .get_mint_quote(quote_id) + .await? + .ok_or(Error::UnknownQuote)?; + + if quote.payment_method == PaymentMethod::Bolt11 { + self.check_mint_quote_paid(&mut quote).await?; + } + + quote.try_into() + } + .await; + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("check_mint_quote"); + METRICS.record_mint_operation("check_mint_quote", result.is_ok()); + if result.is_err() { + METRICS.record_error(); + } + } + + result + } + + /// Processes a mint request to issue new tokens + /// + /// This function: + /// 1. Verifies the mint quote exists and is paid + /// 2. Validates the request signature if a pubkey was provided + /// 3. Verifies the outputs match the expected amount + /// 4. Signs the blinded messages + /// 5. Updates the quote status + /// 6. Broadcasts a notification about the status change + /// + /// # Arguments + /// * `mint_request` - The mint request containing blinded outputs to sign + /// + /// # Returns + /// * `MintBolt11Response` - Response containing blind signatures + /// * `Error` if validation fails or signing fails + #[instrument(skip_all)] + pub async fn process_mint_request( + &self, + mint_request: MintRequest, + ) -> Result { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("process_mint_request"); + let result = async { + let mut mint_quote = self + .localstore + .get_mint_quote(&mint_request.quote) + .await? + .ok_or(Error::UnknownQuote)?; + + if mint_quote.payment_method == PaymentMethod::Bolt11 { + self.check_mint_quote_paid(&mut mint_quote).await?; + } + // get the blind signatures before having starting the db transaction, if there are any + // rollbacks this blind_signatures will be lost, and the signature is stateless. It is not a + // good idea to call an external service (which is really a trait, it could be anything + // anywhere) while keeping a database transaction on-going + let blind_signatures = self.blind_sign(mint_request.outputs.clone()).await?; + + let mut tx = self.localstore.begin_transaction().await?; + + let mint_quote = tx + .get_mint_quote(&mint_request.quote) + .await? + .ok_or(Error::UnknownQuote)?; + + match mint_quote.state() { + MintQuoteState::Unpaid => { + return Err(Error::UnpaidQuote); + } + MintQuoteState::Issued => { + if mint_quote.payment_method == PaymentMethod::Bolt12 + && mint_quote.amount_paid() > mint_quote.amount_issued() + { + tracing::warn!("Mint quote should state should have been set to issued upon new payment. Something isn't right. Stopping mint"); + } + + return Err(Error::IssuedQuote); + } + MintQuoteState::Paid => (), + } + + if mint_quote.payment_method == PaymentMethod::Bolt12 && mint_quote.pubkey.is_none() { + tracing::warn!("Bolt12 mint quote created without pubkey"); + return Err(Error::SignatureMissingOrInvalid); + } + + let mint_amount = match mint_quote.payment_method { + PaymentMethod::Bolt11 => { + let quote_amount = mint_quote.amount.ok_or(Error::AmountUndefined)?; + + if quote_amount != mint_quote.amount_mintable() { + tracing::error!("The quote amount {} does not equal the amount paid {}.", quote_amount, mint_quote.amount_mintable()); + return Err(Error::IncorrectQuoteAmount); + } + + quote_amount + }, + PaymentMethod::Bolt12 => { + if mint_quote.amount_mintable() == Amount::ZERO{ + tracing::error!( + "Quote state should not be issued if issued {} is => paid {}.", + mint_quote.amount_issued(), + mint_quote.amount_paid() + ); + return Err(Error::UnpaidQuote); + } + + mint_quote.amount_mintable() + } + _ => return Err(Error::UnsupportedPaymentMethod), + }; + + // If the there is a public key provoided in mint quote request + // verify the signature is provided for the mint request + if let Some(pubkey) = mint_quote.pubkey { + mint_request.verify_signature(pubkey)?; + } + + let Verification { + amount: outputs_amount, + unit, + } = match self.verify_outputs(&mut tx, &mint_request.outputs).await { + Ok(verification) => verification, + Err(err) => { + tracing::debug!("Could not verify mint outputs"); + + return Err(err); + } + }; + + if mint_quote.payment_method == PaymentMethod::Bolt11 { + // For bolt11 we enforce that mint amount == quote amount + if outputs_amount != mint_amount { + return Err(Error::TransactionUnbalanced( + mint_amount.into(), + mint_request.total_amount()?.into(), + 0, + )); + } + } else { + // For other payments we just make sure outputs is not more then mint amount + if outputs_amount > mint_amount { + return Err(Error::TransactionUnbalanced( + mint_amount.into(), + mint_request.total_amount()?.into(), + 0, + )); + } + } + + 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 + .iter() + .map(|p| p.blinded_secret) + .collect::>(), + &blind_signatures, + Some(mint_request.quote.clone()), + ) + .await?; + + let amount_issued = mint_request.total_amount()?; + + let total_issued = tx + .increment_mint_quote_amount_issued(&mint_request.quote, amount_issued) + .await?; + + tx.commit().await?; + + self.pubsub_manager + .mint_quote_issue(&mint_quote, total_issued); + + Ok(MintResponse { + signatures: blind_signatures, + }) + } + .await; + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("process_mint_request"); + METRICS.record_mint_operation("process_mint_request", result.is_ok()); + if result.is_err() { + METRICS.record_error(); + } + } + result + } +} diff --git a/crates/cdk/src/mint/keysets/mod.rs b/crates/cdk/src/mint/keysets/mod.rs index c9a06267f..1a61f42bc 100644 --- a/crates/cdk/src/mint/keysets/mod.rs +++ b/crates/cdk/src/mint/keysets/mod.rs @@ -75,14 +75,14 @@ impl Mint { pub async fn rotate_keyset( &self, unit: CurrencyUnit, - max_order: u8, + amounts: Vec, input_fee_ppk: u64, ) -> Result { let result = self .signatory .rotate_keyset(RotateKeyArguments { unit, - max_order, + amounts, input_fee_ppk, }) .await?; diff --git a/crates/cdk/src/mint/ln.rs b/crates/cdk/src/mint/ln.rs index d23122a0f..ec5a4046f 100644 --- a/crates/cdk/src/mint/ln.rs +++ b/crates/cdk/src/mint/ln.rs @@ -1,21 +1,42 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use cdk_common::amount::to_unit; use cdk_common::common::PaymentProcessorKey; -use cdk_common::database::{self, MintTransaction}; +use cdk_common::database::DynMintDatabase; use cdk_common::mint::MintQuote; -use cdk_common::MintQuoteState; +use cdk_common::payment::DynMintPayment; +use cdk_common::util::unix_time; +use cdk_common::{database, Amount, MintQuoteState, PaymentMethod}; +use tracing::instrument; +use super::subscription::PubSubManager; use super::Mint; use crate::Error; impl Mint { - /// Check the status of an ln payment for a quote - pub async fn check_mint_quote_paid( - &self, - tx: Box + Send + Sync + '_>, + /// Static implementation of check_mint_quote_paid to avoid circular dependency to the Mint + #[inline(always)] + pub(crate) async fn check_mint_quote_payments( + localstore: DynMintDatabase, + payment_processors: Arc>, + pubsub_manager: Option>, quote: &mut MintQuote, - ) -> Result + Send + Sync + '_>, Error> { - let ln = match self.ln.get(&PaymentProcessorKey::new( + ) -> Result<(), Error> { + let state = quote.state(); + + // We can just return here and do not need to check with ln node. + // If quote is issued it is already in a final state, + // If it is paid ln node will only tell us what we already know + if quote.payment_method == PaymentMethod::Bolt11 + && (state == MintQuoteState::Issued || state == MintQuoteState::Paid) + { + return Ok(()); + } + + let ln = match payment_processors.get(&PaymentProcessorKey::new( quote.unit.clone(), - cdk_common::PaymentMethod::Bolt11, + quote.payment_method.clone(), )) { Some(ln) => ln, None => { @@ -25,23 +46,84 @@ impl Mint { } }; - tx.commit().await?; - let ln_status = ln .check_incoming_payment_status("e.request_lookup_id) .await?; - let mut tx = self.localstore.begin_transaction().await?; + if ln_status.is_empty() { + return Ok(()); + } - if ln_status != quote.state && quote.state != MintQuoteState::Issued { - tx.update_mint_quote_state("e.id, ln_status).await?; + let mut tx = localstore.begin_transaction().await?; - quote.state = ln_status; + // reload the quote, as it state may have changed + *quote = tx + .get_mint_quote("e.id) + .await? + .ok_or(Error::UnknownQuote)?; - self.pubsub_manager - .mint_quote_bolt11_status(quote.clone(), ln_status); + let current_state = quote.state(); + + if quote.payment_method == PaymentMethod::Bolt11 + && (current_state == MintQuoteState::Issued || current_state == MintQuoteState::Paid) + { + return Ok(()); } - Ok(tx) + for payment in ln_status { + if !quote.payment_ids().contains(&&payment.payment_id) + && payment.payment_amount > Amount::ZERO + { + tracing::debug!( + "Found payment of {} {} for quote {} when checking.", + payment.payment_amount, + payment.unit, + quote.id + ); + + let amount_paid = to_unit(payment.payment_amount, &payment.unit, "e.unit)?; + + match tx + .increment_mint_quote_amount_paid( + "e.id, + amount_paid, + payment.payment_id.clone(), + ) + .await + { + Ok(total_paid) => { + quote.increment_amount_paid(amount_paid)?; + quote.add_payment(amount_paid, payment.payment_id.clone(), unix_time())?; + if let Some(pubsub_manager) = pubsub_manager.as_ref() { + pubsub_manager.mint_quote_payment(quote, total_paid); + } + } + Err(database::Error::Duplicate) => { + tracing::debug!( + "Payment ID {} already processed (caught race condition in check_mint_quote_paid)", + payment.payment_id + ); + // This is fine - another concurrent request already processed this payment + } + Err(e) => return Err(e.into()), + } + } + } + + tx.commit().await?; + + Ok(()) + } + + /// Check the status of an ln payment for a quote + #[instrument(skip_all)] + pub async fn check_mint_quote_paid(&self, quote: &mut MintQuote) -> Result<(), Error> { + Self::check_mint_quote_payments( + self.localstore.clone(), + self.payment_processors.clone(), + Some(self.pubsub_manager.clone()), + quote, + ) + .await } } diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs deleted file mode 100644 index 544bab97f..000000000 --- a/crates/cdk/src/mint/melt.rs +++ /dev/null @@ -1,696 +0,0 @@ -use std::str::FromStr; - -use anyhow::bail; -use cdk_common::database::{self, MintTransaction}; -use cdk_common::nut00::ProofsMethods; -use cdk_common::nut05::MeltMethodOptions; -use cdk_common::MeltOptions; -use lightning_invoice::Bolt11Invoice; -use tracing::instrument; -use uuid::Uuid; - -use super::{ - CurrencyUnit, MeltQuote, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, Mint, - PaymentMethod, PublicKey, State, -}; -use crate::amount::to_unit; -use crate::cdk_payment::{MakePaymentResponse, MintPayment}; -use crate::mint::proof_writer::ProofWriter; -use crate::mint::verification::Verification; -use crate::mint::SigFlag; -use crate::nuts::nut11::{enforce_sig_flag, EnforceSigFlag}; -use crate::nuts::MeltQuoteState; -use crate::types::PaymentProcessorKey; -use crate::util::unix_time; -use crate::{cdk_payment, ensure_cdk, Amount, Error}; - -impl Mint { - #[instrument(skip_all)] - async fn check_melt_request_acceptable( - &self, - amount: Amount, - unit: CurrencyUnit, - method: PaymentMethod, - request: String, - options: Option, - ) -> Result<(), Error> { - let mint_info = self.localstore.get_mint_info().await?; - let nut05 = mint_info.nuts.nut05; - - ensure_cdk!(!nut05.disabled, Error::MeltingDisabled); - - let settings = nut05 - .get_settings(&unit, &method) - .ok_or(Error::UnsupportedUnit)?; - - let amount = match options { - Some(MeltOptions::Mpp { mpp: _ }) => { - let nut15 = mint_info.nuts.nut15; - // Verify there is no corresponding mint quote. - // Otherwise a wallet is trying to pay someone internally, but - // with a multi-part quote. And that's just not possible. - if (self.localstore.get_mint_quote_by_request(&request).await?).is_some() { - return Err(Error::InternalMultiPartMeltQuote); - } - // Verify MPP is enabled for unit and method - if !nut15 - .methods - .into_iter() - .any(|m| m.method == method && m.unit == unit) - { - return Err(Error::MppUnitMethodNotSupported(unit, method)); - } - // Assign `amount` - // because should have already been converted to the partial amount - amount - } - Some(MeltOptions::Amountless { amountless: _ }) => { - if !matches!( - settings.options, - Some(MeltMethodOptions::Bolt11 { amountless: true }) - ) { - return Err(Error::AmountlessInvoiceNotSupported(unit, method)); - } - - amount - } - None => amount, - }; - - let is_above_max = matches!(settings.max_amount, Some(max) if amount > max); - let is_below_min = matches!(settings.min_amount, Some(min) if amount < min); - match is_above_max || is_below_min { - true => { - tracing::error!( - "Melt amount out of range: {} is not within {} and {}", - amount, - settings.min_amount.unwrap_or_default(), - settings.max_amount.unwrap_or_default(), - ); - Err(Error::AmountOutofLimitRange( - settings.min_amount.unwrap_or_default(), - settings.max_amount.unwrap_or_default(), - amount, - )) - } - false => Ok(()), - } - } - - /// Get melt bolt11 quote - #[instrument(skip_all)] - pub async fn get_melt_bolt11_quote( - &self, - melt_request: &MeltQuoteBolt11Request, - ) -> Result, Error> { - let MeltQuoteBolt11Request { - request, - unit, - options, - .. - } = melt_request; - - let ln = self - .ln - .get(&PaymentProcessorKey::new( - unit.clone(), - PaymentMethod::Bolt11, - )) - .ok_or_else(|| { - tracing::info!("Could not get ln backend for {}, bolt11 ", unit); - - Error::UnsupportedUnit - })?; - - let payment_quote = ln - .get_payment_quote( - &melt_request.request.to_string(), - &melt_request.unit, - melt_request.options, - ) - .await - .map_err(|err| { - tracing::error!( - "Could not get payment quote for mint quote, {} bolt11, {}", - unit, - err - ); - - Error::UnsupportedUnit - })?; - - self.check_melt_request_acceptable( - payment_quote.amount, - unit.clone(), - PaymentMethod::Bolt11, - request.to_string(), - *options, - ) - .await?; - - // We only want to set the msats_to_pay of the melt quote if the invoice is amountless - // or we want to ignore the amount and do an mpp payment - let msats_to_pay = options.map(|opt| opt.amount_msat()); - - let melt_ttl = self.localstore.get_quote_ttl().await?.melt_ttl; - - let quote = MeltQuote::new( - request.to_string(), - unit.clone(), - payment_quote.amount, - payment_quote.fee, - unix_time() + melt_ttl, - payment_quote.request_lookup_id.clone(), - msats_to_pay, - ); - - tracing::debug!( - "New melt quote {} for {} {} with request id {}", - quote.id, - payment_quote.amount, - unit, - payment_quote.request_lookup_id - ); - - let mut tx = self.localstore.begin_transaction().await?; - if let Some(mut from_db_quote) = tx.get_melt_quote("e.id).await? { - if from_db_quote.state != quote.state { - tx.update_melt_quote_state("e.id, from_db_quote.state) - .await?; - from_db_quote.state = quote.state; - } - if from_db_quote.request_lookup_id != quote.request_lookup_id { - tx.update_melt_quote_request_lookup_id("e.id, "e.request_lookup_id) - .await?; - from_db_quote.request_lookup_id = quote.request_lookup_id.clone(); - } - if from_db_quote != quote { - return Err(Error::Internal); - } - } else if let Err(err) = tx.add_melt_quote(quote.clone()).await { - match err { - database::Error::Duplicate => { - return Err(Error::RequestAlreadyPaid); - } - _ => return Err(Error::from(err)), - } - } - tx.commit().await?; - - Ok(quote.into()) - } - - /// Check melt quote status - #[instrument(skip(self))] - pub async fn check_melt_quote( - &self, - quote_id: &Uuid, - ) -> Result, Error> { - let quote = self - .localstore - .get_melt_quote(quote_id) - .await? - .ok_or(Error::UnknownQuote)?; - - let blind_signatures = self - .localstore - .get_blind_signatures_for_quote(quote_id) - .await?; - - let change = (!blind_signatures.is_empty()).then_some(blind_signatures); - - Ok(MeltQuoteBolt11Response { - quote: quote.id, - paid: Some(quote.state == MeltQuoteState::Paid), - state: quote.state, - expiry: quote.expiry, - amount: quote.amount, - fee_reserve: quote.fee_reserve, - payment_preimage: quote.payment_preimage, - change, - request: Some(quote.request.clone()), - unit: Some(quote.unit.clone()), - }) - } - - /// Get melt quotes - #[instrument(skip_all)] - pub async fn melt_quotes(&self) -> Result, Error> { - let quotes = self.localstore.get_melt_quotes().await?; - Ok(quotes) - } - - /// Check melt has expected fees - #[instrument(skip_all)] - pub async fn check_melt_expected_ln_fees( - &self, - melt_quote: &MeltQuote, - melt_request: &MeltRequest, - ) -> Result, Error> { - let invoice = Bolt11Invoice::from_str(&melt_quote.request)?; - - let quote_msats = to_unit(melt_quote.amount, &melt_quote.unit, &CurrencyUnit::Msat) - .expect("Quote unit is checked above that it can convert to msat"); - - let invoice_amount_msats: Amount = match invoice.amount_milli_satoshis() { - Some(amt) => amt.into(), - None => melt_quote - .msat_to_pay - .ok_or(Error::InvoiceAmountUndefined)?, - }; - - let partial_amount = match invoice_amount_msats > quote_msats { - true => Some( - to_unit(quote_msats, &CurrencyUnit::Msat, &melt_quote.unit) - .map_err(|_| Error::UnsupportedUnit)?, - ), - false => None, - }; - - let amount_to_pay = match partial_amount { - Some(amount_to_pay) => amount_to_pay, - None => to_unit(invoice_amount_msats, &CurrencyUnit::Msat, &melt_quote.unit) - .map_err(|_| Error::UnsupportedUnit)?, - }; - - let inputs_amount_quote_unit = melt_request.proofs_amount().map_err(|_| { - tracing::error!("Proof inputs in melt quote overflowed"); - Error::AmountOverflow - })?; - - if amount_to_pay + melt_quote.fee_reserve > inputs_amount_quote_unit { - tracing::debug!( - "Not enough inputs provided: {} {} needed {} {}", - inputs_amount_quote_unit, - melt_quote.unit, - amount_to_pay, - melt_quote.unit - ); - - return Err(Error::TransactionUnbalanced( - inputs_amount_quote_unit.into(), - amount_to_pay.into(), - melt_quote.fee_reserve.into(), - )); - } - - Ok(partial_amount) - } - - /// Verify melt request is valid - #[instrument(skip_all)] - pub async fn verify_melt_request( - &self, - tx: &mut Box + Send + Sync + '_>, - melt_request: &MeltRequest, - ) -> Result<(ProofWriter, MeltQuote), Error> { - let (state, quote) = tx - .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending) - .await?; - - match state { - MeltQuoteState::Unpaid | MeltQuoteState::Failed => Ok(()), - MeltQuoteState::Pending => Err(Error::PendingQuote), - MeltQuoteState::Paid => Err(Error::PaidQuote), - MeltQuoteState::Unknown => Err(Error::UnknownPaymentState), - }?; - - self.pubsub_manager - .melt_quote_status("e, None, None, MeltQuoteState::Pending); - - let Verification { - amount: input_amount, - unit: input_unit, - } = self.verify_inputs(melt_request.inputs()).await?; - - ensure_cdk!(input_unit.is_some(), Error::UnsupportedUnit); - - let fee = self.get_proofs_fee(melt_request.inputs()).await?; - - let required_total = quote.amount + quote.fee_reserve + fee; - - // Check that the inputs proofs are greater then total. - // Transaction does not need to be balanced as wallet may not want change. - if input_amount < required_total { - tracing::info!( - "Swap request unbalanced: {}, outputs {}, fee {}", - input_amount, - quote.amount, - fee - ); - return Err(Error::TransactionUnbalanced( - input_amount.into(), - quote.amount.into(), - (fee + quote.fee_reserve).into(), - )); - } - - let mut proof_writer = - ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone()); - - proof_writer.add_proofs(tx, melt_request.inputs()).await?; - - let EnforceSigFlag { sig_flag, .. } = enforce_sig_flag(melt_request.inputs().clone()); - - ensure_cdk!(sig_flag.ne(&SigFlag::SigAll), Error::SigAllUsedInMelt); - - if let Some(outputs) = &melt_request.outputs() { - if !outputs.is_empty() { - let Verification { - amount: _, - unit: output_unit, - } = self.verify_outputs(tx, outputs).await?; - - ensure_cdk!(input_unit == output_unit, Error::UnsupportedUnit); - } - } - - tracing::debug!("Verified melt quote: {}", melt_request.quote()); - Ok((proof_writer, quote)) - } - - /// Melt Bolt11 - #[instrument(skip_all)] - pub async fn melt_bolt11( - &self, - melt_request: &MeltRequest, - ) -> Result, Error> { - use std::sync::Arc; - async fn check_payment_state( - ln: Arc + Send + Sync>, - melt_quote: &MeltQuote, - ) -> anyhow::Result { - match ln - .check_outgoing_payment(&melt_quote.request_lookup_id) - .await - { - Ok(response) => Ok(response), - Err(check_err) => { - // If we cannot check the status of the payment we keep the proofs stuck as pending. - tracing::error!( - "Could not check the status of payment for {},. Proofs stuck as pending", - melt_quote.id - ); - tracing::error!("Checking payment error: {}", check_err); - bail!("Could not check payment status") - } - } - } - - let mut tx = self.localstore.begin_transaction().await?; - - let (proof_writer, quote) = self - .verify_melt_request(&mut tx, melt_request) - .await - .map_err(|err| { - tracing::debug!("Error attempting to verify melt quote: {}", err); - err - })?; - - let settled_internally_amount = self - .handle_internal_melt_mint(&mut tx, "e, melt_request) - .await - .map_err(|err| { - tracing::error!("Attempting to settle internally failed: {}", err); - err - })?; - - let (tx, preimage, amount_spent_quote_unit, quote) = match settled_internally_amount { - Some(amount_spent) => (tx, None, amount_spent, quote), - - None => { - // If the quote unit is SAT or MSAT we can check that the expected fees are - // provided. We also check if the quote is less then the invoice - // amount in the case that it is a mmp However, if the quote is not - // of a bitcoin unit we cannot do these checks as the mint - // is unaware of a conversion rate. In this case it is assumed that the quote is - // correct and the mint should pay the full invoice amount if inputs - // > `then quote.amount` are included. This is checked in the - // `verify_melt` method. - let partial_amount = match quote.unit { - CurrencyUnit::Sat | CurrencyUnit::Msat => { - match self.check_melt_expected_ln_fees("e, melt_request).await { - Ok(amount) => amount, - Err(err) => { - tracing::error!("Fee is not expected: {}", err); - return Err(Error::Internal); - } - } - } - _ => None, - }; - tracing::debug!("partial_amount: {:?}", partial_amount); - let ln = match self.ln.get(&PaymentProcessorKey::new( - quote.unit.clone(), - PaymentMethod::Bolt11, - )) { - Some(ln) => ln, - None => { - tracing::info!("Could not get ln backend for {}, bolt11 ", quote.unit); - return Err(Error::UnsupportedUnit); - } - }; - - // Commit before talking to the external call - tx.commit().await?; - - let pre = match ln - .make_payment(quote.clone(), partial_amount, Some(quote.fee_reserve)) - .await - { - Ok(pay) - if pay.status == MeltQuoteState::Unknown - || pay.status == MeltQuoteState::Failed => - { - let check_response = - if let Ok(ok) = check_payment_state(Arc::clone(ln), "e).await { - ok - } else { - return Err(Error::Internal); - }; - - if check_response.status == MeltQuoteState::Paid { - tracing::warn!("Pay invoice returned {} but check returned {}. Proofs stuck as pending", pay.status.to_string(), check_response.status.to_string()); - - proof_writer.commit(); - - return Err(Error::Internal); - } - - check_response - } - Ok(pay) => pay, - Err(err) => { - // If the error is that the invoice was already paid we do not want to hold - // hold the proofs as pending to we reset them and return an error. - if matches!(err, cdk_payment::Error::InvoiceAlreadyPaid) { - tracing::debug!("Invoice already paid, resetting melt quote"); - return Err(Error::RequestAlreadyPaid); - } - - tracing::error!("Error returned attempting to pay: {} {}", quote.id, err); - - let check_response = - if let Ok(ok) = check_payment_state(Arc::clone(ln), "e).await { - ok - } else { - proof_writer.commit(); - return Err(Error::Internal); - }; - // If there error is something else we want to check the status of the payment ensure it is not pending or has been made. - if check_response.status == MeltQuoteState::Paid { - tracing::warn!("Pay invoice returned an error but check returned {}. Proofs stuck as pending", check_response.status.to_string()); - proof_writer.commit(); - return Err(Error::Internal); - } - check_response - } - }; - - match pre.status { - MeltQuoteState::Paid => (), - MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => { - tracing::info!( - "Lightning payment for quote {} failed.", - melt_request.quote() - ); - return Err(Error::PaymentFailed); - } - MeltQuoteState::Pending => { - tracing::warn!( - "LN payment pending, proofs are stuck as pending for quote: {}", - melt_request.quote() - ); - proof_writer.commit(); - return Err(Error::PendingQuote); - } - } - - // Convert from unit of backend to quote unit - // Note: this should never fail since these conversions happen earlier and would fail there. - // Since it will not fail and even if it does the ln payment has already been paid, proofs should still be burned - let amount_spent = - to_unit(pre.total_spent, &pre.unit, "e.unit).unwrap_or_default(); - - let payment_lookup_id = pre.payment_lookup_id; - let mut tx = self.localstore.begin_transaction().await?; - - if payment_lookup_id != quote.request_lookup_id { - tracing::info!( - "Payment lookup id changed post payment from {} to {}", - quote.request_lookup_id, - payment_lookup_id - ); - - let mut melt_quote = quote; - melt_quote.request_lookup_id = payment_lookup_id; - - if let Err(err) = tx - .update_melt_quote_request_lookup_id( - &melt_quote.id, - &melt_quote.request_lookup_id, - ) - .await - { - tracing::warn!("Could not update payment lookup id: {}", err); - } - - (tx, pre.payment_proof, amount_spent, melt_quote) - } else { - (tx, pre.payment_proof, amount_spent, quote) - } - } - }; - - // If we made it here the payment has been made. - // We process the melt burning the inputs and returning change - let res = self - .process_melt_request( - tx, - proof_writer, - quote, - melt_request, - preimage, - amount_spent_quote_unit, - ) - .await - .map_err(|err| { - tracing::error!("Could not process melt request: {}", err); - err - })?; - - Ok(res) - } - /// Process melt request marking proofs as spent - /// The melt request must be verifyed using [`Self::verify_melt_request`] - /// before calling [`Self::process_melt_request`] - #[instrument(skip_all)] - pub async fn process_melt_request( - &self, - mut tx: Box + Send + Sync + '_>, - mut proof_writer: ProofWriter, - quote: MeltQuote, - melt_request: &MeltRequest, - payment_preimage: Option, - total_spent: Amount, - ) -> Result, Error> { - tracing::debug!("Processing melt quote: {}", melt_request.quote()); - - let input_ys = melt_request.inputs().ys()?; - - proof_writer - .update_proofs_states(&mut tx, &input_ys, State::Spent) - .await?; - - tx.update_melt_quote_state(melt_request.quote(), MeltQuoteState::Paid) - .await?; - - self.pubsub_manager.melt_quote_status( - "e, - payment_preimage.clone(), - None, - MeltQuoteState::Paid, - ); - - let mut change = None; - - // Check if there is change to return - if melt_request.proofs_amount()? > total_spent { - // Check if wallet provided change outputs - if let Some(outputs) = melt_request.outputs().clone() { - let blinded_messages: Vec = - outputs.iter().map(|b| b.blinded_secret).collect(); - - if tx - .get_blind_signatures(&blinded_messages) - .await? - .iter() - .flatten() - .next() - .is_some() - { - tracing::info!("Output has already been signed"); - - return Err(Error::BlindedMessageAlreadySigned); - } - - let fee = self.get_proofs_fee(melt_request.inputs()).await?; - - let change_target = melt_request.proofs_amount()? - total_spent - fee; - - let mut amounts = change_target.split(); - let mut change_sigs = Vec::with_capacity(amounts.len()); - - if outputs.len().lt(&amounts.len()) { - tracing::debug!( - "Providing change requires {} blinded messages, but only {} provided", - amounts.len(), - outputs.len() - ); - - // In the case that not enough outputs are provided to return all change - // Reverse sort the amounts so that the most amount of change possible is - // returned. The rest is burnt - amounts.sort_by(|a, b| b.cmp(a)); - } - - let mut outputs = outputs; - - for (amount, blinded_message) in amounts.iter().zip(&mut outputs) { - blinded_message.amount = *amount; - - let blinded_signature = self.blind_sign(blinded_message.clone()).await?; - change_sigs.push(blinded_signature) - } - - tx.add_blind_signatures( - &outputs[0..change_sigs.len()] - .iter() - .map(|o| o.blinded_secret) - .collect::>(), - &change_sigs, - Some(quote.id), - ) - .await?; - - change = Some(change_sigs); - } - } - - proof_writer.commit(); - tx.commit().await?; - - Ok(MeltQuoteBolt11Response { - amount: quote.amount, - paid: Some(true), - payment_preimage, - change, - quote: quote.id, - fee_reserve: quote.fee_reserve, - state: MeltQuoteState::Paid, - expiry: quote.expiry, - request: Some(quote.request.clone()), - unit: Some(quote.unit.clone()), - }) - } -} diff --git a/crates/cdk/src/mint/melt/melt_saga/compensation.rs b/crates/cdk/src/mint/melt/melt_saga/compensation.rs new file mode 100644 index 000000000..560b71f7e --- /dev/null +++ b/crates/cdk/src/mint/melt/melt_saga/compensation.rs @@ -0,0 +1,65 @@ +//! Compensation actions for the melt saga pattern. +//! +//! When a saga step fails, compensating actions are executed in reverse order (LIFO) +//! to undo all completed steps and restore the database to its pre-saga state. + +use async_trait::async_trait; +use cdk_common::database::DynMintDatabase; +use cdk_common::{Error, PublicKey, QuoteId}; +use tracing::instrument; + +/// Trait for compensating actions in the saga pattern. +/// +/// Compensating actions are registered as steps complete and executed in reverse +/// order (LIFO) if the saga fails. Each action should be idempotent. +#[async_trait] +pub trait CompensatingAction: Send + Sync { + async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error>; + fn name(&self) -> &'static str; +} + +/// Compensation action to remove melt setup and reset quote state. +/// +/// This compensation is used when payment fails or finalization fails after +/// the setup transaction has committed. It removes: +/// - Input proofs (identified by input_ys) +/// - Output blinded messages (identified by blinded_secrets) +/// - Melt request tracking record +/// +/// And resets: +/// - Quote state from Pending back to Unpaid +/// +/// This restores the database to its pre-melt state, allowing the user to retry. +pub struct RemoveMeltSetup { + /// Y values (public keys) from the input proofs + pub input_ys: Vec, + /// Blinded secrets (B values) from the change output blinded messages + pub blinded_secrets: Vec, + /// Quote ID to reset state + pub quote_id: QuoteId, +} + +#[async_trait] +impl CompensatingAction for RemoveMeltSetup { + #[instrument(skip_all)] + async fn execute(&self, db: &DynMintDatabase) -> Result<(), Error> { + tracing::info!( + "Compensation: Removing melt setup for quote {} ({} proofs, {} blinded messages)", + self.quote_id, + self.input_ys.len(), + self.blinded_secrets.len() + ); + + super::super::shared::rollback_melt_quote( + db, + &self.quote_id, + &self.input_ys, + &self.blinded_secrets, + ) + .await + } + + fn name(&self) -> &'static str { + "RemoveMeltSetup" + } +} diff --git a/crates/cdk/src/mint/melt/melt_saga/mod.rs b/crates/cdk/src/mint/melt/melt_saga/mod.rs new file mode 100644 index 000000000..81190b7fa --- /dev/null +++ b/crates/cdk/src/mint/melt/melt_saga/mod.rs @@ -0,0 +1,994 @@ +use std::collections::VecDeque; +use std::sync::Arc; + +use cdk_common::amount::to_unit; +use cdk_common::database::mint::MeltRequestInfo; +use cdk_common::database::DynMintDatabase; +use cdk_common::mint::{MeltSagaState, Operation, Saga, SagaStateEnum}; +use cdk_common::nuts::MeltQuoteState; +use cdk_common::{Amount, Error, ProofsMethods, PublicKey, QuoteId, State}; +#[cfg(feature = "prometheus")] +use cdk_prometheus::METRICS; +use tokio::sync::Mutex; +use tracing::instrument; + +use self::compensation::{CompensatingAction, RemoveMeltSetup}; +use self::state::{Initial, PaymentConfirmed, SettlementDecision, SetupComplete}; +use crate::cdk_payment::MakePaymentResponse; +use crate::mint::subscription::PubSubManager; +use crate::mint::verification::Verification; +use crate::mint::{MeltQuoteBolt11Response, MeltRequest}; + +mod compensation; +mod state; + +#[cfg(test)] +mod tests; + +/// Saga pattern implementation for atomic melt operations. +/// +/// # Why Use the Saga Pattern for Melt? +/// +/// The melt operation is more complex than swap because it involves: +/// 1. Database transactions (setup and finalize) +/// 2. External payment operations (Lightning Network) +/// 3. Uncertain payment states (pending/unknown) +/// 4. Change calculation based on actual payment amount +/// +/// Traditional ACID transactions cannot span: +/// 1. Multiple database transactions (TX1: setup, TX2: finalize) +/// 2. External payment operations (LN backend calls) +/// 3. Asynchronous payment confirmation +/// +/// The saga pattern solves this by: +/// - Breaking the operation into discrete steps with clear state transitions +/// - Recording compensating actions for each forward step +/// - Automatically rolling back via compensations if any step fails +/// - Handling payment state uncertainty explicitly +/// +/// # Transaction Boundaries +/// +/// - **TX1 (setup_melt)**: Atomically verifies quote, adds input proofs (pending), +/// adds change output blinded messages, creates melt request tracking record +/// - **Payment (make_payment)**: Non-transactional external LN payment operation +/// - **TX2 (finalize)**: Atomically updates quote state, marks inputs spent, +/// signs change outputs, deletes tracking record +/// +/// # Expected Flow +/// +/// 1. **setup_melt**: Verifies and reserves inputs, prepares change outputs +/// - Compensation: Removes inputs, outputs, resets quote state if later steps fail +/// 2. **make_payment**: Calls LN backend to make payment +/// - Triggers compensation if payment fails +/// - Special handling for pending/unknown states +/// 3. **finalize**: Commits the melt, issues change, marks complete +/// - Does NOT compensate if finalization fails (payment already confirmed) +/// - Startup check will retry finalization on recovery +/// - Clears compensations on success (melt complete) +/// +/// # Failure Handling +/// +/// Failure handling depends on whether payment was attempted: +/// +/// **Before payment attempt (SetupComplete state):** +/// - All compensating actions are executed in reverse order +/// - Database is restored to pre-melt state +/// - User can retry with same proofs +/// +/// **After payment attempt (PaymentAttempted state):** +/// - Compensation is NOT executed (would cause fund loss) +/// - Startup check will verify payment status with LN backend +/// - If payment succeeded: finalize is retried +/// - If payment failed: compensation runs +/// +/// This two-phase approach prevents fund loss where the mint pays the LN invoice +/// but returns the proofs to the user. +/// +/// # Payment State Complexity +/// +/// Unlike swap, melt must handle uncertain payment states: +/// - **Paid**: Proceed to finalize +/// - **Failed/Unpaid**: Compensate and return error +/// - **Pending/Unknown**: Proofs remain pending, saga cannot complete +/// (leave proofs pending for startup check to resolve) +/// +/// # Crash Recovery +/// +/// The saga persists its state for crash recovery: +/// - **SetupComplete**: Payment was never attempted → safe to compensate +/// - **PaymentAttempted**: Payment may have succeeded → must check LN backend +/// +/// On startup, the recovery process checks the persisted saga state and takes +/// appropriate action to either finalize (if payment succeeded) or compensate +/// (if payment was never sent or confirmed failed). +/// +/// # Typestate Pattern +/// +/// This saga uses the **typestate pattern** to enforce state transitions at compile-time. +/// Each state (Initial, SetupComplete, PaymentConfirmed) is a distinct type, and operations +/// are only available on the appropriate type: +/// +/// ```text +/// MeltSaga +/// └─> setup_melt() -> MeltSaga +/// ├─> attempt_internal_settlement() -> SettlementDecision (conditional) +/// └─> make_payment(SettlementDecision) -> MeltSaga +/// └─> finalize() -> MeltQuoteBolt11Response +/// ``` +/// +/// **Benefits:** +/// - Invalid state transitions (e.g., `finalize()` before `make_payment()`) won't compile +/// - State-specific data (e.g., payment_result) only exists in the appropriate state type +/// - No runtime state checks or `Option` unwrapping needed +/// - IDE autocomplete only shows valid operations for each state +pub struct MeltSaga { + mint: Arc, + db: DynMintDatabase, + pubsub: Arc, + /// Compensating actions in LIFO order (most recent first) + compensations: Arc>>>, + /// Operation for tracking + operation: Operation, + /// Tracks if metrics were incremented (for cleanup) + #[cfg(feature = "prometheus")] + metrics_incremented: bool, + /// State-specific data + state_data: S, +} + +impl MeltSaga { + pub fn new(mint: Arc, db: DynMintDatabase, pubsub: Arc) -> Self { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("melt_bolt11"); + + Self { + mint, + db, + pubsub, + compensations: Arc::new(Mutex::new(VecDeque::new())), + operation: Operation::new_melt(), + #[cfg(feature = "prometheus")] + metrics_incremented: true, + state_data: Initial, + } + } + + /// Sets up the melt by atomically verifying and reserving inputs/outputs. + /// + /// This is the first transaction (TX1) in the saga and must complete before payment. + /// + /// # What This Does + /// + /// Within a single database transaction: + /// 1. Verifies the melt request (inputs, quote state, balance) + /// 2. Adds input proofs to the database with Pending state + /// 3. Updates quote state from Unpaid/Failed to Pending + /// 4. Adds change output blinded messages to the database + /// 5. Creates melt request tracking record + /// 6. Publishes proof state changes via pubsub + /// + /// # Compensation + /// + /// Registers a compensation action that will: + /// - Remove input proofs + /// - Remove blinded messages + /// - Reset quote state from Pending to Unpaid + /// - Delete melt request tracking record + /// + /// This compensation runs if payment or finalization fails. + /// + /// # Errors + /// + /// - `PendingQuote`: Quote is already in Pending state + /// - `PaidQuote`: Quote has already been paid + /// - `TokenAlreadySpent`: Input proofs have already been spent + /// - `UnitMismatch`: Input unit doesn't match quote unit + #[instrument(skip_all)] + pub async fn setup_melt( + self, + melt_request: &MeltRequest, + input_verification: Verification, + ) -> Result, Error> { + tracing::info!("TX1: Setting up melt (verify + inputs + outputs)"); + + let Verification { + amount: input_amount, + unit: input_unit, + } = input_verification; + + let mut tx = self.db.begin_transaction().await?; + + // Add proofs to the database + if let Err(err) = tx + .add_proofs( + melt_request.inputs().clone(), + Some(melt_request.quote_id().to_owned()), + &self.operation, + ) + .await + { + tx.rollback().await?; + return Err(match err { + cdk_common::database::Error::Duplicate => Error::TokenPending, + cdk_common::database::Error::AttemptUpdateSpentProof => Error::TokenAlreadySpent, + err => Error::Database(err), + }); + } + + let input_ys = melt_request.inputs().ys()?; + + // Update proof states to Pending + let original_states = match tx.update_proofs_states(&input_ys, State::Pending).await { + Ok(states) => states, + Err(cdk_common::database::Error::AttemptUpdateSpentProof) + | Err(cdk_common::database::Error::AttemptRemoveSpentProof) => { + tx.rollback().await?; + return Err(Error::TokenAlreadySpent); + } + Err(err) => { + tx.rollback().await?; + return Err(err.into()); + } + }; + + // Check for forbidden states (Pending or Spent) + let has_forbidden_state = original_states + .iter() + .any(|state| matches!(state, Some(State::Pending) | Some(State::Spent))); + + if has_forbidden_state { + tx.rollback().await?; + return Err( + if original_states + .iter() + .any(|s| matches!(s, Some(State::Pending))) + { + Error::TokenPending + } else { + Error::TokenAlreadySpent + }, + ); + } + + // Update quote state to Pending + let (state, quote) = match tx + .update_melt_quote_state(melt_request.quote(), MeltQuoteState::Pending, None) + .await + { + Ok(result) => result, + Err(err) => { + tx.rollback().await?; + return Err(err.into()); + } + }; + + // Publish proof state changes + for pk in input_ys.iter() { + self.pubsub.proof_state((*pk, State::Pending)); + } + + if input_unit != Some(quote.unit.clone()) { + tx.rollback().await?; + return Err(Error::UnitMismatch); + } + + match state { + MeltQuoteState::Unpaid | MeltQuoteState::Failed => {} + MeltQuoteState::Pending => { + tx.rollback().await?; + return Err(Error::PendingQuote); + } + MeltQuoteState::Paid => { + tx.rollback().await?; + return Err(Error::PaidQuote); + } + MeltQuoteState::Unknown => { + tx.rollback().await?; + return Err(Error::UnknownPaymentState); + } + } + + self.pubsub + .melt_quote_status("e, None, None, MeltQuoteState::Pending); + + let fee = self.mint.get_proofs_fee(melt_request.inputs()).await?; + + let required_total = quote.amount + quote.fee_reserve + fee; + + if input_amount < required_total { + tracing::info!( + "Melt request unbalanced: inputs {}, amount {}, fee_reserve {}, input_fee {}, required {}", + input_amount, + quote.amount, + quote.fee_reserve, + fee, + required_total + ); + tx.rollback().await?; + return Err(Error::TransactionUnbalanced( + input_amount.into(), + quote.amount.into(), + (fee + quote.fee_reserve).into(), + )); + } + + // Verify outputs if provided + if let Some(outputs) = &melt_request.outputs() { + if !outputs.is_empty() { + let output_verification = match self.mint.verify_outputs(&mut tx, outputs).await { + Ok(verification) => verification, + Err(err) => { + tx.rollback().await?; + return Err(err); + } + }; + + if input_unit != output_verification.unit { + tx.rollback().await?; + return Err(Error::UnitMismatch); + } + } + } + + let inputs_fee = self.mint.get_proofs_fee(melt_request.inputs()).await?; + + // Add melt request tracking record + tx.add_melt_request( + melt_request.quote_id(), + melt_request.inputs_amount()?, + inputs_fee, + ) + .await?; + + // Add change output blinded messages + tx.add_blinded_messages( + Some(melt_request.quote_id()), + melt_request.outputs().as_ref().unwrap_or(&Vec::new()), + &self.operation, + ) + .await?; + + // Get blinded secrets for compensation + let blinded_secrets: Vec = melt_request + .outputs() + .as_ref() + .unwrap_or(&Vec::new()) + .iter() + .map(|bm| bm.blinded_secret) + .collect(); + + // Persist saga state for crash recovery (atomic with TX1) + let saga = Saga::new_melt( + *self.operation.id(), + MeltSagaState::SetupComplete, + input_ys.clone(), + blinded_secrets.clone(), + quote.id.to_string(), + ); + + if let Err(err) = tx.add_saga(&saga).await { + tx.rollback().await?; + return Err(err.into()); + } + + tx.commit().await?; + + // Store blinded messages for state + let blinded_messages_vec = melt_request.outputs().clone().unwrap_or_default(); + + // Register compensation (uses LIFO via push_front) + let compensations = Arc::clone(&self.compensations); + compensations + .lock() + .await + .push_front(Box::new(RemoveMeltSetup { + input_ys: input_ys.clone(), + blinded_secrets, + quote_id: quote.id.clone(), + })); + + // Transition to SetupComplete state + Ok(MeltSaga { + mint: self.mint, + db: self.db, + pubsub: self.pubsub, + compensations: self.compensations, + operation: self.operation, + #[cfg(feature = "prometheus")] + metrics_incremented: self.metrics_incremented, + state_data: SetupComplete { + quote, + input_ys, + blinded_messages: blinded_messages_vec, + }, + }) + } +} + +impl MeltSaga { + /// Attempts to settle the melt internally (melt-to-mint on same mint). + /// + /// This checks if the payment request corresponds to an existing mint quote + /// on the same mint, and if so, settles it atomically within a transaction. + /// + /// # What This Does + /// + /// Within a single database transaction: + /// 1. Checks if payment request matches a mint quote on this mint + /// 2. If not a match or different unit: returns (self, RequiresExternalPayment) + /// 3. If match found: validates quote state and amount + /// 4. Increments the mint quote's paid amount + /// 5. Publishes mint quote payment notification + /// 6. Returns (self, Internal{amount}) + /// + /// # Compensation + /// + /// If internal settlement fails, this method automatically calls compensate_all() + /// to roll back the setup_melt changes before returning the error. The saga is + /// consumed on error, so the caller cannot continue. + /// + /// # Returns + /// + /// - `Ok((self, Internal{amount}))`: Internal settlement succeeded, saga can continue + /// - `Ok((self, RequiresExternalPayment))`: Not an internal payment, saga can continue + /// - `Err(_)`: Internal settlement attempted but failed (compensations executed, saga consumed) + /// + /// # Errors + /// + /// - `RequestAlreadyPaid`: Mint quote already settled + /// - `InsufficientFunds`: Not enough input proofs for mint quote amount + /// - `Internal`: Database error during settlement + #[instrument(skip_all)] + pub async fn attempt_internal_settlement( + self, + melt_request: &MeltRequest, + ) -> Result<(Self, SettlementDecision), Error> { + tracing::info!("Checking for internal settlement opportunity"); + + let mut tx = self.db.begin_transaction().await?; + + let mint_quote = match tx + .get_mint_quote_by_request(&self.state_data.quote.request.to_string()) + .await + { + Ok(Some(mint_quote)) if mint_quote.unit == self.state_data.quote.unit => mint_quote, + Ok(_) => { + tx.rollback().await?; + tracing::debug!("Not an internal payment or unit mismatch"); + return Ok((self, SettlementDecision::RequiresExternalPayment)); + } + Err(err) => { + tx.rollback().await?; + tracing::debug!("Error checking for mint quote: {}", err); + self.compensate_all().await?; + return Err(Error::Internal); + } + }; + + // Mint quote has already been settled + if (mint_quote.state() == cdk_common::nuts::MintQuoteState::Issued + || mint_quote.state() == cdk_common::nuts::MintQuoteState::Paid) + && mint_quote.payment_method == crate::mint::PaymentMethod::Bolt11 + { + tx.rollback().await?; + self.compensate_all().await?; + return Err(Error::RequestAlreadyPaid); + } + + let inputs_amount_quote_unit = melt_request.inputs_amount().map_err(|_| { + tracing::error!("Proof inputs in melt quote overflowed"); + Error::AmountOverflow + })?; + + if let Some(amount) = mint_quote.amount { + if amount > inputs_amount_quote_unit { + tracing::debug!( + "Not enough inputs provided: {} needed {}", + inputs_amount_quote_unit, + amount + ); + tx.rollback().await?; + self.compensate_all().await?; + return Err(Error::InsufficientFunds); + } + } + + let amount = self.state_data.quote.amount; + + tracing::info!( + "Mint quote {} paid {} from internal payment.", + mint_quote.id, + amount + ); + + // Update saga state to PaymentAttempted BEFORE internal settlement commits + // This ensures crash recovery knows payment may have occurred + tx.update_saga( + self.operation.id(), + SagaStateEnum::Melt(MeltSagaState::PaymentAttempted), + ) + .await?; + + let total_paid = tx + .increment_mint_quote_amount_paid( + &mint_quote.id, + amount, + self.state_data.quote.id.to_string(), + ) + .await?; + + self.pubsub.mint_quote_payment(&mint_quote, total_paid); + + tracing::info!( + "Melt quote {} paid Mint quote {}", + self.state_data.quote.id, + mint_quote.id + ); + + tx.commit().await?; + + Ok((self, SettlementDecision::Internal { amount })) + } + + /// Makes payment via Lightning Network backend or internal settlement. + /// + /// This is an external operation that happens after `setup_melt` and before `finalize`. + /// No database changes occur in this step (except for internal settlement case). + /// + /// # What This Does + /// + /// 1. Takes a SettlementDecision from attempt_internal_settlement + /// 2. If Internal: creates payment result directly + /// 3. If RequiresExternalPayment: + /// - Updates saga state to `PaymentAttempted` (for crash recovery) + /// - Calls LN backend to make payment + /// 4. Handles payment result states with idempotent verification + /// 5. Transitions to PaymentConfirmed state on success + /// + /// # Crash Tolerance + /// + /// For external payments, the saga state is updated to `PaymentAttempted` BEFORE + /// calling the LN backend. This write-ahead logging ensures that if the process + /// crashes after payment but before finalize, the startup recovery will: + /// - See `PaymentAttempted` state + /// - Check with LN backend to determine if payment succeeded + /// - Finalize if paid, compensate if failed + /// + /// # Idempotent Payment Verification + /// + /// Lightning payments are asynchronous, and the LN backend may return different + /// states for the same payment query due to: + /// - Network latency between payment initiation and confirmation + /// - Backend database replication lag + /// - HTLC settlement timing + /// + /// **Critical Principle**: If `check_payment_state()` confirms the payment as Paid, + /// we MUST proceed to finalize, regardless of what `make_payment()` initially returned. + /// This ensures the saga is idempotent with respect to payment confirmation. + /// + /// # Failure Handling + /// + /// If payment is confirmed as failed/unpaid, all registered compensations are + /// executed to roll back the setup transaction. + /// + /// # Errors + /// + /// - `PaymentFailed`: Payment confirmed as failed/unpaid + /// - `PendingQuote`: Payment is pending (will be resolved by startup check) + #[instrument(skip_all)] + pub async fn make_payment( + self, + settlement: SettlementDecision, + ) -> Result, Error> { + tracing::info!("Making payment (external LN operation or internal settlement)"); + + let payment_result = match settlement { + SettlementDecision::Internal { amount } => { + tracing::info!( + "Payment settled internally for {} {}", + amount, + self.state_data.quote.unit + ); + MakePaymentResponse { + status: MeltQuoteState::Paid, + total_spent: amount, + unit: self.state_data.quote.unit.clone(), + payment_proof: None, + payment_lookup_id: self + .state_data + .quote + .request_lookup_id + .clone() + .unwrap_or_else(|| { + cdk_common::payment::PaymentIdentifier::CustomId( + self.state_data.quote.id.to_string(), + ) + }), + } + } + SettlementDecision::RequiresExternalPayment => { + // Get LN payment processor + let ln = self + .mint + .payment_processors + .get(&crate::types::PaymentProcessorKey::new( + self.state_data.quote.unit.clone(), + self.state_data.quote.payment_method.clone(), + )) + .ok_or_else(|| { + tracing::info!( + "Could not get ln backend for {}, {}", + self.state_data.quote.unit, + self.state_data.quote.payment_method + ); + Error::UnsupportedUnit + })?; + + // Update saga state to PaymentAttempted BEFORE making payment + // This ensures crash recovery knows payment may have been attempted + { + let mut tx = self.db.begin_transaction().await?; + tx.update_saga( + self.operation.id(), + SagaStateEnum::Melt(MeltSagaState::PaymentAttempted), + ) + .await?; + tx.commit().await?; + } + + // Make payment with idempotent verification + let payment_response = match ln + .make_payment( + &self.state_data.quote.unit, + self.state_data.quote.clone().try_into()?, + ) + .await + { + Ok(pay) + if pay.status == MeltQuoteState::Unknown + || pay.status == MeltQuoteState::Failed => + { + tracing::warn!( + "Got {} status when paying melt quote {} for {} {}. Verifying with backend...", + pay.status, + self.state_data.quote.id, + self.state_data.quote.amount, + self.state_data.quote.unit + ); + + let check_response = self + .check_payment_state(Arc::clone(ln), &pay.payment_lookup_id) + .await?; + + if check_response.status == MeltQuoteState::Paid { + // Race condition: Payment succeeded during verification + tracing::info!( + "Payment initially returned {} but confirmed as Paid. Proceeding to finalize.", + pay.status + ); + check_response + } else { + check_response + } + } + Ok(pay) => pay, + Err(err) => { + if matches!(err, crate::cdk_payment::Error::InvoiceAlreadyPaid) { + tracing::info!("Invoice already paid, verifying payment status"); + } else { + // Other error - check if payment actually succeeded + tracing::error!( + "Error returned attempting to pay: {} {}", + self.state_data.quote.id, + err + ); + } + + let lookup_id = self + .state_data + .quote + .request_lookup_id + .as_ref() + .ok_or_else(|| { + tracing::error!( + "No payment id, cannot verify payment status for {} after error", + self.state_data.quote.id + ); + Error::Internal + })?; + + let check_response = + self.check_payment_state(Arc::clone(ln), lookup_id).await?; + + tracing::info!( + "Initial payment attempt for {} errored. Follow up check stateus: {}", + self.state_data.quote.id, + check_response.status + ); + + check_response + } + }; + + match payment_response.status { + MeltQuoteState::Paid => payment_response, + MeltQuoteState::Unpaid | MeltQuoteState::Failed => { + tracing::info!( + "Lightning payment for quote {} failed.", + self.state_data.quote.id + ); + self.compensate_all().await?; + return Err(Error::PaymentFailed); + } + MeltQuoteState::Unknown => { + tracing::warn!( + "LN payment unknown, proofs remain pending for quote: {}", + self.state_data.quote.id + ); + return Err(Error::PaymentFailed); + } + MeltQuoteState::Pending => { + tracing::warn!( + "LN payment pending, proofs remain pending for quote: {}", + self.state_data.quote.id + ); + return Err(Error::PendingQuote); + } + } + } + }; + + // TODO: Add total spent > quote check + + // Transition to PaymentConfirmed state + Ok(MeltSaga { + mint: self.mint, + db: self.db, + pubsub: self.pubsub, + compensations: self.compensations, + operation: self.operation, + #[cfg(feature = "prometheus")] + metrics_incremented: self.metrics_incremented, + state_data: PaymentConfirmed { + quote: self.state_data.quote, + input_ys: self.state_data.input_ys, + blinded_messages: self.state_data.blinded_messages, + payment_result, + }, + }) + } + + /// Helper to check payment state with LN backend + async fn check_payment_state( + &self, + ln: Arc< + dyn cdk_common::payment::MintPayment + Send + Sync, + >, + lookup_id: &cdk_common::payment::PaymentIdentifier, + ) -> Result { + match ln.check_outgoing_payment(lookup_id).await { + Ok(response) => Ok(response), + Err(check_err) => { + tracing::error!( + "Could not check the status of payment for {}. Proofs stuck as pending", + lookup_id + ); + tracing::error!("Checking payment error: {}", check_err); + Err(Error::Internal) + } + } + } +} + +impl MeltSaga { + /// Finalizes the melt by committing signatures and marking inputs as spent. + /// + /// This is the second and final transaction (TX2) in the saga and completes the melt. + /// + /// # What This Does + /// + /// Within a single database transaction: + /// 1. Updates quote state to Paid + /// 2. Updates payment lookup ID if changed + /// 3. Marks input proofs as Spent + /// 4. Calculates and signs change outputs (if applicable) + /// 5. Deletes melt request tracking record + /// 6. Publishes quote status changes via pubsub + /// 7. Clears all registered compensations (melt successfully completed) + /// + /// # Change Handling + /// + /// If inputs > total_spent: + /// - If change outputs were provided: sign them and return + /// - If no change outputs: change is burnt (logged as info) + /// + /// # Success + /// + /// On success, compensations are cleared and the melt is complete. + /// + /// # Failure Handling + /// + /// **Critical**: If finalization fails, compensation is NOT executed because + /// payment was already confirmed as Paid. Compensating would return proofs to + /// the user while the mint has already paid the Lightning invoice, causing fund loss. + /// + /// Instead, the error is returned and the saga remains in the database with + /// `PaymentAttempted` state. On startup, the recovery process will: + /// 1. Find the incomplete saga + /// 2. Check the LN backend (which will confirm payment as Paid) + /// 3. Retry finalization + /// + /// # Errors + /// + /// - `TokenAlreadySpent`: Input proofs were already spent + /// - `BlindedMessageAlreadySigned`: Change outputs already signed + /// - `UnitMismatch`: Failed to convert payment amount to quote unit + #[instrument(skip_all)] + pub async fn finalize(self) -> Result, Error> { + tracing::info!("TX2: Finalizing melt (mark spent + change)"); + + let total_spent = to_unit( + self.state_data.payment_result.total_spent, + &self.state_data.payment_result.unit, + &self.state_data.quote.unit, + ) + .map_err(|e| { + tracing::error!("Failed to convert total_spent to quote unit: {:?}", e); + Error::UnitMismatch + })?; + + let payment_preimage = self.state_data.payment_result.payment_proof.clone(); + let payment_lookup_id = &self.state_data.payment_result.payment_lookup_id; + + let mut tx = self.db.begin_transaction().await?; + + // Get melt request info first (needed for validation and change) + let MeltRequestInfo { + inputs_amount, + inputs_fee, + change_outputs, + } = tx + .get_melt_request_and_blinded_messages(&self.state_data.quote.id) + .await? + .ok_or(Error::UnknownQuote)?; + + // Use shared core finalization logic + if let Err(err) = super::shared::finalize_melt_core( + &mut tx, + &self.pubsub, + &self.state_data.quote, + &self.state_data.input_ys, + inputs_amount, + inputs_fee, + total_spent, + payment_preimage.clone(), + payment_lookup_id, + ) + .await + { + // Do NOT compensate here - payment was already confirmed as Paid + // Startup check will retry finalization on next recovery cycle + tracing::error!( + "Finalize failed for paid melt quote {} - will retry on startup: {}", + self.state_data.quote.id, + err + ); + + tx.rollback().await?; + return Err(err); + } + + let needs_change = inputs_amount > total_spent; + + // Handle change: either sign change outputs or just commit TX1 + let (change, mut tx) = if !needs_change { + // No change required - just commit TX1 + tracing::debug!("No change required for melt {}", self.state_data.quote.id); + (None, tx) + } else { + // We commit tx here as process_change can make external call to blind sign + // We do not want to hold db txs across external calls + tx.commit().await?; + super::shared::process_melt_change( + &self.mint, + &self.db, + &self.state_data.quote.id, + inputs_amount, + total_spent, + inputs_fee, + change_outputs, + ) + .await? + }; + + tx.delete_melt_request(&self.state_data.quote.id).await?; + + // Delete saga - melt completed successfully (best-effort) + if let Err(e) = tx.delete_saga(self.operation.id()).await { + tracing::warn!("Failed to delete saga in finalize: {}", e); + // Don't rollback - melt succeeded + } + + tx.commit().await?; + + self.pubsub.melt_quote_status( + &self.state_data.quote, + payment_preimage.clone(), + change.clone(), + MeltQuoteState::Paid, + ); + + tracing::debug!( + "Melt for quote {} completed total spent {}, total inputs: {}, change given: {}", + self.state_data.quote.id, + total_spent, + inputs_amount, + change + .as_ref() + .map(|c| Amount::try_sum(c.iter().map(|a| a.amount)) + .expect("Change cannot overflow")) + .unwrap_or_default() + ); + + self.compensations.lock().await.clear(); + + #[cfg(feature = "prometheus")] + if self.metrics_incremented { + METRICS.dec_in_flight_requests("melt_bolt11"); + METRICS.record_mint_operation("melt_bolt11", true); + } + + let response = MeltQuoteBolt11Response { + amount: self.state_data.quote.amount, + payment_preimage, + change, + quote: self.state_data.quote.id, + fee_reserve: self.state_data.quote.fee_reserve, + state: MeltQuoteState::Paid, + expiry: self.state_data.quote.expiry, + request: Some(self.state_data.quote.request.to_string()), + unit: Some(self.state_data.quote.unit.clone()), + }; + + Ok(response) + } +} + +impl MeltSaga { + /// Execute all compensating actions and consume the saga. + /// + /// This method takes ownership of self to ensure the saga cannot be used + /// after compensation has been triggered. + /// + /// This is called internally by saga methods when they need to compensate. + #[instrument(skip_all)] + async fn compensate_all(self) -> Result<(), Error> { + let mut compensations = self.compensations.lock().await; + + if compensations.is_empty() { + return Ok(()); + } + + #[cfg(feature = "prometheus")] + if self.metrics_incremented { + METRICS.dec_in_flight_requests("melt_bolt11"); + METRICS.record_mint_operation("melt_bolt11", false); + METRICS.record_error(); + } + + tracing::warn!("Running {} compensating actions", compensations.len()); + + while let Some(compensation) = compensations.pop_front() { + tracing::debug!("Running compensation: {}", compensation.name()); + if let Err(e) = compensation.execute(&self.db).await { + tracing::error!( + "Compensation {} failed: {}. Continuing...", + compensation.name(), + e + ); + } + } + + Ok(()) + } +} diff --git a/crates/cdk/src/mint/melt/melt_saga/state.rs b/crates/cdk/src/mint/melt/melt_saga/state.rs new file mode 100644 index 000000000..b6ade7662 --- /dev/null +++ b/crates/cdk/src/mint/melt/melt_saga/state.rs @@ -0,0 +1,46 @@ +use cdk_common::nuts::BlindedMessage; +use cdk_common::{Amount, PublicKey}; + +use crate::cdk_payment::MakePaymentResponse; +use crate::mint::MeltQuote; + +/// Initial state - no data yet. +/// +/// The melt saga starts in this state. Only the `setup_melt` method is available. +pub struct Initial; + +/// Setup complete - has quote, input Ys, and blinded messages. +/// +/// After successful setup, the saga transitions to this state. +/// The `attempt_internal_settlement` and `make_payment` methods are available. +pub struct SetupComplete { + pub quote: MeltQuote, + pub input_ys: Vec, + pub blinded_messages: Vec, +} + +/// Payment confirmed - has everything including payment result. +/// +/// After successful payment (internal or external), the saga transitions to this state. +/// Only the `finalize` method is available. +pub struct PaymentConfirmed { + pub quote: MeltQuote, + pub input_ys: Vec, + #[allow(dead_code)] // Stored for completeness, accessed from DB in finalize + pub blinded_messages: Vec, + pub payment_result: MakePaymentResponse, +} + +/// Result of attempting internal settlement for a melt operation. +/// +/// This enum represents the decision point in the melt flow: +/// - Internal settlement succeeded → skip external Lightning payment +/// - External payment required → proceed with Lightning Network call +#[derive(Debug, Clone)] +pub enum SettlementDecision { + /// Payment was settled internally (melt-to-mint on the same mint). + /// Contains the amount that was settled. + Internal { amount: Amount }, + /// Payment requires external Lightning Network settlement. + RequiresExternalPayment, +} diff --git a/crates/cdk/src/mint/melt/melt_saga/tests.rs b/crates/cdk/src/mint/melt/melt_saga/tests.rs new file mode 100644 index 000000000..67c24344f --- /dev/null +++ b/crates/cdk/src/mint/melt/melt_saga/tests.rs @@ -0,0 +1,2866 @@ +//! Tests for melt saga pattern implementation +//! +//! This test module covers: +//! - Basic state transitions +//! - Crash recovery scenarios +//! - Saga persistence and deletion +//! - Compensation execution +//! - Concurrent operations +//! - Failure handling + +use std::str::FromStr; + +use cdk_common::mint::{MeltSagaState, OperationKind, Saga}; +use cdk_common::nuts::MeltQuoteState; +use cdk_common::{Amount, ProofsMethods, State}; + +use crate::mint::melt::melt_saga::MeltSaga; +use crate::test_helpers::mint::{create_test_mint, mint_test_proofs}; + +// ============================================================================ +// Basic State Transition Tests +// ============================================================================ + +/// Test: Saga can be created in Initial state +#[tokio::test] +async fn test_melt_saga_initial_state_creation() { + let mint = create_test_mint().await.unwrap(); + let db = mint.localstore(); + let pubsub = mint.pubsub_manager(); + + let _saga = MeltSaga::new(std::sync::Arc::new(mint.clone()), db, pubsub); + // Type system enforces Initial state - if this compiles, test passes +} + +// ============================================================================ +// Saga Persistence Tests +// ============================================================================ + +/// Test: Saga state is persisted atomically with setup transaction +#[tokio::test] +async fn test_saga_state_persistence_after_setup() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup melt saga + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + + // STEP 3: Query database for saga + let sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + + // STEP 4: Find our saga + let persisted_saga = sagas + .iter() + .find(|s| s.operation_id == operation_id) + .expect("Saga should be persisted"); + + // STEP 5: Validate saga content + assert_eq!( + persisted_saga.operation_id, operation_id, + "Operation ID should match" + ); + assert_eq!( + persisted_saga.operation_kind, + OperationKind::Melt, + "Operation kind should be Melt" + ); + + // Verify state is SetupComplete + match &persisted_saga.state { + cdk_common::mint::SagaStateEnum::Melt(state) => { + assert_eq!( + *state, + MeltSagaState::SetupComplete, + "State should be SetupComplete" + ); + } + _ => panic!("Expected Melt saga state"), + } + + // STEP 6: Verify input_ys are stored + let input_ys = proofs.ys().unwrap(); + assert_eq!( + persisted_saga.input_ys.len(), + input_ys.len(), + "Should store all input Ys" + ); + for y in &input_ys { + assert!( + persisted_saga.input_ys.contains(y), + "Input Y should be stored: {:?}", + y + ); + } + + // STEP 7: Verify timestamps are set + assert!( + persisted_saga.created_at > 0, + "Created timestamp should be set" + ); + assert!( + persisted_saga.updated_at > 0, + "Updated timestamp should be set" + ); + assert_eq!( + persisted_saga.created_at, persisted_saga.updated_at, + "Timestamps should match for new saga" + ); + + // STEP 8: Verify blinded_secrets is empty (not used for melt) + assert!( + persisted_saga.blinded_secrets.is_empty(), + "Melt saga should not store blinded_secrets" + ); + + // SUCCESS: Saga persisted correctly! +} + +/// Test: Saga is deleted after successful finalization +#[tokio::test] +async fn test_saga_deletion_on_success() { + // STEP 1: Setup test environment (FakeWallet handles payments automatically) + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create proofs and quote + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 3: Complete full melt flow + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + + // Setup + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + let operation_id = *setup_saga.operation.id(); + + // Verify saga exists + assert_saga_exists(&mint, &operation_id).await; + + // Attempt internal settlement (will fail, go to external payment) + let (payment_saga, decision) = setup_saga + .attempt_internal_settlement(&melt_request) + .await + .unwrap(); + + // Make payment (FakeWallet will return success based on FakeInvoiceDescription) + let confirmed_saga = payment_saga.make_payment(decision).await.unwrap(); + + // Finalize + let _response = confirmed_saga.finalize().await.unwrap(); + + // STEP 4: Verify saga was deleted + assert_saga_not_exists(&mint, &operation_id).await; + + // STEP 5: Verify no incomplete sagas remain + let sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + assert!(sagas.is_empty(), "Should have no incomplete melt sagas"); + + // SUCCESS: Saga cleaned up on success! +} + +/// Test: Saga remains in database if finalize fails +#[tokio::test] +async fn test_saga_persists_on_finalize_failure() { + // TODO: Implement this test + // 1. Setup melt saga successfully + // 2. Simulate finalize failure (e.g., database error) + // 3. Verify saga still exists in database + // 4. Verify state is still SetupComplete +} + +// ============================================================================ +// Crash Recovery Tests - SetupComplete State +// ============================================================================ + +/// Test: Recovery from crash after setup but before payment +/// +/// This is the primary crash recovery scenario. If the mint crashes after +/// setup_melt() completes but before payment is sent, the proofs should be +/// restored on restart. +#[tokio::test] +async fn test_crash_recovery_setup_complete() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create test proofs (10,000 millisats = 10 sats) + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + + // STEP 3: Create melt quote (9,000 millisats = 9 sats) + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + + // STEP 4: Create melt request + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 5: Setup melt saga (this persists saga to DB) + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga + .setup_melt(&melt_request, verification) + .await + .expect("Setup should succeed"); + + // STEP 6: Verify proofs are PENDING + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // STEP 7: Verify saga was persisted + let operation_id = *setup_saga.operation.id(); + assert_saga_exists(&mint, &operation_id).await; + + // STEP 8: Simulate crash - drop saga without finalizing + drop(setup_saga); + + // STEP 9: Run recovery (simulating mint restart) + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 10: Verify proofs were REMOVED (restored to client) + assert_proofs_state(&mint, &input_ys, None).await; + + // STEP 11: Verify saga was deleted + assert_saga_not_exists(&mint, &operation_id).await; + + // STEP 12: Verify quote state reset to UNPAID + let recovered_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .expect("Quote should still exist"); + assert_eq!( + recovered_quote.state, + MeltQuoteState::Unpaid, + "Quote state should be reset to Unpaid after recovery" + ); + + // SUCCESS: Crash recovery works! +} + +/// Test: Multiple incomplete sagas can be recovered +/// +/// This test validates that the recovery mechanism can handle multiple +/// incomplete sagas in a single recovery pass, ensuring batch operations work. +#[tokio::test] +async fn test_crash_recovery_multiple_sagas() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create multiple incomplete melt sagas (5 sagas) + let mut operation_ids = Vec::new(); + let mut proof_ys_list = Vec::new(); + let mut quote_ids = Vec::new(); + + for i in 0..5 { + // Use smaller amounts to fit within FakeWallet limits + let proofs = mint_test_proofs(&mint, Amount::from(5_000 + i * 100)) + .await + .unwrap(); + let input_ys = proofs.ys().unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(4_000 + i * 100)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + operation_ids.push(*setup_saga.operation.id()); + proof_ys_list.push(input_ys); + quote_ids.push(quote.id.clone()); + + // Drop saga to simulate crash + drop(setup_saga); + } + + // STEP 3: Verify all sagas exist before recovery + let sagas_before = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + + assert_eq!( + sagas_before.len(), + 5, + "Should have 5 incomplete sagas before recovery" + ); + + // Verify all our operation IDs are present + for operation_id in &operation_ids { + assert!( + sagas_before.iter().any(|s| s.operation_id == *operation_id), + "Saga {} should exist before recovery", + operation_id + ); + } + + // Verify all proofs are PENDING + for input_ys in &proof_ys_list { + assert_proofs_state(&mint, input_ys, Some(State::Pending)).await; + } + + // STEP 4: Run recovery (should handle all sagas) + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 5: Verify all sagas were recovered and cleaned up + let sagas_after = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + + assert!( + sagas_after.is_empty(), + "All sagas should be deleted after recovery" + ); + + // Verify none of our operation IDs exist + for operation_id in &operation_ids { + assert_saga_not_exists(&mint, operation_id).await; + } + + // STEP 6: Verify all proofs were removed (returned to client) + for input_ys in &proof_ys_list { + assert_proofs_state(&mint, input_ys, None).await; + } + + // STEP 7: Verify all quotes were reset to UNPAID + for quote_id in "e_ids { + let recovered_quote = mint + .localstore + .get_melt_quote(quote_id) + .await + .unwrap() + .expect("Quote should still exist"); + + assert_eq!( + recovered_quote.state, + MeltQuoteState::Unpaid, + "Quote {} should be reset to Unpaid", + quote_id + ); + } + + // SUCCESS: Multiple sagas recovered successfully! +} + +/// Test: Recovery handles sagas gracefully even when data relationships exist +/// +/// This test verifies that recovery works correctly in a standard crash scenario +/// where all data is intact (saga, quote, proofs all exist). +#[tokio::test] +async fn test_crash_recovery_orphaned_saga() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Create incomplete saga + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + let input_ys = proofs.ys().unwrap(); + + // Drop saga (simulate crash) + drop(setup_saga); + + // Verify saga exists + assert_saga_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // STEP 3: Run recovery + // Recovery should handle the saga gracefully, cleaning up all state + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 4: Verify saga was cleaned up + assert_saga_not_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, None).await; + + // Verify quote was reset + let recovered_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!(recovered_quote.state, MeltQuoteState::Unpaid); + + // SUCCESS: Recovery works correctly! +} + +/// Test: Recovery continues even if one saga fails +#[tokio::test] +async fn test_crash_recovery_partial_failure() { + // TODO: Implement this test + // 1. Create multiple incomplete sagas + // 2. Make one saga fail (e.g., corrupted data) + // 3. Run recovery + // 4. Verify other sagas were still recovered + // 5. Verify failed saga is logged but doesn't stop recovery +} + +/// Test: Crash recovery after internal settlement commits but before finalize +/// +/// This test verifies that if the mint crashes after internal settlement +/// (melt-to-mint on same mint) commits but before finalize() completes, +/// recovery will correctly finalize the melt rather than compensating. +/// +/// This prevents fund loss where: +/// - The mint quote was credited (mint received funds) +/// - But proofs are returned to user (double-spend) +#[tokio::test] +async fn test_crash_recovery_internal_settlement() { + use cdk_common::nuts::MintQuoteState; + use cdk_common::MintQuoteBolt11Request; + + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + + // STEP 2: Create a mint quote that will be paid internally + // This creates a payment request (invoice) on this mint + // Note: We use a smaller amount (4000) because the test mint has 100% fee reserve, + // so required = amount + fee_reserve = 4000 + 4000 = 8000 < 10000 inputs + let mint_quote_response: cdk_common::MintQuoteBolt11Response<_> = mint + .get_mint_quote( + MintQuoteBolt11Request { + amount: Amount::from(4_000), + unit: cdk_common::CurrencyUnit::Sat, + description: None, + pubkey: None, + } + .into(), + ) + .await + .unwrap() + .into(); + + // Get the mint quote from database + let mint_quote_id = cdk_common::QuoteId::from_str(&mint_quote_response.quote).unwrap(); + let mint_quote = mint + .localstore + .get_mint_quote(&mint_quote_id) + .await + .unwrap() + .expect("Mint quote should exist"); + + // STEP 3: Create a melt quote that uses the mint quote's payment request + // This will trigger internal settlement since it's the same mint + use cdk_common::melt::MeltQuoteRequest; + use cdk_common::nuts::MeltQuoteBolt11Request; + + let melt_bolt11_request = MeltQuoteBolt11Request { + request: mint_quote.request.to_string().parse().unwrap(), + unit: cdk_common::CurrencyUnit::Sat, + options: None, + }; + let melt_quote_request = MeltQuoteRequest::Bolt11(melt_bolt11_request); + + let melt_quote_response = mint.get_melt_quote(melt_quote_request).await.unwrap(); + let melt_quote = mint + .localstore + .get_melt_quote(&melt_quote_response.quote) + .await + .unwrap() + .expect("Melt quote should exist"); + + // STEP 4: Create melt request and setup saga + let melt_request = create_test_melt_request(&proofs, &melt_quote); + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + let operation_id = *setup_saga.operation.id(); + + // STEP 5: Attempt internal settlement - this will commit and update saga state + let (payment_saga, decision) = setup_saga + .attempt_internal_settlement(&melt_request) + .await + .unwrap(); + + // Verify internal settlement was detected + match decision { + crate::mint::melt::melt_saga::state::SettlementDecision::Internal { amount } => { + assert_eq!( + amount, + Amount::from(4_000), + "Internal settlement amount should match" + ); + } + _ => panic!("Expected internal settlement decision"), + } + + // STEP 6: Simulate crash - drop saga WITHOUT calling make_payment/finalize + drop(payment_saga); + + // STEP 7: Verify pre-recovery state + // Saga should exist in PaymentAttempted state (updated by internal settlement) + let persisted_saga = assert_saga_exists(&mint, &operation_id).await; + match &persisted_saga.state { + cdk_common::mint::SagaStateEnum::Melt(state) => { + assert_eq!( + *state, + MeltSagaState::PaymentAttempted, + "Saga should be in PaymentAttempted state after internal settlement" + ); + } + _ => panic!("Expected Melt saga state"), + } + + // Proofs should still be Pending + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // Mint quote should be paid (internal settlement committed) + let mint_quote_after = mint + .localstore + .get_mint_quote(&mint_quote_id) + .await + .unwrap() + .expect("Mint quote should exist"); + assert_eq!( + mint_quote_after.state(), + MintQuoteState::Paid, + "Mint quote should be paid after internal settlement" + ); + + // STEP 8: Run recovery + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 9: Verify post-recovery state + // Saga should be deleted (successfully finalized) + assert_saga_not_exists(&mint, &operation_id).await; + + // CRITICAL: Proofs should be SPENT, not returned (None) + // This is the key assertion - if proofs were compensated, they'd be None + assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await; + + // Melt quote should be Paid + let melt_quote_after = mint + .localstore + .get_melt_quote(&melt_quote.id) + .await + .unwrap() + .expect("Melt quote should exist"); + assert_eq!( + melt_quote_after.state, + MeltQuoteState::Paid, + "Melt quote should be paid after recovery" + ); + + // Mint quote should still be paid + let mint_quote_final = mint + .localstore + .get_mint_quote(&mint_quote_id) + .await + .unwrap() + .expect("Mint quote should exist"); + assert_eq!( + mint_quote_final.state(), + MintQuoteState::Paid, + "Mint quote should remain paid after recovery" + ); + + // SUCCESS: Recovery correctly finalized internal settlement! + // No fund loss - proofs spent and mint quote paid +} + +// ============================================================================ +// Startup Integration Tests +// ============================================================================ + +/// Test: Startup recovery is called on mint.start() +#[tokio::test] +async fn test_startup_recovery_integration() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create incomplete saga + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + let input_ys = proofs.ys().unwrap(); + + // Drop saga (simulate crash) + drop(setup_saga); + + // Verify saga exists after setup + assert_saga_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // STEP 3: Manually trigger recovery (simulating restart behavior) + // Note: create_test_mint() already calls mint.start(), so recovery should + // have run on startup. However, since we created the saga AFTER startup, + // we need to manually trigger recovery to simulate a restart scenario. + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 4: Verify recovery was executed + assert_saga_not_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, None).await; + + // STEP 5: Verify mint is running normally + // (Can perform new melt operations) + let new_proofs = mint_test_proofs(&mint, Amount::from(5_000)).await.unwrap(); + let new_quote = create_test_melt_quote(&mint, Amount::from(4_000)).await; + let new_request = create_test_melt_request(&new_proofs, &new_quote); + + let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap(); + let new_saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let _new_setup = new_saga + .setup_melt(&new_request, new_verification) + .await + .unwrap(); + + // SUCCESS: Recovery runs on startup and mint works normally! +} + +/// Test: Startup never fails due to recovery errors +#[tokio::test] +async fn test_startup_resilient_to_recovery_errors() { + // TODO: Implement this test + // 1. Create corrupted saga data + // 2. Call mint.start() + // 3. Verify start() completes successfully + // 4. Verify error was logged +} + +// ============================================================================ +// Compensation Tests +// ============================================================================ + +/// Test: Compensation removes proofs from database +/// +/// This test validates that when compensation runs (during crash recovery), +/// the proofs are properly removed from the database and returned to the client. +#[tokio::test] +async fn test_compensation_removes_proofs() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup melt saga (this marks proofs as PENDING) + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + + // Verify proofs are PENDING + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // STEP 3: Simulate crash and trigger compensation via recovery + drop(setup_saga); + + // Run recovery which triggers compensation + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 4: Verify proofs were removed from database (returned to client) + assert_proofs_state(&mint, &input_ys, None).await; + + // STEP 5: Verify saga was cleaned up + assert_saga_not_exists(&mint, &operation_id).await; + + // STEP 6: Verify proofs can be used again in a new melt operation + let new_quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let new_request = create_test_melt_request(&proofs, &new_quote); + + let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap(); + let new_saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let new_setup = new_saga + .setup_melt(&new_request, new_verification) + .await + .expect("Should be able to reuse proofs after compensation"); + + // Verify new saga was created successfully + assert_saga_exists(&mint, new_setup.operation.id()).await; + + // SUCCESS: Compensation properly removed proofs and they can be reused! +} + +/// Test: Compensation removes change outputs +/// +/// This test validates that compensation properly removes blinded messages +/// (change outputs) from the database during rollback. +#[tokio::test] +async fn test_compensation_removes_change_outputs() { + use cdk_common::nuts::MeltRequest; + + use crate::test_helpers::mint::create_test_blinded_messages; + + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // Create input proofs (more than needed so we have change) + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(7_000)).await; + + // STEP 2: Create change outputs (blinded messages) + // Change = 10,000 - 7,000 - fee = ~3,000 sats + let (blinded_messages, _premint) = create_test_blinded_messages(&mint, Amount::from(3_000)) + .await + .unwrap(); + + let blinded_secrets: Vec<_> = blinded_messages + .iter() + .map(|bm| bm.blinded_secret) + .collect(); + + // STEP 3: Create melt request with change outputs + let melt_request = MeltRequest::new(quote.id.clone(), proofs.clone(), Some(blinded_messages)); + + // STEP 4: Setup melt saga (this stores blinded messages) + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + + // STEP 5: Verify blinded messages are stored in database + let stored_info = { + let mut tx = mint.localstore.begin_transaction().await.unwrap(); + let info = tx + .get_melt_request_and_blinded_messages("e.id) + .await + .expect("Should be able to query melt request") + .expect("Melt request should exist"); + tx.rollback().await.unwrap(); + info + }; + + assert_eq!( + stored_info.change_outputs.len(), + blinded_secrets.len(), + "All blinded messages should be stored" + ); + + // STEP 6: Simulate crash and trigger compensation + drop(setup_saga); + + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 7: Verify blinded messages were removed + let result = { + let mut tx = mint.localstore.begin_transaction().await.unwrap(); + let res = tx + .get_melt_request_and_blinded_messages("e.id) + .await + .expect("Query should succeed"); + tx.rollback().await.unwrap(); + res + }; + + assert!( + result.is_none(), + "Melt request and blinded messages should be deleted after compensation" + ); + + // STEP 8: Verify saga was cleaned up + assert_saga_not_exists(&mint, &operation_id).await; + + // SUCCESS: Compensation properly removed change outputs! +} + +/// Test: Compensation resets quote state +/// +/// This test validates that compensation properly resets the quote state +/// from PENDING back to UNPAID, allowing the quote to be used again. +#[tokio::test] +async fn test_compensation_resets_quote_state() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + + // Verify initial quote state is UNPAID + assert_eq!( + quote.state, + MeltQuoteState::Unpaid, + "Quote should start as Unpaid" + ); + + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup melt saga (this changes quote state to PENDING) + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + + // STEP 3: Verify quote state became PENDING + let pending_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .expect("Quote should exist"); + + assert_eq!( + pending_quote.state, + MeltQuoteState::Pending, + "Quote state should be Pending after setup" + ); + + // STEP 4: Simulate crash and trigger compensation + drop(setup_saga); + + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 5: Verify quote state was reset to UNPAID + let recovered_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .expect("Quote should still exist after compensation"); + + assert_eq!( + recovered_quote.state, + MeltQuoteState::Unpaid, + "Quote state should be reset to Unpaid after compensation" + ); + + // STEP 6: Verify saga was cleaned up + assert_saga_not_exists(&mint, &operation_id).await; + + // STEP 7: Verify quote can be used again with new melt request + let new_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let new_request = create_test_melt_request(&new_proofs, &recovered_quote); + + let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap(); + let new_saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let _new_setup = new_saga + .setup_melt(&new_request, new_verification) + .await + .expect("Should be able to reuse quote after compensation"); + + // SUCCESS: Quote state properly reset and can be reused! +} + +/// Test: Compensation is idempotent +/// +/// This test validates that running compensation multiple times is safe +/// and produces consistent results. This is important because recovery +/// might be called multiple times during debugging or startup. +#[tokio::test] +async fn test_compensation_idempotent() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup melt saga + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + + // Verify initial state + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + assert_saga_exists(&mint, &operation_id).await; + + // STEP 3: Simulate crash + drop(setup_saga); + + // STEP 4: Run compensation first time + mint.recover_from_incomplete_melt_sagas() + .await + .expect("First recovery should succeed"); + + // Verify state after first compensation + assert_proofs_state(&mint, &input_ys, None).await; + assert_saga_not_exists(&mint, &operation_id).await; + + let quote_after_first = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .expect("Quote should exist"); + assert_eq!(quote_after_first.state, MeltQuoteState::Unpaid); + + // STEP 5: Run compensation second time (should be idempotent) + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Second recovery should succeed without errors"); + + // STEP 6: Verify state is unchanged after second compensation + assert_proofs_state(&mint, &input_ys, None).await; + assert_saga_not_exists(&mint, &operation_id).await; + + let quote_after_second = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .expect("Quote should still exist"); + assert_eq!(quote_after_second.state, MeltQuoteState::Unpaid); + + // STEP 7: Verify both results are identical + assert_eq!( + quote_after_first.state, quote_after_second.state, + "Quote state should be identical after multiple compensations" + ); + + // STEP 8: Run third time to be extra sure + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Third recovery should also succeed"); + + // SUCCESS: Compensation is idempotent and safe to run multiple times! +} + +// ============================================================================ +// Saga Content Validation Tests +// ============================================================================ + +/// Test: Persisted saga contains correct data +/// +/// This test validates that all saga fields are persisted correctly, +/// providing comprehensive validation beyond the basic persistence test. +#[tokio::test] +async fn test_saga_content_validation() { + // STEP 1: Setup test environment with known data + let mint = create_test_mint().await.unwrap(); + + // Create proofs with specific amount + let proof_amount = Amount::from(10_000); + let proofs = mint_test_proofs(&mint, proof_amount).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + + // Create quote with specific amount + let quote_amount = Amount::from(9_000); + let quote = create_test_melt_quote(&mint, quote_amount).await; + + // Create melt request + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup melt saga + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + + // STEP 3: Retrieve saga from database + let persisted_saga = assert_saga_exists(&mint, &operation_id).await; + + // STEP 4: Verify operation_id matches exactly + assert_eq!( + persisted_saga.operation_id, operation_id, + "Operation ID should match exactly" + ); + + // STEP 5: Verify operation_kind is Melt + assert_eq!( + persisted_saga.operation_kind, + OperationKind::Melt, + "Operation kind must be Melt" + ); + + // STEP 6: Verify state is SetupComplete + match &persisted_saga.state { + cdk_common::mint::SagaStateEnum::Melt(state) => { + assert_eq!( + *state, + MeltSagaState::SetupComplete, + "State should be SetupComplete after setup" + ); + } + _ => panic!("Expected Melt saga state, got {:?}", persisted_saga.state), + } + + // STEP 7: Verify input_ys are stored correctly + assert_eq!( + persisted_saga.input_ys.len(), + input_ys.len(), + "Should store all input Ys" + ); + + // Verify each Y is present and in correct order + for (i, expected_y) in input_ys.iter().enumerate() { + assert!( + persisted_saga.input_ys.contains(expected_y), + "Input Y at index {} should be stored: {:?}", + i, + expected_y + ); + } + + // STEP 8: Verify timestamps are set and reasonable + let current_timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + assert!( + persisted_saga.created_at > 0, + "Created timestamp should be set" + ); + assert!( + persisted_saga.updated_at > 0, + "Updated timestamp should be set" + ); + + // Timestamps should be recent (within last hour) + assert!( + persisted_saga.created_at <= current_timestamp, + "Created timestamp should not be in the future" + ); + assert!( + persisted_saga.created_at > current_timestamp - 3600, + "Created timestamp should be recent (within last hour)" + ); + + // For new saga, created_at and updated_at should match + assert_eq!( + persisted_saga.created_at, persisted_saga.updated_at, + "Timestamps should match for newly created saga" + ); + + // STEP 9: Verify blinded_secrets is empty (not used for melt) + assert!( + persisted_saga.blinded_secrets.is_empty(), + "Melt saga should not use blinded_secrets field" + ); + + // SUCCESS: All saga content validated! +} + +/// Test: Saga timestamps remain consistent across retrievals +/// +/// Note: The melt saga doesn't have intermediate state updates that persist +/// to the database. It's created in SetupComplete state and then deleted on +/// finalize. This test validates that timestamps remain consistent when +/// retrieving the saga multiple times from the database. +#[tokio::test] +async fn test_saga_state_updates_timestamp() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup melt saga and note timestamps + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + + // STEP 3: Retrieve saga and note timestamps + let saga1 = assert_saga_exists(&mint, &operation_id).await; + let created_at_1 = saga1.created_at; + let updated_at_1 = saga1.updated_at; + + // STEP 4: Wait a brief moment + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + + // STEP 5: Retrieve saga again + let saga2 = assert_saga_exists(&mint, &operation_id).await; + let created_at_2 = saga2.created_at; + let updated_at_2 = saga2.updated_at; + + // STEP 6: Verify timestamps remain unchanged across retrievals + assert_eq!( + created_at_1, created_at_2, + "Created timestamp should not change across retrievals" + ); + assert_eq!( + updated_at_1, updated_at_2, + "Updated timestamp should not change across retrievals" + ); + + // STEP 7: Verify timestamps are identical for new saga + assert_eq!( + created_at_1, updated_at_1, + "New saga should have matching created_at and updated_at" + ); + + // SUCCESS: Timestamps are consistent! +} + +// ============================================================================ +// Query Tests +// ============================================================================ + +/// Test: get_incomplete_sagas returns only melt sagas +/// +/// This test validates that the database query correctly filters sagas +/// by operation kind, only returning melt sagas when requested. +#[tokio::test] +async fn test_get_incomplete_sagas_filters_by_kind() { + use crate::mint::swap::swap_saga::SwapSaga; + use crate::test_helpers::mint::create_test_blinded_messages; + + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create a melt saga + let melt_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&melt_proofs, &melt_quote); + + let melt_verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let melt_saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let melt_setup = melt_saga + .setup_melt(&melt_request, melt_verification) + .await + .unwrap(); + + let melt_operation_id = *melt_setup.operation.id(); + + // STEP 3: Create a swap saga + let swap_proofs = mint_test_proofs(&mint, Amount::from(5_000)).await.unwrap(); + let swap_verification = crate::mint::Verification { + amount: Amount::from(5_000), + unit: Some(cdk_common::nuts::CurrencyUnit::Sat), + }; + + let (swap_outputs, _) = create_test_blinded_messages(&mint, Amount::from(5_000)) + .await + .unwrap(); + + let swap_saga = SwapSaga::new(&mint, mint.localstore(), mint.pubsub_manager()); + let _swap_setup = swap_saga + .setup_swap(&swap_proofs, &swap_outputs, None, swap_verification) + .await + .unwrap(); + + // STEP 4: Query for incomplete melt sagas + let melt_sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + + // STEP 5: Verify only melt saga is returned + assert_eq!(melt_sagas.len(), 1, "Should return exactly one melt saga"); + + assert_eq!( + melt_sagas[0].operation_id, melt_operation_id, + "Returned saga should be the melt saga" + ); + + assert_eq!( + melt_sagas[0].operation_kind, + OperationKind::Melt, + "Returned saga should have Melt kind" + ); + + // STEP 6: Query for incomplete swap sagas + let swap_sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Swap) + .await + .unwrap(); + + // STEP 7: Verify only swap saga is returned + assert_eq!(swap_sagas.len(), 1, "Should return exactly one swap saga"); + + assert_eq!( + swap_sagas[0].operation_kind, + OperationKind::Swap, + "Returned saga should have Swap kind" + ); + + // SUCCESS: Query correctly filters by operation kind! +} + +/// Test: get_incomplete_sagas returns empty when none exist +#[tokio::test] +async fn test_get_incomplete_sagas_empty() { + let mint = create_test_mint().await.unwrap(); + + let sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + + assert!(sagas.is_empty(), "Should have no incomplete melt sagas"); +} + +// ============================================================================ +// Concurrent Operation Tests +// ============================================================================ + +/// Test: Multiple concurrent melt operations don't interfere +#[tokio::test] +async fn test_concurrent_melt_operations() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create 5 sets of proofs and quotes concurrently + // Using same amount for each to avoid FakeWallet limit issues + let mut tasks = Vec::new(); + + for _ in 0..5 { + let mint_clone = mint.clone(); + let task = tokio::spawn(async move { + let proofs = mint_test_proofs(&mint_clone, Amount::from(10_000)) + .await + .unwrap(); + let quote = create_test_melt_quote(&mint_clone, Amount::from(9_000)).await; + (proofs, quote) + }); + tasks.push(task); + } + + let proof_quote_pairs: Vec<_> = futures::future::join_all(tasks) + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + // STEP 3: Setup all melt sagas concurrently + let mut setup_tasks = Vec::new(); + + for (proofs, quote) in proof_quote_pairs { + let mint_clone = mint.clone(); + let task = tokio::spawn(async move { + let melt_request = create_test_melt_request(&proofs, "e); + let verification = mint_clone + .verify_inputs(melt_request.inputs()) + .await + .unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint_clone.clone()), + mint_clone.localstore(), + mint_clone.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + let operation_id = *setup_saga.operation.id(); + // Drop setup_saga before returning to avoid lifetime issues + drop(setup_saga); + operation_id + }); + setup_tasks.push(task); + } + + let operation_ids: Vec<_> = futures::future::join_all(setup_tasks) + .await + .into_iter() + .map(|r| r.unwrap()) + .collect(); + + // STEP 4: Verify all operation_ids are unique + let unique_ids: std::collections::HashSet<_> = operation_ids.iter().collect(); + assert_eq!( + unique_ids.len(), + operation_ids.len(), + "All operation IDs should be unique" + ); + + // STEP 5: Verify all sagas exist in database + let sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + assert!(sagas.len() >= 5, "Should have at least 5 incomplete sagas"); + + for operation_id in &operation_ids { + assert!( + sagas.iter().any(|s| s.operation_id == *operation_id), + "Saga {} should exist in database", + operation_id + ); + } + + // SUCCESS: Concurrent operations work without interference! +} + +/// Test: Concurrent recovery and new operations work together +#[tokio::test] +async fn test_concurrent_recovery_and_operations() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create incomplete saga + let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request1 = create_test_melt_request(&proofs1, "e1); + + let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap(); + let saga1 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga1 = saga1 + .setup_melt(&melt_request1, verification1) + .await + .unwrap(); + let incomplete_operation_id = *setup_saga1.operation.id(); + + // Drop saga to simulate crash + drop(setup_saga1); + + // Verify saga exists + assert_saga_exists(&mint, &incomplete_operation_id).await; + + // STEP 3: Create tasks for concurrent recovery and new operation + let mint_for_recovery = mint.clone(); + let recovery_task = tokio::spawn(async move { + mint_for_recovery + .recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed") + }); + + let mint_for_new_op = mint.clone(); + let new_operation_task = tokio::spawn(async move { + let proofs2 = mint_test_proofs(&mint_for_new_op, Amount::from(10_000)) + .await + .unwrap(); + let quote2 = create_test_melt_quote(&mint_for_new_op, Amount::from(9_000)).await; + let melt_request2 = create_test_melt_request(&proofs2, "e2); + + let verification2 = mint_for_new_op + .verify_inputs(melt_request2.inputs()) + .await + .unwrap(); + let saga2 = MeltSaga::new( + std::sync::Arc::new(mint_for_new_op.clone()), + mint_for_new_op.localstore(), + mint_for_new_op.pubsub_manager(), + ); + let setup_saga2 = saga2 + .setup_melt(&melt_request2, verification2) + .await + .unwrap(); + *setup_saga2.operation.id() + }); + + // STEP 4: Wait for both tasks to complete + let (recovery_result, new_op_result) = tokio::join!(recovery_task, new_operation_task); + + recovery_result.expect("Recovery task should complete"); + let new_operation_id = new_op_result.expect("New operation task should complete"); + + // STEP 5: Verify recovery completed + assert_saga_not_exists(&mint, &incomplete_operation_id).await; + + // STEP 6: Verify new operation succeeded + assert_saga_exists(&mint, &new_operation_id).await; + + // SUCCESS: Concurrent recovery and operations work together! +} + +// ============================================================================ +// Failure Scenario Tests +// ============================================================================ + +/// Test: Double-spend detection during setup +#[tokio::test] +async fn test_double_spend_detection() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + + // STEP 2: Setup first melt saga with proofs + let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request1 = create_test_melt_request(&proofs, "e1); + + let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap(); + let saga1 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let _setup_saga1 = saga1 + .setup_melt(&melt_request1, verification1) + .await + .unwrap(); + + // Proofs should now be in PENDING state + let input_ys = proofs.ys().unwrap(); + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // STEP 3: Try to setup second saga with same proofs + let quote2 = create_test_melt_quote(&mint, Amount::from(8_000)).await; + let melt_request2 = create_test_melt_request(&proofs, "e2); + + // STEP 4: verify_inputs succeeds (only checks signatures) + // but setup_melt should fail (checks proof states) + let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap(); + let saga2 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await; + + // STEP 5: Verify second setup fails with appropriate error + assert!( + setup_result2.is_err(), + "Second melt with same proofs should fail during setup" + ); + + if let Err(error) = setup_result2 { + let error_msg = error.to_string().to_lowercase(); + assert!( + error_msg.contains("pending") + || error_msg.contains("spent") + || error_msg.contains("state"), + "Error should mention proof state issue, got: {}", + error + ); + } + + // STEP 6: Verify first saga is unaffected - proofs still pending + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // SUCCESS: Double-spend prevented! +} + +/// Test: Transaction balance validation +/// +/// Note: This test verifies that the mint properly validates transaction balance. +/// In the current implementation, balance checking happens during melt request +/// validation before saga setup. +#[tokio::test] +async fn test_insufficient_funds() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create proofs + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + + // STEP 3: Create quote + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + + // STEP 4: Setup a normal melt (this should succeed with sufficient funds) + let melt_request = create_test_melt_request(&proofs, "e); + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_result = saga.setup_melt(&melt_request, verification).await; + + // With 10000 msats input and 9000 msats quote, this should succeed + assert!( + setup_result.is_ok(), + "Setup should succeed with sufficient funds" + ); + + // Clean up + drop(setup_result); + + // Verify proofs are now marked pending (setup succeeded) + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // SUCCESS: Balance validation works correctly! + // Note: Testing actual insufficient funds would require creating a quote + // that costs more than the proofs, but that's prevented at quote creation time +} + +/// Test: Invalid quote ID rejection +#[tokio::test] +async fn test_invalid_quote_id() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + + // STEP 2: Create a melt request with non-existent quote ID + use cdk_common::nuts::MeltRequest; + use cdk_common::QuoteId; + + let fake_quote_id = QuoteId::new_uuid(); + let melt_request = MeltRequest::new(fake_quote_id.clone(), proofs.clone(), None); + + // STEP 3: Try to setup melt saga (should fail due to invalid quote) + let verification_result = mint.verify_inputs(melt_request.inputs()).await; + + // Verification might succeed (just checks signatures) or fail (if database issues) + if let Ok(verification) = verification_result { + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_result = saga.setup_melt(&melt_request, verification).await; + + // STEP 4: Verify setup fails with unknown quote error + assert!( + setup_result.is_err(), + "Setup should fail with invalid quote ID" + ); + + if let Err(error) = setup_result { + let error_msg = error.to_string().to_lowercase(); + assert!( + error_msg.contains("quote") + || error_msg.contains("unknown") + || error_msg.contains("not found"), + "Error should mention quote issue, got: {}", + error + ); + } + + // Note: We don't query database state after a failed setup because + // the database may be in a transaction rollback state which can cause timeouts + } else { + // If verification fails due to database issues, that's also acceptable + // for this test (we're mainly testing quote validation) + eprintln!("Note: Verification failed (expected in some environments)"); + } + + // SUCCESS: Invalid quote ID handling works correctly! +} + +/// Test: Quote already paid rejection +#[tokio::test] +async fn test_quote_already_paid() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create and complete a full melt operation + let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request1 = create_test_melt_request(&proofs1, "e); + + // Complete the full melt flow + let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap(); + let saga1 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga1 = saga1 + .setup_melt(&melt_request1, verification1) + .await + .unwrap(); + + let (payment_saga, decision) = setup_saga1 + .attempt_internal_settlement(&melt_request1) + .await + .unwrap(); + + let confirmed_saga = payment_saga.make_payment(decision).await.unwrap(); + let _response = confirmed_saga.finalize().await.unwrap(); + + // Verify quote is now paid + let paid_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + paid_quote.state, + MeltQuoteState::Paid, + "Quote should be paid" + ); + + // STEP 3: Try to setup new melt saga with the already-paid quote + let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_request2 = create_test_melt_request(&proofs2, &paid_quote); + + let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap(); + let saga2 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await; + + // STEP 4: Verify setup fails + assert!( + setup_result2.is_err(), + "Setup should fail with already paid quote" + ); + + if let Err(error) = setup_result2 { + let error_msg = error.to_string().to_lowercase(); + assert!( + error_msg.contains("paid") + || error_msg.contains("quote") + || error_msg.contains("state"), + "Error should mention paid quote, got: {}", + error + ); + } + + // SUCCESS: Already paid quote rejected! +} + +/// Test: Quote already pending rejection +#[tokio::test] +async fn test_quote_already_pending() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Setup first melt saga (this puts quote in PENDING state) + let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request1 = create_test_melt_request(&proofs1, "e); + + let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap(); + let saga1 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let _setup_saga1 = saga1 + .setup_melt(&melt_request1, verification1) + .await + .unwrap(); + + // Verify quote is now pending + let pending_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + pending_quote.state, + MeltQuoteState::Pending, + "Quote should be pending" + ); + + // STEP 3: Try to setup second saga with same quote (different proofs) + let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_request2 = create_test_melt_request(&proofs2, &pending_quote); + + let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap(); + let saga2 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await; + + // STEP 4: Verify second setup fails + assert!( + setup_result2.is_err(), + "Setup should fail with pending quote" + ); + + if let Err(error) = setup_result2 { + let error_msg = error.to_string().to_lowercase(); + assert!( + error_msg.contains("pending") + || error_msg.contains("quote") + || error_msg.contains("state"), + "Error should mention pending quote, got: {}", + error + ); + } + + // SUCCESS: Concurrent quote use prevented! +} + +// ============================================================================ +// Edge Cases +// ============================================================================ + +/// Test: Empty input proofs +#[tokio::test] +async fn test_empty_inputs() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create a melt request with empty proofs + use cdk_common::nuts::{MeltRequest, Proofs}; + + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let empty_proofs = Proofs::new(); + + let melt_request = MeltRequest::new(quote.id.clone(), empty_proofs, None); + + // STEP 3: Try to verify inputs (should fail with empty proofs) + let verification_result = mint.verify_inputs(melt_request.inputs()).await; + + // Verification should fail with empty inputs + assert!( + verification_result.is_err(), + "Verification should fail with empty proofs" + ); + + let error = verification_result.unwrap_err(); + let error_msg = error.to_string().to_lowercase(); + assert!( + error_msg.contains("empty") || error_msg.contains("no") || error_msg.contains("input"), + "Error should mention empty inputs, got: {}", + error + ); + + // STEP 4: Verify no saga persisted + let sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + assert!(sagas.is_empty(), "No saga should be persisted"); + + // SUCCESS: Empty inputs rejected! +} + +/// Test: Recovery with empty input_ys in saga +#[tokio::test] +async fn test_recovery_empty_input_ys() { + // TODO: Implement this test + // 1. Manually create saga with empty input_ys + // 2. Run recovery + // 3. Verify saga is skipped gracefully + // 4. Verify logged warning +} + +/// Test: Saga with no change outputs (simple melt scenario) +/// +/// This test verifies that recovery works correctly when there are no +/// change outputs to clean up (e.g., when input amount exactly matches quote amount) +#[tokio::test] +async fn test_recovery_no_melt_request() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // Create proofs that exactly match the quote amount (no change needed) + let amount = Amount::from(10_000); + let proofs = mint_test_proofs(&mint, amount).await.unwrap(); + let quote = create_test_melt_quote(&mint, amount).await; + + // Create melt request without change outputs + let melt_request = create_test_melt_request(&proofs, "e); + assert!( + melt_request.outputs().is_none(), + "Should have no change outputs" + ); + + // STEP 2: Create incomplete saga + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + let input_ys = proofs.ys().unwrap(); + + // Drop saga (simulate crash) + drop(setup_saga); + + // Verify saga exists + assert_saga_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // STEP 3: Run recovery + // Should handle gracefully even with no change outputs to clean up + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed without change outputs"); + + // STEP 4: Verify recovery completed successfully + assert_saga_not_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, None).await; + + // SUCCESS: Recovery works even without change outputs! +} + +// ============================================================================ +// Integration with check_pending_melt_quotes +// ============================================================================ + +/// Test: Saga recovery runs before quote checking on startup +/// +/// This test verifies that saga recovery executes before quote checking, +/// preventing conflicts where both mechanisms might try to handle the same state. +#[tokio::test] +async fn test_recovery_order_on_startup() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create incomplete saga with a pending quote + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + let input_ys = proofs.ys().unwrap(); + + // Drop saga (simulate crash) - this leaves quote in PENDING state + drop(setup_saga); + + // Verify initial state: saga exists, quote is pending, proofs are pending + assert_saga_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + let pending_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + pending_quote.state, + MeltQuoteState::Pending, + "Quote should be pending" + ); + + // STEP 3: Manually trigger recovery (simulating startup) + // Note: In production, mint.start() calls this automatically + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 4: Verify saga recovery completed correctly + // - Saga should be deleted + // - Proofs should be removed (returned to client) + // - Quote should be reset to UNPAID + assert_saga_not_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, None).await; + + let recovered_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + recovered_quote.state, + MeltQuoteState::Unpaid, + "Quote should be reset to unpaid" + ); + + // STEP 5: Verify no conflicts - system is in consistent state + // Quote can be used again with new proofs + let new_proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let new_request = create_test_melt_request(&new_proofs, &recovered_quote); + + let new_verification = mint.verify_inputs(new_request.inputs()).await.unwrap(); + let new_saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let _new_setup = new_saga + .setup_melt(&new_request, new_verification) + .await + .unwrap(); + + // SUCCESS: Recovery order is correct, no conflicts! +} + +/// Test: Saga recovery and quote checking don't duplicate work +/// +/// This test verifies that compensation is idempotent - running recovery +/// multiple times doesn't cause errors or duplicate work. +#[tokio::test] +async fn test_no_duplicate_recovery() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create incomplete saga with pending quote + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + let input_ys = proofs.ys().unwrap(); + + // Drop saga (simulate crash) + drop(setup_saga); + + // Verify saga exists and proofs are pending + assert_saga_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // STEP 3: Run recovery first time + mint.recover_from_incomplete_melt_sagas() + .await + .expect("First recovery should succeed"); + + // Verify saga deleted and proofs removed + assert_saga_not_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, None).await; + + let recovered_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!(recovered_quote.state, MeltQuoteState::Unpaid); + + // STEP 4: Run recovery again (simulating duplicate execution) + // Should be idempotent - no errors even though saga is already cleaned up + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Second recovery should succeed (idempotent)"); + + // STEP 5: Verify state unchanged - still consistent + assert_saga_not_exists(&mint, &operation_id).await; + assert_proofs_state(&mint, &input_ys, None).await; + + let still_recovered_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .unwrap(); + assert_eq!(still_recovered_quote.state, MeltQuoteState::Unpaid); + + // SUCCESS: Recovery is idempotent, no duplicate work or errors! +} + +// ============================================================================ +// Production Readiness Tests +// ============================================================================ + +/// Test: Operation ID uniqueness across multiple sagas +#[tokio::test] +async fn test_operation_id_uniqueness_and_tracking() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create 10 sagas and collect their operation IDs + // Using same amount for each to avoid FakeWallet limit issues + let mut operation_ids = Vec::new(); + + for _ in 0..10 { + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + + let operation_id = *setup_saga.operation.id(); + operation_ids.push(operation_id); + + // Keep saga alive + drop(setup_saga); + } + + // STEP 3: Verify all operation IDs are unique + let unique_ids: std::collections::HashSet<_> = operation_ids.iter().collect(); + assert_eq!( + unique_ids.len(), + operation_ids.len(), + "All {} operation IDs should be unique", + operation_ids.len() + ); + + // STEP 4: Verify all sagas are trackable in database + let sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + + for operation_id in &operation_ids { + assert!( + sagas.iter().any(|s| s.operation_id == *operation_id), + "Saga {} should be trackable in database", + operation_id + ); + } + + // SUCCESS: All operation IDs are unique and trackable! +} + +/// Test: Saga drop without finalize doesn't panic +#[tokio::test] +async fn test_saga_drop_without_finalize() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup saga + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + let operation_id = *setup_saga.operation.id(); + + // STEP 3: Drop saga without finalizing (simulates crash) + drop(setup_saga); + + // STEP 4: Verify no panic occurred and saga remains in database + let saga_in_db = assert_saga_exists(&mint, &operation_id).await; + assert_eq!(saga_in_db.operation_id, operation_id); + + // SUCCESS: Drop without finalize doesn't panic! +} + +/// Test: Saga drop after payment is recoverable and finalizes correctly +/// +/// This test verifies that when a saga is dropped after payment but before finalize, +/// the recovery process correctly finalizes the melt (marks proofs as spent) rather +/// than compensating (returning proofs to user). This is critical for preventing +/// fund loss where the mint pays the LN invoice but returns the proofs. +#[tokio::test] +async fn test_saga_drop_after_payment() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup saga and make payment + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + let operation_id = *setup_saga.operation.id(); + + // Verify proofs are PENDING after setup + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // Attempt internal settlement + let (payment_saga, decision) = setup_saga + .attempt_internal_settlement(&melt_request) + .await + .unwrap(); + + // Make payment + let confirmed_saga = payment_saga.make_payment(decision).await.unwrap(); + + // STEP 3: Verify saga state is now PaymentAttempted (not SetupComplete) + let saga_in_db = assert_saga_exists(&mint, &operation_id).await; + match &saga_in_db.state { + cdk_common::mint::SagaStateEnum::Melt(state) => { + assert_eq!( + *state, + MeltSagaState::PaymentAttempted, + "Saga state should be PaymentAttempted after make_payment" + ); + } + _ => panic!("Expected Melt saga state"), + } + + // STEP 4: Drop before finalize (simulates crash after payment) + drop(confirmed_saga); + + // STEP 5: Run recovery to complete the operation + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 6: Verify saga was recovered and cleaned up + assert_saga_not_exists(&mint, &operation_id).await; + + // STEP 7: Verify proofs were marked SPENT (not returned to user) + // This is the critical check - if compensation ran instead of finalize, + // proofs would be None (returned) instead of Spent + assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await; + + // STEP 8: Verify quote is marked as PAID + let final_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .expect("Quote should exist"); + assert_eq!( + final_quote.state, + MeltQuoteState::Paid, + "Quote should be marked as Paid after recovery finalization" + ); + + // SUCCESS: Drop after payment correctly finalizes (doesn't compensate)! +} + +/// Test: PaymentAttempted state triggers LN backend check during recovery +/// +/// This test verifies that when recovery finds a saga in PaymentAttempted state, +/// it checks the LN backend to determine whether to finalize or compensate, +/// rather than blindly compensating like SetupComplete state. +#[tokio::test] +async fn test_payment_attempted_state_triggers_ln_check() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup saga and make payment + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + let operation_id = *setup_saga.operation.id(); + + // Check initial state is SetupComplete + let saga_before_payment = assert_saga_exists(&mint, &operation_id).await; + match &saga_before_payment.state { + cdk_common::mint::SagaStateEnum::Melt(state) => { + assert_eq!( + *state, + MeltSagaState::SetupComplete, + "Initial state should be SetupComplete" + ); + } + _ => panic!("Expected Melt saga state"), + } + + // Attempt internal settlement and make payment + let (payment_saga, decision) = setup_saga + .attempt_internal_settlement(&melt_request) + .await + .unwrap(); + let confirmed_saga = payment_saga.make_payment(decision).await.unwrap(); + + // STEP 3: Verify state transitioned to PaymentAttempted + let saga_after_payment = assert_saga_exists(&mint, &operation_id).await; + match &saga_after_payment.state { + cdk_common::mint::SagaStateEnum::Melt(state) => { + assert_eq!( + *state, + MeltSagaState::PaymentAttempted, + "State should be PaymentAttempted after make_payment" + ); + } + _ => panic!("Expected Melt saga state"), + } + + // STEP 4: Drop saga (simulate crash after payment but before finalize) + drop(confirmed_saga); + + // STEP 5: Run recovery - should check LN backend and finalize + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 6: Verify correct outcome - finalized, not compensated + assert_saga_not_exists(&mint, &operation_id).await; + + // Proofs should be SPENT (finalized), not None (compensated) + assert_proofs_state(&mint, &input_ys, Some(State::Spent)).await; + + // Quote should be PAID + let final_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .expect("Quote should exist"); + assert_eq!( + final_quote.state, + MeltQuoteState::Paid, + "Quote should be Paid - LN backend check should have triggered finalization" + ); + + // SUCCESS: PaymentAttempted state correctly triggers LN check and finalizes! +} + +/// Test: SetupComplete state compensates without LN check +/// +/// This test verifies that when recovery finds a saga in SetupComplete state, +/// it compensates (returns proofs) without checking LN backend, because +/// payment was never sent. +#[tokio::test] +async fn test_setup_complete_state_compensates() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + let proofs = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let input_ys = proofs.ys().unwrap(); + let quote = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let melt_request = create_test_melt_request(&proofs, "e); + + // STEP 2: Setup saga but don't make payment + let verification = mint.verify_inputs(melt_request.inputs()).await.unwrap(); + let saga = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga = saga.setup_melt(&melt_request, verification).await.unwrap(); + let operation_id = *setup_saga.operation.id(); + + // Verify state is SetupComplete + let saga_in_db = assert_saga_exists(&mint, &operation_id).await; + match &saga_in_db.state { + cdk_common::mint::SagaStateEnum::Melt(state) => { + assert_eq!( + *state, + MeltSagaState::SetupComplete, + "State should be SetupComplete" + ); + } + _ => panic!("Expected Melt saga state"), + } + + // Verify proofs are PENDING + assert_proofs_state(&mint, &input_ys, Some(State::Pending)).await; + + // STEP 3: Drop saga (simulate crash before payment) + drop(setup_saga); + + // STEP 4: Run recovery - should compensate without LN check + mint.recover_from_incomplete_melt_sagas() + .await + .expect("Recovery should succeed"); + + // STEP 5: Verify correct outcome - compensated, not finalized + assert_saga_not_exists(&mint, &operation_id).await; + + // Proofs should be None (compensated/returned), not Spent + assert_proofs_state(&mint, &input_ys, None).await; + + // Quote should be UNPAID (reset) + let final_quote = mint + .localstore + .get_melt_quote("e.id) + .await + .unwrap() + .expect("Quote should exist"); + assert_eq!( + final_quote.state, + MeltQuoteState::Unpaid, + "Quote should be Unpaid - compensation should have reset it" + ); + + // SUCCESS: SetupComplete state correctly compensates! +} + +// ============================================================================ +// Test Helpers +// ============================================================================ + +/// Helper: Create a test melt quote +/// +/// # Arguments +/// * `mint` - Test mint instance +/// * `amount` - Amount in sats for the quote +/// +/// # Returns +/// A valid unpaid melt quote +/// +/// # How it works +/// Uses `create_fake_invoice()` from cdk-fake-wallet to generate a valid +/// bolt11 invoice that FakeWallet will process. The FakeInvoiceDescription +/// controls payment behavior (success/failure). +async fn create_test_melt_quote( + mint: &crate::mint::Mint, + amount: Amount, +) -> cdk_common::mint::MeltQuote { + use cdk_common::melt::MeltQuoteRequest; + use cdk_common::nuts::MeltQuoteBolt11Request; + use cdk_common::CurrencyUnit; + use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; + + // Create fake invoice description (controls payment behavior) + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Paid, // Payment will succeed + check_payment_state: MeltQuoteState::Paid, // Check will show paid + pay_err: false, // No payment error + check_err: false, // No check error + }; + + // Create valid bolt11 invoice (amount in millisats) + // Amount is already in millisats, just convert to u64 + let amount_msats: u64 = amount.into(); + let invoice = create_fake_invoice( + amount_msats, + serde_json::to_string(&fake_description).unwrap(), + ); + + // Create melt quote request + let bolt11_request = MeltQuoteBolt11Request { + request: invoice, + unit: CurrencyUnit::Sat, + options: None, + }; + + let request = MeltQuoteRequest::Bolt11(bolt11_request); + + // Get quote from mint + let quote_response = mint.get_melt_quote(request).await.unwrap(); + + // Retrieve the full quote from database + let quote = mint + .localstore + .get_melt_quote("e_response.quote) + .await + .unwrap() + .expect("Quote should exist in database"); + + quote +} + +/// Helper: Create a test melt request +/// +/// # Arguments +/// * `proofs` - Input proofs for the melt +/// * `quote` - Melt quote to use +/// +/// # Returns +/// A MeltRequest ready to be used with setup_melt() +fn create_test_melt_request( + proofs: &cdk_common::nuts::Proofs, + quote: &cdk_common::mint::MeltQuote, +) -> cdk_common::nuts::MeltRequest { + use cdk_common::nuts::MeltRequest; + + MeltRequest::new( + quote.id.clone(), + proofs.clone(), + None, // No change outputs for simplicity in tests + ) +} + +/// Helper: Verify saga exists in database +async fn assert_saga_exists(mint: &crate::mint::Mint, operation_id: &uuid::Uuid) -> Saga { + let sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + + sagas + .into_iter() + .find(|s| s.operation_id == *operation_id) + .expect("Saga should exist in database") +} + +/// Helper: Verify saga does not exist in database +async fn assert_saga_not_exists(mint: &crate::mint::Mint, operation_id: &uuid::Uuid) { + let sagas = mint + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await + .unwrap(); + + assert!( + !sagas.iter().any(|s| s.operation_id == *operation_id), + "Saga should not exist in database" + ); +} + +/// Helper: Verify proofs are in expected state +async fn assert_proofs_state( + mint: &crate::mint::Mint, + ys: &[cdk_common::PublicKey], + expected_state: Option, +) { + let states = mint.localstore.get_proofs_states(ys).await.unwrap(); + + for state in states { + assert_eq!(state, expected_state, "Proof state mismatch"); + } +} + +// ============================================================================ +// Duplicate request_lookup_id Constraint Tests +// ============================================================================ + +/// Test: Cannot set melt quote to pending if another quote with same lookup_id is already pending +/// +/// This test verifies that when two melt quotes share the same request_lookup_id, +/// only one can be in PENDING state at a time. +#[tokio::test] +async fn test_duplicate_lookup_id_prevents_second_pending() { + use cdk_common::melt::MeltQuoteRequest; + use cdk_common::nuts::MeltQuoteBolt11Request; + use cdk_common::CurrencyUnit; + use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; + + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // Create a fake invoice description + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Paid, + check_payment_state: MeltQuoteState::Paid, + pay_err: false, + check_err: false, + }; + + // Create a single invoice that will be used for both quotes + let amount_msats: u64 = 9000; + let invoice = create_fake_invoice( + amount_msats, + serde_json::to_string(&fake_description).unwrap(), + ); + + // STEP 2: Create two melt quotes for the same invoice (same request_lookup_id) + let bolt11_request1 = MeltQuoteBolt11Request { + request: invoice.clone(), + unit: CurrencyUnit::Sat, + options: None, + }; + let quote_response1 = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request1)) + .await + .unwrap(); + + let bolt11_request2 = MeltQuoteBolt11Request { + request: invoice, + unit: CurrencyUnit::Sat, + options: None, + }; + let quote_response2 = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request2)) + .await + .unwrap(); + + // Retrieve full quotes + let quote1 = mint + .localstore + .get_melt_quote("e_response1.quote) + .await + .unwrap() + .expect("Quote 1 should exist"); + let quote2 = mint + .localstore + .get_melt_quote("e_response2.quote) + .await + .unwrap() + .expect("Quote 2 should exist"); + + // Verify both quotes have the same lookup_id + assert_eq!( + quote1.request_lookup_id, quote2.request_lookup_id, + "Both quotes should have the same request_lookup_id" + ); + + // STEP 3: Setup first melt saga (puts quote1 in PENDING state) + let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_request1 = create_test_melt_request(&proofs1, "e1); + + let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap(); + let saga1 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga1 = saga1 + .setup_melt(&melt_request1, verification1) + .await + .unwrap(); + + // Continue through the payment flow to release any transaction locks + // The quote will stay in PENDING state because FakeWallet returns Paid + // but we don't call finalize() + let (payment_saga1, decision1) = setup_saga1 + .attempt_internal_settlement(&melt_request1) + .await + .unwrap(); + + // Make payment but don't finalize - keeps quote in PENDING + let confirmed_saga1 = payment_saga1.make_payment(decision1).await.unwrap(); + + // Drop the saga to release resources (simulates crash before finalize) + drop(confirmed_saga1); + + // Verify quote1 is now pending + let pending_quote1 = mint + .localstore + .get_melt_quote("e1.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + pending_quote1.state, + MeltQuoteState::Pending, + "Quote 1 should be pending" + ); + + // STEP 4: Try to setup second saga with quote2 (same lookup_id) + let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_request2 = create_test_melt_request(&proofs2, "e2); + + let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap(); + let saga2 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await; + + // STEP 5: Verify second setup fails due to duplicate pending lookup_id + assert!( + setup_result2.is_err(), + "Setup should fail when another quote with same lookup_id is already pending" + ); + + if let Err(error) = setup_result2 { + let error_msg = error.to_string().to_lowercase(); + assert!( + error_msg.contains("duplicate") || error_msg.contains("pending"), + "Error should mention duplicate or pending, got: {}", + error + ); + } + + // Verify quote2 is still unpaid + let still_unpaid_quote2 = mint + .localstore + .get_melt_quote("e2.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + still_unpaid_quote2.state, + MeltQuoteState::Unpaid, + "Quote 2 should still be unpaid" + ); + + // SUCCESS: Duplicate pending lookup_id prevented! +} + +/// Test: Cannot set melt quote to pending if another quote with same lookup_id is already paid +/// +/// This test verifies that once a melt quote with a specific request_lookup_id is paid, +/// no other quote with the same lookup_id can transition to pending. +#[tokio::test] +async fn test_paid_lookup_id_prevents_pending() { + use cdk_common::melt::MeltQuoteRequest; + use cdk_common::nuts::MeltQuoteBolt11Request; + use cdk_common::CurrencyUnit; + use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; + + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // Create a fake invoice description + let fake_description = FakeInvoiceDescription { + pay_invoice_state: MeltQuoteState::Paid, + check_payment_state: MeltQuoteState::Paid, + pay_err: false, + check_err: false, + }; + + // Create a single invoice that will be used for both quotes + let amount_msats: u64 = 9000; + let invoice = create_fake_invoice( + amount_msats, + serde_json::to_string(&fake_description).unwrap(), + ); + + // STEP 2: Create two melt quotes for the same invoice (same request_lookup_id) + let bolt11_request1 = MeltQuoteBolt11Request { + request: invoice.clone(), + unit: CurrencyUnit::Sat, + options: None, + }; + let quote_response1 = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request1)) + .await + .unwrap(); + + let bolt11_request2 = MeltQuoteBolt11Request { + request: invoice, + unit: CurrencyUnit::Sat, + options: None, + }; + let quote_response2 = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(bolt11_request2)) + .await + .unwrap(); + + // Retrieve full quotes + let quote1 = mint + .localstore + .get_melt_quote("e_response1.quote) + .await + .unwrap() + .expect("Quote 1 should exist"); + let quote2 = mint + .localstore + .get_melt_quote("e_response2.quote) + .await + .unwrap() + .expect("Quote 2 should exist"); + + // STEP 3: Complete the first melt (marks quote1 as PAID) + let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_request1 = create_test_melt_request(&proofs1, "e1); + + let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap(); + let saga1 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_saga1 = saga1 + .setup_melt(&melt_request1, verification1) + .await + .unwrap(); + + // Complete the full melt flow for quote1 + let (payment_saga, decision) = setup_saga1 + .attempt_internal_settlement(&melt_request1) + .await + .unwrap(); + let confirmed_saga = payment_saga.make_payment(decision).await.unwrap(); + let _response = confirmed_saga.finalize().await.unwrap(); + + // Verify quote1 is now paid + let paid_quote1 = mint + .localstore + .get_melt_quote("e1.id) + .await + .unwrap() + .unwrap(); + assert_eq!( + paid_quote1.state, + MeltQuoteState::Paid, + "Quote 1 should be paid" + ); + + // STEP 4: Try to setup second saga with quote2 (same lookup_id as paid quote) + let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_request2 = create_test_melt_request(&proofs2, "e2); + + let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap(); + let saga2 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let setup_result2 = saga2.setup_melt(&melt_request2, verification2).await; + + // STEP 5: Verify second setup fails due to already paid lookup_id + assert!( + setup_result2.is_err(), + "Setup should fail when another quote with same lookup_id is already paid" + ); + + if let Err(error) = setup_result2 { + let error_msg = error.to_string().to_lowercase(); + assert!( + error_msg.contains("duplicate") + || error_msg.contains("paid") + || error_msg.contains("pending"), + "Error should mention duplicate or paid, got: {}", + error + ); + } + + // SUCCESS: Paid lookup_id prevents new pending! +} + +/// Test: Different lookup_ids allow concurrent pending quotes +/// +/// This test verifies that melt quotes with different request_lookup_ids +/// can both be in PENDING state simultaneously. +#[tokio::test] +async fn test_different_lookup_ids_allow_concurrent_pending() { + // STEP 1: Setup test environment + let mint = create_test_mint().await.unwrap(); + + // STEP 2: Create two quotes with different lookup_ids (different invoices) + let quote1 = create_test_melt_quote(&mint, Amount::from(9_000)).await; + let quote2 = create_test_melt_quote(&mint, Amount::from(8_000)).await; + + // Verify quotes have different lookup_ids + assert_ne!( + quote1.request_lookup_id, quote2.request_lookup_id, + "Quotes should have different request_lookup_ids" + ); + + // STEP 3: Setup first saga (puts quote1 in PENDING state) + let proofs1 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_request1 = create_test_melt_request(&proofs1, "e1); + + let verification1 = mint.verify_inputs(melt_request1.inputs()).await.unwrap(); + let saga1 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let _setup_saga1 = saga1 + .setup_melt(&melt_request1, verification1) + .await + .unwrap(); + + // STEP 4: Setup second saga (puts quote2 in PENDING state) + let proofs2 = mint_test_proofs(&mint, Amount::from(10_000)).await.unwrap(); + let melt_request2 = create_test_melt_request(&proofs2, "e2); + + let verification2 = mint.verify_inputs(melt_request2.inputs()).await.unwrap(); + let saga2 = MeltSaga::new( + std::sync::Arc::new(mint.clone()), + mint.localstore(), + mint.pubsub_manager(), + ); + let _setup_saga2 = saga2 + .setup_melt(&melt_request2, verification2) + .await + .unwrap(); + + // STEP 5: Verify both quotes are pending + let pending_quote1 = mint + .localstore + .get_melt_quote("e1.id) + .await + .unwrap() + .unwrap(); + let pending_quote2 = mint + .localstore + .get_melt_quote("e2.id) + .await + .unwrap() + .unwrap(); + + assert_eq!( + pending_quote1.state, + MeltQuoteState::Pending, + "Quote 1 should be pending" + ); + assert_eq!( + pending_quote2.state, + MeltQuoteState::Pending, + "Quote 2 should be pending" + ); + + // SUCCESS: Different lookup_ids allow concurrent pending! +} diff --git a/crates/cdk/src/mint/melt/mod.rs b/crates/cdk/src/mint/melt/mod.rs new file mode 100644 index 000000000..9d2917150 --- /dev/null +++ b/crates/cdk/src/mint/melt/mod.rs @@ -0,0 +1,552 @@ +use std::str::FromStr; + +use cdk_common::amount::amount_for_offer; +use cdk_common::melt::MeltQuoteRequest; +use cdk_common::mint::MeltPaymentRequest; +use cdk_common::nut05::MeltMethodOptions; +use cdk_common::payment::{ + Bolt11OutgoingPaymentOptions, Bolt12OutgoingPaymentOptions, OutgoingPaymentOptions, +}; +use cdk_common::quote_id::QuoteId; +use cdk_common::{MeltOptions, MeltQuoteBolt12Request, SpendingConditionVerification}; +#[cfg(feature = "prometheus")] +use cdk_prometheus::METRICS; +use lightning::offers::offer::Offer; +use tracing::instrument; + +use super::{ + CurrencyUnit, MeltQuote, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, Mint, + PaymentMethod, +}; +use crate::amount::to_unit; +use crate::nuts::MeltQuoteState; +use crate::types::PaymentProcessorKey; +use crate::util::unix_time; +use crate::{ensure_cdk, Amount, Error}; + +pub(crate) mod melt_saga; +pub(crate) mod shared; + +#[cfg(test)] +mod tests; + +use melt_saga::MeltSaga; + +impl Mint { + #[instrument(skip_all)] + async fn check_melt_request_acceptable( + &self, + amount: Amount, + unit: CurrencyUnit, + method: PaymentMethod, + request: String, + options: Option, + ) -> Result<(), Error> { + let mint_info = self.mint_info().await?; + let nut05 = mint_info.nuts.nut05; + + ensure_cdk!(!nut05.disabled, Error::MeltingDisabled); + + let settings = nut05 + .get_settings(&unit, &method) + .ok_or(Error::UnsupportedUnit)?; + + let amount = match options { + Some(MeltOptions::Mpp { mpp: _ }) => { + let nut15 = mint_info.nuts.nut15; + // Verify there is no corresponding mint quote. + // Otherwise a wallet is trying to pay someone internally, but + // with a multi-part quote. And that's just not possible. + if (self.localstore.get_mint_quote_by_request(&request).await?).is_some() { + return Err(Error::InternalMultiPartMeltQuote); + } + // Verify MPP is enabled for unit and method + if !nut15 + .methods + .into_iter() + .any(|m| m.method == method && m.unit == unit) + { + return Err(Error::MppUnitMethodNotSupported(unit, method)); + } + // Assign `amount` + // because should have already been converted to the partial amount + amount + } + Some(MeltOptions::Amountless { amountless: _ }) => { + if method == PaymentMethod::Bolt11 + && !matches!( + settings.options, + Some(MeltMethodOptions::Bolt11 { amountless: true }) + ) + { + return Err(Error::AmountlessInvoiceNotSupported(unit, method)); + } + + amount + } + None => amount, + }; + + let is_above_max = matches!(settings.max_amount, Some(max) if amount > max); + let is_below_min = matches!(settings.min_amount, Some(min) if amount < min); + match is_above_max || is_below_min { + true => { + tracing::error!( + "Melt amount out of range: {} is not within {} and {}", + amount, + settings.min_amount.unwrap_or_default(), + settings.max_amount.unwrap_or_default(), + ); + Err(Error::AmountOutofLimitRange( + settings.min_amount.unwrap_or_default(), + settings.max_amount.unwrap_or_default(), + amount, + )) + } + false => Ok(()), + } + } + + /// Get melt quote for either BOLT11 or BOLT12 + /// + /// This function accepts a `MeltQuoteRequest` enum and delegates to the + /// appropriate handler based on the request type. + #[instrument(skip_all)] + pub async fn get_melt_quote( + &self, + melt_quote_request: MeltQuoteRequest, + ) -> Result, Error> { + match melt_quote_request { + MeltQuoteRequest::Bolt11(bolt11_request) => { + self.get_melt_bolt11_quote_impl(&bolt11_request).await + } + MeltQuoteRequest::Bolt12(bolt12_request) => { + self.get_melt_bolt12_quote_impl(&bolt12_request).await + } + } + } + + /// Implementation of get_melt_bolt11_quote + #[instrument(skip_all)] + async fn get_melt_bolt11_quote_impl( + &self, + melt_request: &MeltQuoteBolt11Request, + ) -> Result, Error> { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("get_melt_bolt11_quote"); + let MeltQuoteBolt11Request { + request, + unit, + options, + .. + } = melt_request; + + let ln = self + .payment_processors + .get(&PaymentProcessorKey::new( + unit.clone(), + PaymentMethod::Bolt11, + )) + .ok_or_else(|| { + tracing::info!("Could not get ln backend for {}, bolt11 ", unit); + + Error::UnsupportedUnit + })?; + + let bolt11 = Bolt11OutgoingPaymentOptions { + bolt11: melt_request.request.clone(), + max_fee_amount: None, + timeout_secs: None, + melt_options: melt_request.options, + }; + + let payment_quote = ln + .get_payment_quote( + &melt_request.unit, + OutgoingPaymentOptions::Bolt11(Box::new(bolt11)), + ) + .await + .map_err(|err| { + tracing::error!( + "Could not get payment quote for mint quote, {} bolt11, {}", + unit, + err + ); + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("get_melt_bolt11_quote"); + METRICS.record_mint_operation("get_melt_bolt11_quote", false); + METRICS.record_error(); + } + err + })?; + + if &payment_quote.unit != unit { + return Err(Error::UnitMismatch); + } + + // Validate using processor quote amount for currency conversion + self.check_melt_request_acceptable( + payment_quote.amount, + unit.clone(), + PaymentMethod::Bolt11, + request.to_string(), + *options, + ) + .await?; + + let melt_ttl = self.quote_ttl().await?.melt_ttl; + + let quote = MeltQuote::new( + MeltPaymentRequest::Bolt11 { + bolt11: request.clone(), + }, + unit.clone(), + payment_quote.amount, + payment_quote.fee, + unix_time() + melt_ttl, + payment_quote.request_lookup_id.clone(), + *options, + PaymentMethod::Bolt11, + ); + + tracing::debug!( + "New {} melt quote {} for {} {} with request id {:?}", + quote.payment_method, + quote.id, + payment_quote.amount, + unit, + payment_quote.request_lookup_id + ); + + let mut tx = self.localstore.begin_transaction().await?; + tx.add_melt_quote(quote.clone()).await?; + tx.commit().await?; + + Ok(quote.into()) + } + + /// Implementation of get_melt_bolt12_quote + #[instrument(skip_all)] + async fn get_melt_bolt12_quote_impl( + &self, + melt_request: &MeltQuoteBolt12Request, + ) -> Result, Error> { + let MeltQuoteBolt12Request { + request, + unit, + options, + } = melt_request; + + let offer = Offer::from_str(request).map_err(|_| Error::InvalidPaymentRequest)?; + + let amount = match options { + Some(options) => match options { + MeltOptions::Amountless { amountless } => { + to_unit(amountless.amount_msat, &CurrencyUnit::Msat, unit)? + } + _ => return Err(Error::UnsupportedUnit), + }, + None => amount_for_offer(&offer, unit).map_err(|_| Error::UnsupportedUnit)?, + }; + + let ln = self + .payment_processors + .get(&PaymentProcessorKey::new( + unit.clone(), + PaymentMethod::Bolt12, + )) + .ok_or_else(|| { + tracing::info!("Could not get ln backend for {}, bolt12 ", unit); + + Error::UnsupportedUnit + })?; + + let offer = Offer::from_str(&melt_request.request).map_err(|_| Error::Bolt12parse)?; + + let outgoing_payment_options = Bolt12OutgoingPaymentOptions { + offer: offer.clone(), + max_fee_amount: None, + timeout_secs: None, + melt_options: *options, + }; + + let payment_quote = ln + .get_payment_quote( + &melt_request.unit, + OutgoingPaymentOptions::Bolt12(Box::new(outgoing_payment_options)), + ) + .await + .map_err(|err| { + tracing::error!( + "Could not get payment quote for mint quote, {} bolt12, {}", + unit, + err + ); + + err + })?; + + if &payment_quote.unit != unit { + return Err(Error::UnitMismatch); + } + + // Validate using processor quote amount for currency conversion + self.check_melt_request_acceptable( + payment_quote.amount, + unit.clone(), + PaymentMethod::Bolt12, + request.clone(), + *options, + ) + .await?; + + let payment_request = MeltPaymentRequest::Bolt12 { + offer: Box::new(offer), + }; + + let quote = MeltQuote::new( + payment_request, + unit.clone(), + payment_quote.amount, + payment_quote.fee, + unix_time() + self.quote_ttl().await?.melt_ttl, + payment_quote.request_lookup_id.clone(), + *options, + PaymentMethod::Bolt12, + ); + + tracing::debug!( + "New {} melt quote {} for {} {} with request id {:?}", + quote.payment_method, + quote.id, + amount, + unit, + payment_quote.request_lookup_id + ); + + let mut tx = self.localstore.begin_transaction().await?; + tx.add_melt_quote(quote.clone()).await?; + tx.commit().await?; + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("get_melt_bolt11_quote"); + METRICS.record_mint_operation("get_melt_bolt11_quote", true); + } + + Ok(quote.into()) + } + + /// Check melt quote status + #[instrument(skip(self))] + pub async fn check_melt_quote( + &self, + quote_id: &QuoteId, + ) -> Result, Error> { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("check_melt_quote"); + let quote = match self.localstore.get_melt_quote(quote_id).await { + Ok(Some(quote)) => quote, + Ok(None) => { + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("check_melt_quote"); + METRICS.record_mint_operation("check_melt_quote", false); + METRICS.record_error(); + } + return Err(Error::UnknownQuote); + } + Err(err) => { + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("check_melt_quote"); + METRICS.record_mint_operation("check_melt_quote", false); + METRICS.record_error(); + } + return Err(err.into()); + } + }; + + let blind_signatures = match self + .localstore + .get_blind_signatures_for_quote(quote_id) + .await + { + Ok(signatures) => signatures, + Err(err) => { + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("check_melt_quote"); + METRICS.record_mint_operation("check_melt_quote", false); + METRICS.record_error(); + } + return Err(err.into()); + } + }; + + let change = (!blind_signatures.is_empty()).then_some(blind_signatures); + + let response = MeltQuoteBolt11Response { + quote: quote.id, + state: quote.state, + expiry: quote.expiry, + amount: quote.amount, + fee_reserve: quote.fee_reserve, + payment_preimage: quote.payment_preimage, + change, + request: Some(quote.request.to_string()), + unit: Some(quote.unit.clone()), + }; + + #[cfg(feature = "prometheus")] + { + METRICS.dec_in_flight_requests("check_melt_quote"); + METRICS.record_mint_operation("check_melt_quote", true); + } + + Ok(response) + } + + /// Get melt quotes + #[instrument(skip_all)] + pub async fn melt_quotes(&self) -> Result, Error> { + let quotes = self.localstore.get_melt_quotes().await?; + Ok(quotes) + } + + /// Melt + /// + /// Uses MeltSaga typestate pattern for atomic transaction handling with automatic rollback on failure. + #[instrument(skip_all)] + pub async fn melt( + &self, + melt_request: &MeltRequest, + ) -> Result, Error> { + // Verify spending conditions (NUT-10/NUT-11/NUT-14), i.e. P2PK + // and HTLC (including SIGALL) + melt_request.verify_spending_conditions()?; + + // We don't need to check P2PK or HTLC again. It has all been checked above + // and the code doesn't reach here unless such verifications were satisfactory + + let verification = self.verify_inputs(melt_request.inputs()).await?; + + let init_saga = MeltSaga::new( + std::sync::Arc::new(self.clone()), + self.localstore.clone(), + std::sync::Arc::clone(&self.pubsub_manager), + ); + + // Step 1: Setup (TX1 - reserves inputs and outputs) + let setup_saga = init_saga.setup_melt(melt_request, verification).await?; + + // Step 2: Attempt internal settlement (returns saga + SettlementDecision) + // Note: Compensation is handled internally if this fails + let (setup_saga, settlement) = setup_saga.attempt_internal_settlement(melt_request).await?; + + // Step 3: Make payment (internal or external) + let payment_saga = setup_saga.make_payment(settlement).await?; + + // Step 4: Finalize (TX2 - marks spent, issues change) + payment_saga.finalize().await + } + + /// Process melt asynchronously - returns immediately after setup with PENDING state + /// + /// This method is called when the client includes the `Prefer: respond-async` header. + /// It performs the setup phase (TX1) to validate and reserve proofs, then spawns a + /// background task to complete the payment and finalization phases. + pub async fn melt_async( + &self, + melt_request: &MeltRequest, + ) -> Result, Error> { + let verification = self.verify_inputs(melt_request.inputs()).await?; + + let init_saga = MeltSaga::new( + std::sync::Arc::new(self.clone()), + self.localstore.clone(), + std::sync::Arc::clone(&self.pubsub_manager), + ); + + let setup_saga = init_saga.setup_melt(melt_request, verification).await?; + + // Get the quote to return with PENDING state + let quote_id = melt_request.quote().clone(); + let quote = self + .localstore + .get_melt_quote("e_id) + .await? + .ok_or(Error::UnknownQuote)?; + + // Spawn background task to complete the melt operation + let melt_request_clone = melt_request.clone(); + let quote_id_clone = quote_id.clone(); + tokio::spawn(async move { + tracing::debug!( + "Starting background melt completion for quote: {}", + quote_id_clone + ); + + // Step 2: Attempt internal settlement + match setup_saga + .attempt_internal_settlement(&melt_request_clone) + .await + { + Ok((setup_saga, settlement)) => { + // Step 3: Make payment + match setup_saga.make_payment(settlement).await { + Ok(payment_saga) => { + // Step 4: Finalize + match payment_saga.finalize().await { + Ok(_) => { + tracing::info!( + "Background melt completed successfully for quote: {}", + quote_id_clone + ); + } + Err(e) => { + tracing::error!( + "Failed to finalize melt for quote {}: {}", + quote_id_clone, + e + ); + } + } + } + Err(e) => { + tracing::error!( + "Failed to make payment for quote {}: {}", + quote_id_clone, + e + ); + } + } + } + Err(e) => { + tracing::error!( + "Failed internal settlement for quote {}: {}", + quote_id_clone, + e + ); + } + } + }); + + debug_assert!(quote.state == MeltQuoteState::Pending); + + // Return immediately with the quote in PENDING state + Ok(MeltQuoteBolt11Response { + quote: quote_id, + amount: quote.amount, + fee_reserve: quote.fee_reserve, + state: quote.state, + expiry: quote.expiry, + payment_preimage: None, + change: None, + request: Some(quote.request.to_string()), + unit: Some(quote.unit), + }) + } +} diff --git a/crates/cdk/src/mint/melt/shared.rs b/crates/cdk/src/mint/melt/shared.rs new file mode 100644 index 000000000..870507773 --- /dev/null +++ b/crates/cdk/src/mint/melt/shared.rs @@ -0,0 +1,443 @@ +//! Shared logic for melt operations across saga and startup check. +//! +//! This module contains common functions used by both: +//! - `melt_saga`: Normal melt operation flow +//! - `start_up_check`: Recovery of interrupted melts during startup +//! +//! The functions here ensure consistency between these two code paths. + +use cdk_common::database::{self, DynMintDatabase}; +use cdk_common::nuts::{BlindSignature, BlindedMessage, MeltQuoteState, State}; +use cdk_common::{Amount, Error, PublicKey, QuoteId}; +use cdk_signatory::signatory::SignatoryKeySet; + +use crate::mint::subscription::PubSubManager; +use crate::mint::MeltQuote; + +/// Retrieves fee and amount configuration for the keyset matching the change outputs. +/// +/// Searches active keysets for one matching the first output's keyset_id. +/// Used during change calculation for melts. +/// +/// # Arguments +/// +/// * `keysets` - Arc reference to the loaded keysets +/// * `outputs` - Change output blinded messages +/// +/// # Returns +/// +/// Fee per thousand and allowed amounts for the keyset, or default if not found +pub fn get_keyset_fee_and_amounts( + keysets: &arc_swap::ArcSwap>, + outputs: &[BlindedMessage], +) -> cdk_common::amount::FeeAndAmounts { + keysets + .load() + .iter() + .filter_map(|keyset| { + if keyset.active && Some(keyset.id) == outputs.first().map(|x| x.keyset_id) { + Some((keyset.input_fee_ppk, keyset.amounts.clone()).into()) + } else { + None + } + }) + .next() + .unwrap_or_else(|| (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into()) +} + +/// Rolls back a melt quote by removing all setup artifacts and resetting state. +/// +/// This function is used by both: +/// - `melt_saga::compensation::RemoveMeltSetup` when saga fails +/// - `start_up_check::rollback_failed_melt_quote` when recovering failed payments +/// +/// # What This Does +/// +/// Within a single database transaction: +/// 1. Removes input proofs from database +/// 2. Removes change output blinded messages +/// 3. Resets quote state from Pending to Unpaid +/// 4. Deletes melt request tracking record +/// +/// This restores the database to its pre-melt state, allowing retry. +/// +/// # Arguments +/// +/// * `db` - Database connection +/// * `quote_id` - ID of the quote to rollback +/// * `input_ys` - Y values (public keys) from input proofs +/// * `blinded_secrets` - Blinded secrets from change outputs +/// +/// # Errors +/// +/// Returns database errors if transaction fails +pub async fn rollback_melt_quote( + db: &DynMintDatabase, + quote_id: &QuoteId, + input_ys: &[PublicKey], + blinded_secrets: &[PublicKey], +) -> Result<(), Error> { + if input_ys.is_empty() && blinded_secrets.is_empty() { + return Ok(()); + } + + tracing::info!( + "Rolling back melt quote {} ({} proofs, {} blinded messages)", + quote_id, + input_ys.len(), + blinded_secrets.len() + ); + + let mut tx = db.begin_transaction().await?; + + // Remove input proofs + if !input_ys.is_empty() { + tx.remove_proofs(input_ys, Some(quote_id.clone())).await?; + } + + // Remove blinded messages (change outputs) + if !blinded_secrets.is_empty() { + tx.delete_blinded_messages(blinded_secrets).await?; + } + + // Reset quote state from Pending to Unpaid + let (previous_state, _quote) = tx + .update_melt_quote_state(quote_id, MeltQuoteState::Unpaid, None) + .await?; + + if previous_state != MeltQuoteState::Pending { + tracing::warn!( + "Unexpected quote state during rollback: expected Pending, got {}", + previous_state + ); + } + + // Delete melt request tracking record + tx.delete_melt_request(quote_id).await?; + + tx.commit().await?; + + tracing::info!("Successfully rolled back melt quote {}", quote_id); + + Ok(()) +} + +/// Processes change for a melt operation. +/// +/// This function handles the complete change workflow: +/// 1. Calculate change target amount +/// 2. Split into denominations based on keyset configuration +/// 3. Sign change outputs (external call to blind_sign) +/// 4. Store signatures in database (new transaction) +/// +/// # Transaction Management +/// +/// This function expects that the caller has already committed or will rollback +/// their current transaction before calling. It will: +/// - Call blind_sign (external, no DB lock held) +/// - Open a new transaction to store signatures +/// - Return the new transaction for the caller to commit +/// +/// # Arguments +/// +/// * `mint` - Mint instance (for keysets and blind_sign) +/// * `db` - Database connection +/// * `quote_id` - Quote ID for associating signatures +/// * `inputs_amount` - Total amount from input proofs +/// * `total_spent` - Amount spent on payment +/// * `inputs_fee` - Fee paid for inputs +/// * `change_outputs` - Blinded messages for change +/// +/// # Returns +/// +/// Tuple of: +/// - `Option>` - Signed change outputs (if any) +/// - `Box` - New transaction with signatures stored +/// +/// # Errors +/// +/// Returns error if: +/// - Change calculation fails +/// - Blind signing fails +/// - Database operations fail +pub async fn process_melt_change<'a>( + mint: &super::super::Mint, + db: &'a DynMintDatabase, + quote_id: &QuoteId, + inputs_amount: Amount, + total_spent: Amount, + inputs_fee: Amount, + change_outputs: Vec, +) -> Result< + ( + Option>, + Box + Send + Sync + 'a>, + ), + Error, +> { + // Check if change is needed + let needs_change = inputs_amount > total_spent; + + if !needs_change || change_outputs.is_empty() { + // No change needed - open transaction and return empty result + let tx = db.begin_transaction().await?; + return Ok((None, tx)); + } + + let change_target = inputs_amount - total_spent - inputs_fee; + + // Get keyset configuration + let fee_and_amounts = get_keyset_fee_and_amounts(&mint.keysets, &change_outputs); + + // Split change into denominations + let mut amounts = change_target.split(&fee_and_amounts); + + if change_outputs.len() < amounts.len() { + tracing::debug!( + "Providing change requires {} blinded messages, but only {} provided", + amounts.len(), + change_outputs.len() + ); + amounts.sort_by(|a, b| b.cmp(a)); + } + + // Prepare blinded messages with amounts + let mut blinded_messages_to_sign = vec![]; + for (amount, mut blinded_message) in amounts.iter().zip(change_outputs.iter().cloned()) { + blinded_message.amount = *amount; + blinded_messages_to_sign.push(blinded_message); + } + + // External call: sign change outputs (no DB transaction held) + let change_sigs = mint.blind_sign(blinded_messages_to_sign.clone()).await?; + + // Open new transaction to store signatures + let mut tx = db.begin_transaction().await?; + + let blinded_secrets: Vec<_> = blinded_messages_to_sign + .iter() + .map(|bm| bm.blinded_secret) + .collect(); + + tx.add_blind_signatures(&blinded_secrets, &change_sigs, Some(quote_id.clone())) + .await?; + + Ok((Some(change_sigs), tx)) +} + +/// Finalizes a melt quote by updating proofs, quote state, and publishing changes. +/// +/// This function performs the core finalization operations that are common to both +/// the saga finalize step and startup check recovery: +/// 1. Validates amounts (total_spent vs quote amount, inputs vs total_spent) +/// 2. Marks input proofs as SPENT +/// 3. Publishes proof state changes +/// 4. Updates quote state to PAID +/// 5. Updates payment lookup ID if changed +/// 6. Deletes melt request tracking +/// +/// # Transaction Management +/// +/// This function expects an open transaction and will NOT commit it. +/// The caller is responsible for committing the transaction. +/// +/// # Arguments +/// +/// * `tx` - Open database transaction +/// * `pubsub` - Pubsub manager for state notifications +/// * `quote` - Melt quote being finalized +/// * `input_ys` - Y values of input proofs +/// * `inputs_amount` - Total amount from inputs +/// * `inputs_fee` - Fee for inputs +/// * `total_spent` - Amount spent on payment +/// * `payment_preimage` - Payment preimage (if any) +/// * `payment_lookup_id` - Payment lookup identifier +/// +/// # Returns +/// +/// `Ok(())` if finalization succeeds +/// +/// # Errors +/// +/// Returns error if: +/// - Amount validation fails +/// - Proofs are already spent +/// - Database operations fail +#[allow(clippy::too_many_arguments)] +pub async fn finalize_melt_core( + tx: &mut Box + Send + Sync + '_>, + pubsub: &PubSubManager, + quote: &MeltQuote, + input_ys: &[PublicKey], + inputs_amount: Amount, + inputs_fee: Amount, + total_spent: Amount, + payment_preimage: Option, + payment_lookup_id: &cdk_common::payment::PaymentIdentifier, +) -> Result<(), Error> { + // Validate quote amount vs payment amount + if quote.amount > total_spent { + tracing::error!( + "Payment amount {} is less than quote amount {} for quote {}", + total_spent, + quote.amount, + quote.id + ); + return Err(Error::IncorrectQuoteAmount); + } + + // Validate inputs amount + if inputs_amount - inputs_fee < total_spent { + tracing::error!("Over paid melt quote {}", quote.id); + return Err(Error::IncorrectQuoteAmount); + } + + // Update quote state to Paid + tx.update_melt_quote_state("e.id, MeltQuoteState::Paid, payment_preimage.clone()) + .await?; + + // Update payment lookup ID if changed + if quote.request_lookup_id.as_ref() != Some(payment_lookup_id) { + tracing::info!( + "Payment lookup id changed post payment from {:?} to {}", + "e.request_lookup_id, + payment_lookup_id + ); + + tx.update_melt_quote_request_lookup_id("e.id, payment_lookup_id) + .await?; + } + + // Mark input proofs as spent + match tx.update_proofs_states(input_ys, State::Spent).await { + Ok(_) => {} + Err(database::Error::AttemptUpdateSpentProof) => { + tracing::info!("Proofs for quote {} already marked as spent", quote.id); + return Ok(()); + } + Err(err) => { + return Err(err.into()); + } + } + + // Publish proof state changes + for pk in input_ys.iter() { + pubsub.proof_state((*pk, State::Spent)); + } + + Ok(()) +} + +/// High-level melt finalization that handles the complete workflow. +/// +/// This function orchestrates: +/// 1. Getting melt request info +/// 2. Getting input proof Y values +/// 3. Processing change (if needed) +/// 4. Core finalization operations +/// 5. Transaction commit +/// 6. Pubsub notification +/// +/// # Arguments +/// +/// * `mint` - Mint instance +/// * `db` - Database connection +/// * `pubsub` - Pubsub manager +/// * `quote` - Melt quote to finalize +/// * `total_spent` - Amount spent on payment +/// * `payment_preimage` - Payment preimage (if any) +/// * `payment_lookup_id` - Payment lookup identifier +/// +/// # Returns +/// +/// `Option>` - Change signatures (if any) +pub async fn finalize_melt_quote( + mint: &super::super::Mint, + db: &DynMintDatabase, + pubsub: &PubSubManager, + quote: &MeltQuote, + total_spent: Amount, + payment_preimage: Option, + payment_lookup_id: &cdk_common::payment::PaymentIdentifier, +) -> Result>, Error> { + use cdk_common::amount::to_unit; + + tracing::info!("Finalizing melt quote {}", quote.id); + + // Convert total_spent to quote unit + let total_spent = to_unit(total_spent, "e.unit, "e.unit).unwrap_or(total_spent); + + let mut tx = db.begin_transaction().await?; + + // Get melt request info + let melt_request_info = match tx.get_melt_request_and_blinded_messages("e.id).await? { + Some(info) => info, + None => { + tracing::warn!( + "No melt request found for quote {} - may have been completed already", + quote.id + ); + tx.rollback().await?; + return Ok(None); + } + }; + + // Get input proof Y values + let input_ys = tx.get_proof_ys_by_quote_id("e.id).await?; + + if input_ys.is_empty() { + tracing::warn!( + "No input proofs found for quote {} - may have been completed already", + quote.id + ); + tx.rollback().await?; + return Ok(None); + } + + // Core finalization (marks proofs spent, updates quote) + finalize_melt_core( + &mut tx, + pubsub, + quote, + &input_ys, + melt_request_info.inputs_amount, + melt_request_info.inputs_fee, + total_spent, + payment_preimage.clone(), + payment_lookup_id, + ) + .await?; + + // Close transaction before external call + tx.commit().await?; + + // Process change (if needed) - opens new transaction + let (change_sigs, mut tx) = process_melt_change( + mint, + db, + "e.id, + melt_request_info.inputs_amount, + total_spent, + melt_request_info.inputs_fee, + melt_request_info.change_outputs.clone(), + ) + .await?; + + // Delete melt request tracking + tx.delete_melt_request("e.id).await?; + + // Commit transaction + tx.commit().await?; + + // Publish quote status change + pubsub.melt_quote_status( + quote, + payment_preimage, + change_sigs.clone(), + MeltQuoteState::Paid, + ); + + tracing::info!("Successfully finalized melt quote {}", quote.id); + + Ok(change_sigs) +} diff --git a/crates/cdk/src/mint/melt/tests/htlc_sigall_spending_conditions_tests.rs b/crates/cdk/src/mint/melt/tests/htlc_sigall_spending_conditions_tests.rs new file mode 100644 index 000000000..9d5c49118 --- /dev/null +++ b/crates/cdk/src/mint/melt/tests/htlc_sigall_spending_conditions_tests.rs @@ -0,0 +1,180 @@ +//! HTLC SIG_ALL tests for melt functionality +//! +//! These tests verify that the mint correctly enforces SIG_ALL flag behavior for HTLC +//! during melt operations. + +use std::str::FromStr; + +use cdk_common::dhke::construct_proofs; +use cdk_common::melt::MeltQuoteRequest; +use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions}; +use cdk_common::{Amount, SpendingConditionVerification}; + +use crate::test_helpers::nut10::{ + create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper, +}; + +/// Test: HTLC SIG_ALL requiring preimage and one signature +/// +/// Creates HTLC-locked proofs with SIG_ALL flag and verifies: +/// 1. Melting with only preimage fails (signature required) +/// 2. Melting with only SIG_INPUTS signatures fails (SIG_ALL required) +/// 3. Melting with both preimage and SIG_ALL signature succeeds +#[tokio::test] +async fn test_htlc_sig_all_requiring_preimage_and_one_signature() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for Alice + let (alice_secret, alice_pubkey) = create_test_keypair(); + + // Create hash and preimage + let (hash, preimage) = create_test_hash_and_preimage(); + + println!("Alice pubkey: {}", alice_pubkey); + println!("Hash: {}", hash); + println!("Preimage: {}", preimage); + + // Step 1: Mint regular proofs (enough to cover invoice + fees) + let input_amount = Amount::from(20); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create HTLC spending conditions with SIG_ALL flag (hash locked to Alice's key) + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![alice_pubkey]), + refund_keys: None, + num_sigs: None, // Default (1) + sig_flag: SigFlag::SigAll, // <-- SIG_ALL flag + num_sigs_refund: None, + }), + ) + .unwrap(); + println!("Created HTLC spending conditions with SIG_ALL flag"); + + // Step 3: Create HTLC blinded messages (outputs) + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + println!( + "Created {} HTLC outputs locked to alice with hash", + htlc_outputs.len() + ); + + // Step 4: Swap regular proofs for HTLC proofs (no signature needed on inputs) + let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for HTLC proofs"); + println!("Swap successful! Got BlindSignatures for our HTLC outputs"); + + // Step 5: Construct the HTLC proofs + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = htlc_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} HTLC proof(s) [{}]", + htlc_proofs.len(), + proof_amounts.join("+") + ); + + // Step 6: Create a real melt quote that we'll use for all tests + let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq"; + let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap(); + + let melt_quote_request = cdk_common::MeltQuoteBolt11Request { + request: bolt11, + unit: cdk_common::CurrencyUnit::Sat, + options: None, + }; + + let melt_quote = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request)) + .await + .unwrap(); + println!("Created melt quote: {}", melt_quote.quote); + + // Step 7: Try to melt with only preimage (should fail - signature required) + let mut proofs_preimage_only = htlc_proofs.clone(); + // Add only preimage to first proof (no signature) + proofs_preimage_only[0].add_preimage(preimage.clone()); + + let melt_request_preimage_only = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_preimage_only.into(), None); + + let result = melt_request_preimage_only.verify_spending_conditions(); + assert!( + result.is_err(), + "Should fail with only preimage (no signature)" + ); + println!("✓ Melting with ONLY preimage failed verification as expected"); + + let melt_result = mint.melt(&melt_request_preimage_only).await; + assert!( + melt_result.is_err(), + "Actual melt should also fail with only preimage" + ); + println!("✓ Actual melt with ONLY preimage also failed as expected"); + + // Step 8: Try to melt with SIG_INPUTS signatures (should fail - SIG_ALL required) + let mut melt_request_sig_inputs = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), htlc_proofs.clone().into(), None); + + // Add preimage to first proof + melt_request_sig_inputs.inputs_mut()[0].add_preimage(preimage.clone()); + + // Sign each proof individually (SIG_INPUTS mode) - this should fail for SIG_ALL + for proof in melt_request_sig_inputs.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = melt_request_sig_inputs.verify_spending_conditions(); + assert!( + result.is_err(), + "Should fail - SIG_INPUTS signatures not valid for SIG_ALL" + ); + println!("✓ Melting with SIG_INPUTS signatures failed verification as expected"); + + let melt_result = mint.melt(&melt_request_sig_inputs).await; + assert!( + melt_result.is_err(), + "Actual melt should also fail with SIG_INPUTS signatures" + ); + println!("✓ Actual melt with SIG_INPUTS signatures also failed as expected"); + + // Step 9: Now melt with correct preimage + SIG_ALL signature + let mut melt_request = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), htlc_proofs.clone().into(), None); + + // Add preimage to first proof + melt_request.inputs_mut()[0].add_preimage(preimage.clone()); + + // Use sign_sig_all to sign the transaction (signature goes on first proof's witness) + melt_request.sign_sig_all(alice_secret.clone()).unwrap(); + + // Verify spending conditions pass + melt_request.verify_spending_conditions().unwrap(); + println!("✓ HTLC SIG_ALL spending conditions verified successfully"); + + // Perform the actual melt - this also verifies spending conditions internally + let melt_response = mint.melt(&melt_request).await.unwrap(); + println!("✓ Melt operation completed successfully!"); + println!(" Quote state: {:?}", melt_response.state); + assert_eq!(melt_response.quote, melt_quote.quote); +} diff --git a/crates/cdk/src/mint/melt/tests/htlc_spending_conditions_tests.rs b/crates/cdk/src/mint/melt/tests/htlc_spending_conditions_tests.rs new file mode 100644 index 000000000..85c54f362 --- /dev/null +++ b/crates/cdk/src/mint/melt/tests/htlc_spending_conditions_tests.rs @@ -0,0 +1,191 @@ +//! HTLC (NUT-14) tests for melt functionality +//! +//! These tests verify that the mint correctly validates HTLC spending conditions +//! during melt operations, including: +//! - Hash preimage verification +//! - Signature validation + +use cdk_common::dhke::construct_proofs; +use cdk_common::melt::MeltQuoteRequest; +use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions}; +use cdk_common::Amount; + +use crate::test_helpers::nut10::{ + create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper, +}; + +/// Test: HTLC requiring preimage and one signature +/// +/// Creates HTLC-locked proofs and verifies: +/// 1. Melting with only preimage fails (signature required) +/// 2. Melting with only signature fails (preimage required) +/// 3. Melting with both preimage and signature succeeds +#[tokio::test] +async fn test_htlc_requiring_preimage_and_one_signature() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for Alice + let (alice_secret, alice_pubkey) = create_test_keypair(); + + // Create hash and preimage + let (hash, preimage) = create_test_hash_and_preimage(); + + println!("Alice pubkey: {}", alice_pubkey); + println!("Hash: {}", hash); + println!("Preimage: {}", preimage); + + // Step 1: Mint regular proofs (enough to cover the invoice amount + fees) + // Invoice is 10 sats, fee reserve is 100% (10 sats), so we need 20 sats total + let input_amount = Amount::from(20); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create HTLC spending conditions (hash locked to Alice's key) + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![alice_pubkey]), + refund_keys: None, + num_sigs: None, // Default (1) + sig_flag: SigFlag::default(), + num_sigs_refund: None, + }), + ) + .unwrap(); + println!("Created HTLC spending conditions"); + + // Step 3: Create HTLC blinded messages (outputs) + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + println!( + "Created {} HTLC outputs locked to alice with hash", + htlc_outputs.len() + ); + + // Step 4: Swap regular proofs for HTLC proofs (no signature needed on inputs) + let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for HTLC proofs"); + println!("Swap successful! Got BlindSignatures for our HTLC outputs"); + + // Step 5: Construct the HTLC proofs + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = htlc_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} HTLC proof(s) [{}]", + htlc_proofs.len(), + proof_amounts.join("+") + ); + + // Step 6: Create a real melt quote that we'll use for all tests + use std::str::FromStr; + + use cdk_common::SpendingConditionVerification; + let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq"; + let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap(); + + let melt_quote_request = cdk_common::MeltQuoteBolt11Request { + request: bolt11, + unit: cdk_common::CurrencyUnit::Sat, + options: None, + }; + + let melt_quote = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request)) + .await + .unwrap(); + println!("Created melt quote: {}", melt_quote.quote); + + // Step 7: Try to melt with only preimage (should fail - signature required) + + let mut proofs_preimage_only = htlc_proofs.clone(); + + // Add only preimage (no signature) + for proof in proofs_preimage_only.iter_mut() { + proof.add_preimage(preimage.clone()); + } + + let melt_request_preimage_only = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_preimage_only.into(), None); + + let result = melt_request_preimage_only.verify_spending_conditions(); + assert!( + result.is_err(), + "Should fail with only preimage (no signature)" + ); + println!("✓ Melting with ONLY preimage failed verification as expected"); + + // Also verify the actual melt fails + let melt_result = mint.melt(&melt_request_preimage_only).await; + assert!( + melt_result.is_err(), + "Actual melt should also fail with only preimage" + ); + println!("✓ Actual melt with ONLY preimage also failed as expected"); + + // Step 8: Try to melt with only signature (should fail - preimage required) + let mut proofs_signature_only = htlc_proofs.clone(); + + // Add only signature (no preimage) + for proof in proofs_signature_only.iter_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let melt_request_signature_only = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_signature_only.into(), None); + + let result = melt_request_signature_only.verify_spending_conditions(); + assert!( + result.is_err(), + "Should fail with only signature (no preimage)" + ); + println!("✓ Melting with ONLY signature failed verification as expected"); + + // Also verify the actual melt fails + let melt_result = mint.melt(&melt_request_signature_only).await; + assert!( + melt_result.is_err(), + "Actual melt should also fail with only signature" + ); + println!("✓ Actual melt with ONLY signature also failed as expected"); + + // Step 9: Now melt with correct preimage + signature + let mut proofs_both = htlc_proofs.clone(); + + // Add preimage and sign all proofs + for proof in proofs_both.iter_mut() { + proof.add_preimage(preimage.clone()); + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let melt_request = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_both.into(), None); + + // Verify spending conditions pass + melt_request.verify_spending_conditions().unwrap(); + println!("✓ HTLC spending conditions verified successfully"); + + // Perform the actual melt - this also verifies spending conditions internally + let melt_response = mint.melt(&melt_request).await.unwrap(); + println!("✓ Melt operation completed successfully!"); + println!(" Quote state: {:?}", melt_response.state); + assert_eq!(melt_response.quote, melt_quote.quote); +} diff --git a/crates/cdk/src/mint/melt/tests/locktime_spending_conditions_tests.rs b/crates/cdk/src/mint/melt/tests/locktime_spending_conditions_tests.rs new file mode 100644 index 000000000..9e2a5e4b8 --- /dev/null +++ b/crates/cdk/src/mint/melt/tests/locktime_spending_conditions_tests.rs @@ -0,0 +1,276 @@ +//! Locktime tests for melt functionality +//! +//! These tests verify that the mint correctly validates locktime spending conditions +//! during melt operations, including spending after locktime expiry. + +use std::str::FromStr; + +use cdk_common::dhke::construct_proofs; +use cdk_common::melt::MeltQuoteRequest; +use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions}; +use cdk_common::{Amount, SpendingConditionVerification}; + +use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper}; +use crate::util::unix_time; + +/// Test: P2PK with locktime - spending after expiry +/// +/// Creates P2PK proofs with locktime and verifies: +/// 1. Melting before locktime with wrong key fails +/// 2. Melting after locktime with any key succeeds (anyone-can-spend) +#[tokio::test] +async fn test_p2pk_post_locktime_anyone_can_spend() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypairs + let (_alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, _bob_pubkey) = create_test_keypair(); + + println!("Alice pubkey: {}", alice_pubkey); + + // Step 1: Create regular unencumbered proofs + let input_amount = Amount::from(20); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create P2PK spending conditions with locktime in the past (already expired) + // Locktime is 1 hour ago - so it's already expired + let locktime = unix_time() - 3600; + + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // Locktime in the past (expired) + pubkeys: None, // no additional pubkeys + refund_keys: None, // NO refund keys - anyone can spend! + num_sigs: None, // default (1) + sig_flag: SigFlag::SigInputs, // SIG_INPUTS flag + num_sigs_refund: None, // default (1) + }), + ); + println!( + "Created P2PK spending conditions with expired locktime: {}", + locktime + ); + + // Split the input amount + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + // Create blinded messages + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + println!("Created {} P2PK outputs with locktime", p2pk_outputs.len()); + + // Step 3: Swap for P2PK proofs + let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for P2PK proofs"); + println!("Swap successful! Got BlindSignatures"); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} P2PK proof(s) [{}]", + p2pk_proofs.len(), + proof_amounts.join("+") + ); + + // Step 5: Create a real melt quote + let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq"; + let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap(); + + let melt_quote_request = cdk_common::MeltQuoteBolt11Request { + request: bolt11, + unit: cdk_common::CurrencyUnit::Sat, + options: None, + }; + + let melt_quote = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request)) + .await + .unwrap(); + println!("Created melt quote: {}", melt_quote.quote); + + // Step 6: Try to melt with Bob's signature (wrong key, but locktime expired so should work) + let mut proofs_bob_signed = p2pk_proofs.clone(); + + // Sign with Bob's key (not Alice's) + for proof in proofs_bob_signed.iter_mut() { + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let melt_request_bob = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_bob_signed.into(), None); + + // After locktime expiry, anyone can spend (signature verification is skipped) + melt_request_bob.verify_spending_conditions().unwrap(); + println!("✓ Post-locktime spending conditions verified successfully (anyone-can-spend)"); + + // Perform the actual melt + let melt_response = mint.melt(&melt_request_bob).await.unwrap(); + println!("✓ Melt operation completed successfully with Bob's key after locktime!"); + println!(" Quote state: {:?}", melt_response.state); + assert_eq!(melt_response.quote, melt_quote.quote); +} + +/// Test: P2PK with future locktime - must use correct key before expiry +/// +/// Creates P2PK proofs with future locktime and verifies: +/// 1. Melting with wrong key before locktime fails +/// 2. Melting with correct key before locktime succeeds +#[tokio::test] +async fn test_p2pk_before_locktime_requires_correct_key() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypairs + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, _bob_pubkey) = create_test_keypair(); + + println!("Alice pubkey: {}", alice_pubkey); + + // Step 1: Create regular unencumbered proofs + let input_amount = Amount::from(20); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create P2PK spending conditions with locktime FAR in the future + // Locktime is 1 year from now - definitely not expired yet + let locktime = unix_time() + 365 * 24 * 60 * 60; + + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + Some(locktime), // Locktime in the future + None, // no additional pubkeys + None, // no refund keys + None, // default num_sigs (1) + Some(SigFlag::SigInputs), // SIG_INPUTS flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + println!( + "Created P2PK spending conditions with future locktime: {}", + locktime + ); + + // Split the input amount + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + // Create blinded messages + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + println!("Created {} P2PK outputs with locktime", p2pk_outputs.len()); + + // Step 3: Swap for P2PK proofs + let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for P2PK proofs"); + println!("Swap successful! Got BlindSignatures"); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} P2PK proof(s) [{}]", + p2pk_proofs.len(), + proof_amounts.join("+") + ); + + // Step 5: Create a real melt quote + let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq"; + let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap(); + + let melt_quote_request = cdk_common::MeltQuoteBolt11Request { + request: bolt11, + unit: cdk_common::CurrencyUnit::Sat, + options: None, + }; + + let melt_quote = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request)) + .await + .unwrap(); + println!("Created melt quote: {}", melt_quote.quote); + + // Step 6: Try to melt with Bob's signature (wrong key, locktime not expired) + let mut proofs_bob_signed = p2pk_proofs.clone(); + + // Sign with Bob's key (not Alice's) + for proof in proofs_bob_signed.iter_mut() { + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let melt_request_bob = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_bob_signed.into(), None); + + // Before locktime expiry, wrong key should fail + let result = melt_request_bob.verify_spending_conditions(); + assert!( + result.is_err(), + "Should fail with wrong key before locktime" + ); + println!("✓ Melting with Bob's key before locktime failed verification as expected"); + + // Also verify the actual melt fails + let melt_result = mint.melt(&melt_request_bob).await; + assert!( + melt_result.is_err(), + "Actual melt should also fail with wrong key" + ); + println!("✓ Actual melt with Bob's key before locktime also failed as expected"); + + // Step 7: Now melt with Alice's signature (correct key) + let mut proofs_alice_signed = p2pk_proofs.clone(); + + // Sign with Alice's key (correct) + for proof in proofs_alice_signed.iter_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let melt_request_alice = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_alice_signed.into(), None); + + // Verify spending conditions pass + melt_request_alice.verify_spending_conditions().unwrap(); + println!("✓ Pre-locktime spending conditions verified successfully with Alice's key"); + + // Perform the actual melt + let melt_response = mint.melt(&melt_request_alice).await.unwrap(); + println!("✓ Melt operation completed successfully with Alice's key before locktime!"); + println!(" Quote state: {:?}", melt_response.state); + assert_eq!(melt_response.quote, melt_quote.quote); +} diff --git a/crates/cdk/src/mint/melt/tests/mod.rs b/crates/cdk/src/mint/melt/tests/mod.rs new file mode 100644 index 000000000..e3f8724c0 --- /dev/null +++ b/crates/cdk/src/mint/melt/tests/mod.rs @@ -0,0 +1,5 @@ +mod htlc_sigall_spending_conditions_tests; +mod htlc_spending_conditions_tests; +mod locktime_spending_conditions_tests; +mod p2pk_sigall_spending_conditions_tests; +mod p2pk_spending_conditions_tests; diff --git a/crates/cdk/src/mint/melt/tests/p2pk_sigall_spending_conditions_tests.rs b/crates/cdk/src/mint/melt/tests/p2pk_sigall_spending_conditions_tests.rs new file mode 100644 index 000000000..670fde238 --- /dev/null +++ b/crates/cdk/src/mint/melt/tests/p2pk_sigall_spending_conditions_tests.rs @@ -0,0 +1,168 @@ +//! P2PK SIG_ALL tests for melt functionality +//! +//! These tests verify that the mint correctly enforces SIG_ALL flag behavior +//! during melt operations. + +use cdk_common::dhke::construct_proofs; +use cdk_common::melt::MeltQuoteRequest; +use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions}; +use cdk_common::Amount; + +use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper}; + +/// Test: P2PK with SIG_ALL flag requires transaction signature +/// +/// Creates P2PK proofs with SIG_ALL flag and verifies: +/// 1. Melting without signature is rejected +/// 2. Melting with SIG_INPUTS signatures (individual proof signatures) is rejected +/// 3. Melting with SIG_ALL signature (transaction signature) succeeds +#[tokio::test] +async fn test_p2pk_sig_all_requires_transaction_signature() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for P2PK + let (alice_secret, alice_pubkey) = create_test_keypair(); + println!("Alice pubkey: {}", alice_pubkey); + + // Step 1: Create regular unencumbered proofs that we'll swap for P2PK proofs + // Invoice is 10 sats, fee reserve is 100% (10 sats), so we need 20 sats total + let input_amount = Amount::from(20); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create P2PK blinded messages (outputs locked to alice_pubkey) with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + None, // no locktime + None, // no additional pubkeys + None, // no refund keys + None, // default num_sigs (1) + Some(SigFlag::SigAll), // SIG_ALL flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + println!("Created P2PK spending conditions with SIG_ALL flag"); + + // Split the input amount into power-of-2 denominations + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + // Create blinded messages for each split amount + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + println!( + "Created {} P2PK outputs locked to alice", + p2pk_outputs.len() + ); + + // Step 3: Swap regular proofs for P2PK proofs (no signature needed on inputs) + let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for P2PK proofs"); + println!("Swap successful! Got BlindSignatures for our P2PK outputs"); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} P2PK proof(s) [{}]", + p2pk_proofs.len(), + proof_amounts.join("+") + ); + + // Step 5: Create a real melt quote that we'll use for all tests + use std::str::FromStr; + + use cdk_common::SpendingConditionVerification; + let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq"; + let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap(); + + let melt_quote_request = cdk_common::MeltQuoteBolt11Request { + request: bolt11, + unit: cdk_common::CurrencyUnit::Sat, + options: None, + }; + + let melt_quote = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request)) + .await + .unwrap(); + println!("Created melt quote: {}", melt_quote.quote); + + // Step 6: Try to melt P2PK proof WITHOUT signature (should fail) + + let melt_request_no_sig = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), p2pk_proofs.clone().into(), None); + + let result = melt_request_no_sig.verify_spending_conditions(); + assert!(result.is_err(), "Should fail without signature"); + println!("✓ Melting WITHOUT signature failed verification as expected"); + + // Also verify the actual melt fails + let melt_result = mint.melt(&melt_request_no_sig).await; + assert!( + melt_result.is_err(), + "Actual melt should also fail without signature" + ); + println!("✓ Actual melt WITHOUT signature also failed as expected"); + + // Step 7: Sign all proofs individually (SIG_INPUTS way) - should fail for SIG_ALL + let mut melt_request_sig_inputs = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), p2pk_proofs.clone().into(), None); + + // Sign each proof individually (SIG_INPUTS mode) + for proof in melt_request_sig_inputs.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = melt_request_sig_inputs.verify_spending_conditions(); + assert!( + result.is_err(), + "Should fail - SIG_INPUTS signatures not valid for SIG_ALL" + ); + println!("✓ Melting with SIG_INPUTS signatures failed verification as expected"); + + // Also verify the actual melt fails + let melt_result = mint.melt(&melt_request_sig_inputs).await; + assert!( + melt_result.is_err(), + "Actual melt should also fail with SIG_INPUTS signatures" + ); + println!("✓ Actual melt with SIG_INPUTS signatures also failed as expected"); + + // Step 8: Sign the transaction with SIG_ALL and perform the melt + let mut melt_request = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), p2pk_proofs.clone().into(), None); + + // Use sign_sig_all to sign the transaction (signature goes on first proof's witness) + melt_request.sign_sig_all(alice_secret.clone()).unwrap(); + + // Verify spending conditions pass + melt_request.verify_spending_conditions().unwrap(); + println!("✓ P2PK SIG_ALL spending conditions verified successfully"); + + // Perform the actual melt - this also verifies spending conditions internally + let melt_response = mint.melt(&melt_request).await.unwrap(); + println!("✓ Melt operation completed successfully!"); + println!(" Quote state: {:?}", melt_response.state); + assert_eq!(melt_response.quote, melt_quote.quote); +} diff --git a/crates/cdk/src/mint/melt/tests/p2pk_spending_conditions_tests.rs b/crates/cdk/src/mint/melt/tests/p2pk_spending_conditions_tests.rs new file mode 100644 index 000000000..1065029c7 --- /dev/null +++ b/crates/cdk/src/mint/melt/tests/p2pk_spending_conditions_tests.rs @@ -0,0 +1,134 @@ +//! Basic P2PK tests for melt functionality (SIG_INPUTS mode) +//! +//! These tests verify that the mint correctly validates basic P2PK spending conditions +//! during melt operations. + +use std::str::FromStr; + +use cdk_common::dhke::construct_proofs; +use cdk_common::melt::MeltQuoteRequest; +use cdk_common::nuts::SpendingConditions; +use cdk_common::{Amount, SpendingConditionVerification}; + +use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper}; + +/// Test: Basic P2PK with SIG_INPUTS (default mode) +/// +/// Creates P2PK proofs with default SIG_INPUTS flag and verifies: +/// 1. Melting without signatures is rejected +/// 2. Melting with signatures on all proofs succeeds +#[tokio::test] +async fn test_p2pk_basic_sig_inputs() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for P2PK + let (alice_secret, alice_pubkey) = create_test_keypair(); + println!("Alice pubkey: {}", alice_pubkey); + + // Step 1: Create regular unencumbered proofs that we'll swap for P2PK proofs + let input_amount = Amount::from(20); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create P2PK blinded messages (outputs locked to alice_pubkey) with default SIG_INPUTS + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + None, // No additional conditions - uses default SIG_INPUTS + ); + println!("Created P2PK spending conditions with default SIG_INPUTS flag"); + + // Split the input amount into power-of-2 denominations + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + // Create blinded messages for each split amount + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + println!( + "Created {} P2PK outputs locked to alice", + p2pk_outputs.len() + ); + + // Step 3: Swap regular proofs for P2PK proofs (no signature needed on inputs) + let swap_request = cdk_common::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for P2PK proofs"); + println!("Swap successful! Got BlindSignatures for our P2PK outputs"); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} P2PK proof(s) [{}]", + p2pk_proofs.len(), + proof_amounts.join("+") + ); + + // Step 5: Create a real melt quote that we'll use for all tests + let bolt11_str = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq"; + let bolt11 = cdk_common::Bolt11Invoice::from_str(bolt11_str).unwrap(); + + let melt_quote_request = cdk_common::MeltQuoteBolt11Request { + request: bolt11, + unit: cdk_common::CurrencyUnit::Sat, + options: None, + }; + + let melt_quote = mint + .get_melt_quote(MeltQuoteRequest::Bolt11(melt_quote_request)) + .await + .unwrap(); + println!("Created melt quote: {}", melt_quote.quote); + + // Step 6: Try to melt P2PK proof WITHOUT signature (should fail) + let melt_request_no_sig = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), p2pk_proofs.clone().into(), None); + + let result = melt_request_no_sig.verify_spending_conditions(); + assert!(result.is_err(), "Should fail without signature"); + println!("✓ Melting WITHOUT signature failed verification as expected"); + + // Also verify the actual melt fails + let melt_result = mint.melt(&melt_request_no_sig).await; + assert!( + melt_result.is_err(), + "Actual melt should also fail without signature" + ); + println!("✓ Actual melt WITHOUT signature also failed as expected"); + + // Step 7: Sign all proofs individually (SIG_INPUTS mode) and perform the melt + let mut proofs_signed = p2pk_proofs.clone(); + + // Sign each proof individually (SIG_INPUTS mode) + for proof in proofs_signed.iter_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let melt_request = + cdk_common::MeltRequest::new(melt_quote.quote.clone(), proofs_signed.into(), None); + + // Verify spending conditions pass + melt_request.verify_spending_conditions().unwrap(); + println!("✓ P2PK SIG_INPUTS spending conditions verified successfully"); + + // Perform the actual melt - this also verifies spending conditions internally + let melt_response = mint.melt(&melt_request).await.unwrap(); + println!("✓ Melt operation completed successfully!"); + println!(" Quote state: {:?}", melt_response.state); + assert_eq!(melt_response.quote, melt_quote.quote); +} diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index d9285a01d..dc2aee999 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -2,31 +2,34 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; use arc_swap::ArcSwap; -use cdk_common::common::{PaymentProcessorKey, QuoteTTL}; +use cdk_common::amount::to_unit; +use cdk_common::common::{PaymentProcessorKey, QuoteTTL, UnitMetadata}; #[cfg(feature = "auth")] -use cdk_common::database::MintAuthDatabase; -use cdk_common::database::{self, MintDatabase, MintTransaction}; -use cdk_common::nuts::{self, BlindSignature, BlindedMessage, CurrencyUnit, Id, Kind}; -use cdk_common::secret; +use cdk_common::database::DynMintAuthDatabase; +use cdk_common::database::{self, DynMintDatabase}; +use cdk_common::nuts::{BlindSignature, BlindedMessage, CurrencyUnit, Id}; +use cdk_common::payment::{DynMintPayment, WaitPaymentResponse}; +pub use cdk_common::quote_id::QuoteId; +#[cfg(feature = "prometheus")] +use cdk_prometheus::global; use cdk_signatory::signatory::{Signatory, SignatoryKeySet}; use futures::StreamExt; #[cfg(feature = "auth")] use nut21::ProtectedEndpoint; use subscription::PubSubManager; -use tokio::sync::Notify; -use tokio::task::JoinSet; +use tokio::sync::{Mutex, Notify}; +use tokio::task::{JoinHandle, JoinSet}; use tracing::instrument; -use uuid::Uuid; -use crate::cdk_payment::{self, MintPayment}; use crate::error::Error; use crate::fees::calculate_fee; use crate::nuts::*; +use crate::Amount; #[cfg(feature = "auth")] use crate::OidcClient; -use crate::{cdk_database, Amount}; #[cfg(feature = "auth")] pub(crate) mod auth; @@ -36,9 +39,8 @@ mod issue; mod keysets; mod ln; mod melt; -mod proof_writer; mod start_up_check; -pub mod subscription; +mod subscription; mod swap; mod verification; @@ -46,6 +48,11 @@ pub use builder::{MintBuilder, MintMeltLimits}; pub use cdk_common::mint::{MeltQuote, MintKeySetInfo, MintQuote}; pub use verification::Verification; +const CDK_MINT_PRIMARY_NAMESPACE: &str = "cdk_mint"; +const CDK_MINT_CONFIG_SECONDARY_NAMESPACE: &str = "config"; +const CDK_MINT_CONFIG_KV_KEY: &str = "mint_info"; +const CDK_MINT_QUOTE_TTL_KV_KEY: &str = "quote_ttl"; + /// Cashu Mint #[derive(Clone)] pub struct Mint { @@ -53,58 +60,56 @@ pub struct Mint { /// /// It is implemented in the cdk-signatory crate, and it can be embedded in the mint or it can /// be a gRPC client to a remote signatory server. - pub signatory: Arc, + signatory: Arc, /// Mint Storage backend - pub localstore: Arc + Send + Sync>, + localstore: DynMintDatabase, /// Auth Storage backend (only available with auth feature) #[cfg(feature = "auth")] - pub auth_localstore: Option + Send + Sync>>, - /// Ln backends for mint - pub ln: - HashMap + Send + Sync>>, + auth_localstore: Option, + /// Payment processors for mint + payment_processors: Arc>, /// Subscription manager - pub pubsub_manager: Arc, + pubsub_manager: Arc, #[cfg(feature = "auth")] oidc_client: Option, + /// Static auth token (if set, this token will be accepted for clear auth) + #[cfg(feature = "auth")] + static_auth_token: Option, /// In-memory keyset keysets: Arc>>, + /// Background task management + task_state: Arc>, + keys_metadata: Arc>, } -impl Mint { - /// Get the payment processor for the given unit and payment method - pub fn get_payment_processor( - &self, - unit: CurrencyUnit, - payment_method: PaymentMethod, - ) -> Result + Send + Sync>, Error> { - let key = PaymentProcessorKey::new(unit.clone(), payment_method.clone()); - self.ln.get(&key).cloned().ok_or_else(|| { - tracing::info!( - "No payment processor set for pair {}, {}", - unit, - payment_method - ); - Error::UnsupportedUnit - }) - } +/// State for managing background tasks +#[derive(Default)] +struct TaskState { + /// Shutdown signal for all background tasks + shutdown_notify: Option>, + /// Handle to the main supervisor task + supervisor_handle: Option>>, +} +impl Mint { /// Create new [`Mint`] without authentication pub async fn new( + mint_info: MintInfo, signatory: Arc, - localstore: Arc + Send + Sync>, - ln: HashMap< - PaymentProcessorKey, - Arc + Send + Sync>, - >, + localstore: DynMintDatabase, + payment_processors: HashMap, + keys_metadata: HashMap, ) -> Result { Self::new_internal( + mint_info, signatory, localstore, #[cfg(feature = "auth")] None, - ln, #[cfg(feature = "auth")] None, + payment_processors, + keys_metadata, ) .await } @@ -112,21 +117,22 @@ impl Mint { /// Create new [`Mint`] with authentication support #[cfg(feature = "auth")] pub async fn new_with_auth( + mint_info: MintInfo, signatory: Arc, - localstore: Arc + Send + Sync>, - auth_localstore: Arc + Send + Sync>, - ln: HashMap< - PaymentProcessorKey, - Arc + Send + Sync>, - >, - open_id_discovery: String, + localstore: DynMintDatabase, + auth_localstore: Option, + static_auth_token: Option, + payment_processors: HashMap, + keys_metadata: HashMap, ) -> Result { Self::new_internal( + mint_info, signatory, localstore, - Some(auth_localstore), - ln, - Some(open_id_discovery), + auth_localstore, + static_auth_token, + payment_processors, + keys_metadata, ) .await } @@ -134,21 +140,14 @@ impl Mint { /// Internal function to create a new [`Mint`] with shared logic #[inline] async fn new_internal( + mint_info: MintInfo, signatory: Arc, - localstore: Arc + Send + Sync>, - #[cfg(feature = "auth")] auth_localstore: Option< - Arc + Send + Sync>, - >, - ln: HashMap< - PaymentProcessorKey, - Arc + Send + Sync>, - >, - #[cfg(feature = "auth")] open_id_discovery: Option, + localstore: DynMintDatabase, + #[cfg(feature = "auth")] auth_localstore: Option, + #[cfg(feature = "auth")] static_auth_token: Option, + payment_processors: HashMap, + keys_metadata: HashMap, ) -> Result { - #[cfg(feature = "auth")] - let oidc_client = - open_id_discovery.map(|openid_discovery| OidcClient::new(openid_discovery.clone())); - let keysets = signatory.keysets().await?; if !keysets .keysets @@ -168,23 +167,295 @@ impl Mint { .count() ); + // Persist missing pubkey early to avoid losing it on next boot and ensure stable identity across restarts + let mut computed_info = mint_info; + if computed_info.pubkey.is_none() { + computed_info.pubkey = Some(keysets.pubkey); + } + + match localstore + .kv_read( + CDK_MINT_PRIMARY_NAMESPACE, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_CONFIG_KV_KEY, + ) + .await? + { + Some(bytes) => { + let mut stored: MintInfo = serde_json::from_slice(&bytes)?; + let mut mutated = false; + if stored.pubkey.is_none() && computed_info.pubkey.is_some() { + stored.pubkey = computed_info.pubkey; + mutated = true; + } + if mutated { + let updated = serde_json::to_vec(&stored)?; + let mut tx = localstore.begin_transaction().await?; + tx.kv_write( + CDK_MINT_PRIMARY_NAMESPACE, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_CONFIG_KV_KEY, + &updated, + ) + .await?; + tx.commit().await?; + } + } + None => { + let bytes = serde_json::to_vec(&computed_info)?; + let mut tx = localstore.begin_transaction().await?; + tx.kv_write( + CDK_MINT_PRIMARY_NAMESPACE, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_CONFIG_KV_KEY, + &bytes, + ) + .await?; + tx.commit().await?; + } + } + + let payment_processors = Arc::new(payment_processors); + Ok(Self { signatory, - pubsub_manager: Arc::new(localstore.clone().into()), + pubsub_manager: PubSubManager::new((localstore.clone(), payment_processors.clone())), localstore, #[cfg(feature = "auth")] - oidc_client, - ln, + oidc_client: computed_info.nuts.nut21.as_ref().map(|nut21| { + OidcClient::new( + nut21.openid_discovery.clone(), + Some(nut21.client_id.clone()), + ) + }), + #[cfg(feature = "auth")] + static_auth_token, + payment_processors, #[cfg(feature = "auth")] auth_localstore, keysets: Arc::new(ArcSwap::new(keysets.keysets.into())), + task_state: Arc::new(Mutex::new(TaskState::default())), + keys_metadata: Arc::new(keys_metadata), + }) + } + + /// Start the mint's background services and operations + /// + /// This function immediately starts background services and returns. The background + /// tasks will continue running until `stop()` is called. + /// + /// # Returns + /// + /// Returns `Ok(())` if background services started successfully, or an `Error` + /// if startup failed. + /// + /// # Background Services + /// + /// Currently manages: + /// - Payment processor initialization and startup + /// - Invoice payment monitoring across all configured payment processors + pub async fn start(&self) -> Result<(), Error> { + // Recover from incomplete swap sagas + // This cleans up incomplete swap operations using persisted saga state + if let Err(e) = self.recover_from_incomplete_sagas().await { + tracing::error!("Failed to recover incomplete swap sagas: {}", e); + // Don't fail startup + } + + // Recover from incomplete melt sagas + // This cleans up incomplete melt operations using persisted saga state + // Now includes checking payment status with LN backend to determine + // whether to finalize (if paid) or compensate (if failed/unpaid) + if let Err(e) = self.recover_from_incomplete_melt_sagas().await { + tracing::error!("Failed to recover incomplete melt sagas: {}", e); + // Don't fail startup + } + + let mut task_state = self.task_state.lock().await; + + // Prevent starting if already running + if task_state.shutdown_notify.is_some() { + return Err(Error::Internal); // Already started + } + + // Start all payment processors first + tracing::info!("Starting payment processors..."); + let mut seen_processors = Vec::new(); + for (key, processor) in self.payment_processors.iter() { + // Skip if we've already spawned a task for this processor instance + if seen_processors.iter().any(|p| Arc::ptr_eq(p, processor)) { + continue; + } + + seen_processors.push(Arc::clone(processor)); + + tracing::info!("Starting payment wait task for {:?}", key); + + match processor.start().await { + Ok(()) => { + tracing::debug!("Successfully started payment processor for {:?}", key); + } + Err(e) => { + // Log the error but continue with other processors + tracing::error!("Failed to start payment processor for {:?}: {}", key, e); + return Err(e.into()); + } + } + } + + tracing::info!("Payment processor startup completed"); + + // Create shutdown signal + let shutdown_notify = Arc::new(Notify::new()); + + // Clone required components for the background task + let payment_processors = self.payment_processors.clone(); + let localstore = Arc::clone(&self.localstore); + let pubsub_manager = Arc::clone(&self.pubsub_manager); + let shutdown_clone = shutdown_notify.clone(); + + // Spawn the supervisor task + let supervisor_handle = tokio::spawn(async move { + Self::wait_for_paid_invoices( + &payment_processors, + localstore, + pubsub_manager, + shutdown_clone, + ) + .await + }); + + // Store the handles + task_state.shutdown_notify = Some(shutdown_notify); + task_state.supervisor_handle = Some(supervisor_handle); + + // Give the background task a tiny bit of time to start waiting + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + tracing::info!("Mint background services started"); + Ok(()) + } + + /// Stop all background services and wait for graceful shutdown + /// + /// This function signals all background tasks to shut down and waits for them + /// to complete gracefully. It's safe to call multiple times. + /// + /// # Returns + /// + /// Returns `Ok(())` when all background services have shut down cleanly, or an + /// `Error` if there was an issue during shutdown. + pub async fn stop(&self) -> Result<(), Error> { + let mut task_state = self.task_state.lock().await; + + // Take the handles out of the state + let shutdown_notify = task_state.shutdown_notify.take(); + let supervisor_handle = task_state.supervisor_handle.take(); + + // If nothing to stop, return early + let (shutdown_notify, supervisor_handle) = match (shutdown_notify, supervisor_handle) { + (Some(notify), Some(handle)) => (notify, handle), + _ => { + tracing::debug!("Stop called but no background services were running"); + // Still try to stop payment processors + return self.stop_payment_processors().await; + } + }; + + // Drop the lock before waiting + drop(task_state); + + tracing::info!("Stopping mint background services..."); + + // Signal shutdown + shutdown_notify.notify_waiters(); + + // Wait for supervisor to complete + let result = match supervisor_handle.await { + Ok(result) => { + tracing::info!("Mint background services stopped"); + result + } + Err(join_error) => { + tracing::error!("Background service task panicked: {:?}", join_error); + Err(Error::Internal) + } + }; + + // Stop all payment processors + self.stop_payment_processors().await?; + + result + } + + /// Stop all payment processors + async fn stop_payment_processors(&self) -> Result<(), Error> { + tracing::info!("Stopping payment processors..."); + let mut seen_processors = Vec::new(); + + for (key, processor) in self.payment_processors.iter() { + // Skip if we've already spawned a task for this processor instance + if seen_processors.iter().any(|p| Arc::ptr_eq(p, processor)) { + continue; + } + + seen_processors.push(Arc::clone(processor)); + + match processor.stop().await { + Ok(()) => { + tracing::debug!("Successfully stopped payment processor for {:?}", key); + } + Err(e) => { + // Log the error but continue with other processors + tracing::error!("Failed to stop payment processor for {:?}: {}", key, e); + } + } + } + tracing::info!("Payment processor shutdown completed"); + Ok(()) + } + + /// Get the payment processor for the given unit and payment method + pub fn get_payment_processor( + &self, + unit: CurrencyUnit, + payment_method: PaymentMethod, + ) -> Result { + let key = PaymentProcessorKey::new(unit.clone(), payment_method.clone()); + self.payment_processors.get(&key).cloned().ok_or_else(|| { + tracing::info!( + "No payment processor set for pair {}, {}", + unit, + payment_method + ); + Error::UnsupportedUnit }) } + /// Localstore + pub fn localstore(&self) -> DynMintDatabase { + Arc::clone(&self.localstore) + } + + /// Pub Sub manager + pub fn pubsub_manager(&self) -> Arc { + Arc::clone(&self.pubsub_manager) + } + /// Get mint info #[instrument(skip_all)] pub async fn mint_info(&self) -> Result { - let mint_info = self.localstore.get_mint_info().await?; + let mint_info = self + .localstore + .kv_read( + CDK_MINT_PRIMARY_NAMESPACE, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_CONFIG_KV_KEY, + ) + .await? + .ok_or(Error::CouldNotGetMintInfo)?; + + let mint_info: MintInfo = serde_json::from_slice(&mint_info)?; #[cfg(feature = "auth")] let mint_info = if let Some(auth_db) = self.auth_localstore.as_ref() { @@ -226,78 +497,298 @@ impl Mint { /// Set mint info #[instrument(skip_all)] pub async fn set_mint_info(&self, mint_info: MintInfo) -> Result<(), Error> { + tracing::info!("Updating mint info"); + let mint_info_bytes = serde_json::to_vec(&mint_info)?; let mut tx = self.localstore.begin_transaction().await?; - tx.set_mint_info(mint_info).await?; - Ok(tx.commit().await?) + tx.kv_write( + CDK_MINT_PRIMARY_NAMESPACE, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_CONFIG_KV_KEY, + &mint_info_bytes, + ) + .await?; + tx.commit().await?; + Ok(()) } /// Get quote ttl #[instrument(skip_all)] pub async fn quote_ttl(&self) -> Result { - Ok(self.localstore.get_quote_ttl().await?) + let quote_ttl_bytes = self + .localstore + .kv_read( + CDK_MINT_PRIMARY_NAMESPACE, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_QUOTE_TTL_KV_KEY, + ) + .await?; + + match quote_ttl_bytes { + Some(bytes) => { + let quote_ttl: QuoteTTL = serde_json::from_slice(&bytes)?; + Ok(quote_ttl) + } + None => { + // Return default if not found + Ok(QuoteTTL::default()) + } + } } /// Set quote ttl #[instrument(skip_all)] pub async fn set_quote_ttl(&self, quote_ttl: QuoteTTL) -> Result<(), Error> { + let quote_ttl_bytes = serde_json::to_vec("e_ttl)?; let mut tx = self.localstore.begin_transaction().await?; - tx.set_quote_ttl(quote_ttl).await?; - Ok(tx.commit().await?) + tx.kv_write( + CDK_MINT_PRIMARY_NAMESPACE, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_QUOTE_TTL_KV_KEY, + "e_ttl_bytes, + ) + .await?; + tx.commit().await?; + Ok(()) } - /// Wait for any invoice to be paid /// For each backend starts a task that waits for any invoice to be paid /// Once invoice is paid mint quote status is updated + /// Returns true if a QuoteTTL is persisted in the database. This is used to avoid overwriting + /// explicit configuration with defaults when the TTL has already been set by an operator. #[instrument(skip_all)] - pub async fn wait_for_paid_invoices(&self, shutdown: Arc) -> Result<(), Error> { - let mint_arc = Arc::new(self.clone()); + pub async fn quote_ttl_is_persisted(&self) -> Result { + let quote_ttl_bytes = self + .localstore + .kv_read( + CDK_MINT_PRIMARY_NAMESPACE, + CDK_MINT_CONFIG_SECONDARY_NAMESPACE, + CDK_MINT_QUOTE_TTL_KV_KEY, + ) + .await?; + + Ok(quote_ttl_bytes.is_some()) + } + #[instrument(skip_all)] + async fn wait_for_paid_invoices( + payment_processors: &HashMap, + localstore: DynMintDatabase, + pubsub_manager: Arc, + shutdown: Arc, + ) -> Result<(), Error> { let mut join_set = JoinSet::new(); - for (key, ln) in self.ln.iter() { - if !ln.is_wait_invoice_active() { - tracing::info!("Wait payment for {:?} inactive starting.", key); - let mint = Arc::clone(&mint_arc); - let ln = Arc::clone(ln); - let shutdown = Arc::clone(&shutdown); - let key = key.clone(); - join_set.spawn(async move { + // Group processors by unique instance (using Arc pointer equality) + let mut seen_processors = Vec::new(); + for (key, processor) in payment_processors { + // Skip if processor is already active + if processor.is_wait_invoice_active() { + continue; + } + + // Skip if we've already spawned a task for this processor instance + if seen_processors.iter().any(|p| Arc::ptr_eq(p, processor)) { + continue; + } + + seen_processors.push(Arc::clone(processor)); + + tracing::info!("Starting payment wait task for {:?}", key); + + // Clone for the spawned task + let processor = Arc::clone(processor); + let localstore = Arc::clone(&localstore); + let pubsub_manager = Arc::clone(&pubsub_manager); + let shutdown = Arc::clone(&shutdown); + + join_set.spawn(async move { + let result = Self::wait_for_processor_payments( + processor, + localstore, + pubsub_manager, + shutdown, + ) + .await; + + if let Err(e) = result { + tracing::error!("Payment processor task failed: {:?}", e); + } + }); + } + + // If no payment processors, just wait for shutdown + if join_set.is_empty() { + shutdown.notified().await; + } else { + // Wait for shutdown or all tasks to complete loop { - tracing::info!("Restarting wait for: {:?}", key); tokio::select! { _ = shutdown.notified() => { - tracing::info!("Shutdown signal received, stopping task for {:?}", key); - ln.cancel_wait_invoice(); + tracing::info!("Shutting down payment processors"); break; } - result = ln.wait_any_incoming_payment() => { - match result { - Ok(mut stream) => { - while let Some(request_lookup_id) = stream.next().await { - if let Err(err) = mint.pay_mint_quote_for_request_id(&request_lookup_id).await { - tracing::warn!("{:?}", err); + Some(result) = join_set.join_next() => { + if let Err(e) = result { + tracing::warn!("Task panicked: {:?}", e); + } + } + else => break, // All tasks completed + } + } + } + + join_set.shutdown().await; + Ok(()) + } + + /// Handles payment waiting for a single processor + #[instrument(skip_all)] + async fn wait_for_processor_payments( + processor: DynMintPayment, + localstore: DynMintDatabase, + pubsub_manager: Arc, + shutdown: Arc, + ) -> Result<(), Error> { + loop { + tokio::select! { + _ = shutdown.notified() => { + processor.cancel_wait_invoice(); + break; + } + result = processor.wait_payment_event() => { + match result { + Ok(mut stream) => { + while let Some(event) = stream.next().await { + match event { + cdk_common::payment::Event::PaymentReceived(wait_payment_response) => { + if let Err(e) = Self::handle_payment_notification( + &localstore, + &pubsub_manager, + wait_payment_response, + ).await { + tracing::warn!("Payment notification error: {:?}", e); + } } } } - Err(err) => { - tracing::warn!("Could not get incoming payment stream for {:?}: {}",key, err); - - tokio::time::sleep(std::time::Duration::from_secs(5)).await; - } + } + Err(e) => { + tracing::warn!("Failed to get payment stream: {}", e); + tokio::time::sleep(Duration::from_secs(5)).await; } } - } - } - }); + } } } + Ok(()) + } - // Spawn a task to manage the JoinSet - while let Some(result) = join_set.join_next().await { - match result { - Ok(_) => tracing::info!("A task completed successfully."), - Err(err) => tracing::warn!("A task failed: {:?}", err), + /// Handle payment notification without needing full Mint instance + /// This is a helper function that can be called with just the required components + #[instrument(skip_all)] + async fn handle_payment_notification( + localstore: &DynMintDatabase, + pubsub_manager: &Arc, + wait_payment_response: WaitPaymentResponse, + ) -> Result<(), Error> { + if wait_payment_response.payment_amount == Amount::ZERO { + tracing::warn!( + "Received payment response with 0 amount with payment id {}.", + wait_payment_response.payment_id + ); + return Err(Error::AmountUndefined); + } + + let mut tx = localstore.begin_transaction().await?; + + if let Ok(Some(mint_quote)) = tx + .get_mint_quote_by_request_lookup_id(&wait_payment_response.payment_identifier) + .await + { + Self::handle_mint_quote_payment( + &mut tx, + &mint_quote, + wait_payment_response, + pubsub_manager, + ) + .await?; + } else { + tracing::warn!( + "Could not get request for request lookup id {:?}", + wait_payment_response.payment_identifier + ); + } + + tx.commit().await?; + Ok(()) + } + + /// Handle payment for a specific mint quote (extracted from pay_mint_quote) + #[instrument(skip_all)] + async fn handle_mint_quote_payment( + tx: &mut Box + Send + Sync + '_>, + mint_quote: &MintQuote, + wait_payment_response: WaitPaymentResponse, + pubsub_manager: &Arc, + ) -> Result<(), Error> { + tracing::debug!( + "Received payment notification of {} {} for mint quote {} with payment id {}", + wait_payment_response.payment_amount, + wait_payment_response.unit, + mint_quote.id, + wait_payment_response.payment_id.to_string() + ); + + let quote_state = mint_quote.state(); + if !mint_quote + .payment_ids() + .contains(&&wait_payment_response.payment_id) + { + if mint_quote.payment_method == PaymentMethod::Bolt11 + && (quote_state == MintQuoteState::Issued || quote_state == MintQuoteState::Paid) + { + tracing::info!("Received payment notification for already issued quote."); + } else { + let payment_amount_quote_unit = to_unit( + wait_payment_response.payment_amount, + &wait_payment_response.unit, + &mint_quote.unit, + )?; + + if payment_amount_quote_unit == Amount::ZERO { + tracing::error!("Zero amount payments should not be recorded."); + return Err(Error::AmountUndefined); + } + + tracing::debug!( + "Payment received amount in quote unit {} {}", + mint_quote.unit, + payment_amount_quote_unit + ); + + match tx + .increment_mint_quote_amount_paid( + &mint_quote.id, + payment_amount_quote_unit, + wait_payment_response.payment_id.clone(), + ) + .await + { + Ok(total_paid) => { + pubsub_manager.mint_quote_payment(mint_quote, total_paid); + } + Err(database::Error::Duplicate) => { + tracing::info!( + "Payment ID {} already processed (caught race condition)", + wait_payment_response.payment_id + ); + // This is fine - another concurrent request already processed this payment + } + Err(e) => return Err(e.into()), + } } + } else { + tracing::info!("Received payment notification for already seen payment."); } Ok(()) @@ -364,181 +855,151 @@ impl Mint { #[tracing::instrument(skip_all)] pub async fn blind_sign( &self, - blinded_message: BlindedMessage, - ) -> Result { - self.signatory - .blind_sign(vec![blinded_message]) - .await? - .pop() - .ok_or(Error::Internal) - } - - /// Verify [`Proof`] meets conditions and is signed - #[tracing::instrument(skip_all)] - pub async fn verify_proofs(&self, proofs: Proofs) -> Result<(), Error> { - proofs - .iter() - .map(|proof| { - // Check if secret is a nut10 secret with conditions - if let Ok(secret) = - <&secret::Secret as TryInto>::try_into(&proof.secret) - { - // Checks and verifies known secret kinds. - // If it is an unknown secret kind it will be treated as a normal secret. - // Spending conditions will **not** be check. It is up to the wallet to ensure - // only supported secret kinds are used as there is no way for the mint to - // enforce only signing supported secrets as they are blinded at - // that point. - match secret.kind() { - Kind::P2PK => { - proof.verify_p2pk()?; - } - Kind::HTLC => { - proof.verify_htlc()?; - } - } - } - Ok(()) - }) - .collect::, Error>>()?; - - self.signatory.verify_proofs(proofs).await - } - - /// Verify melt request is valid - /// Check to see if there is a corresponding mint quote for a melt. - /// In this case the mint can settle the payment internally and no ln payment is - /// needed - #[instrument(skip_all)] - pub async fn handle_internal_melt_mint( - &self, - tx: &mut Box + Send + Sync + '_>, - melt_quote: &MeltQuote, - melt_request: &MeltRequest, - ) -> Result, Error> { - let mint_quote = match tx.get_mint_quote_by_request(&melt_quote.request).await { - Ok(Some(mint_quote)) => mint_quote, - // Not an internal melt -> mint - Ok(None) => return Ok(None), - Err(err) => { - tracing::debug!("Error attempting to get mint quote: {}", err); - return Err(Error::Internal); + blinded_message: Vec, + ) -> Result, Error> { + #[cfg(test)] + { + if crate::test_helpers::mint::should_fail_in_test() { + return Err(Error::SignatureMissingOrInvalid); } - }; - tracing::error!("internal stuff"); - - // Mint quote has already been settled, proofs should not be burned or held. - if mint_quote.state == MintQuoteState::Issued || mint_quote.state == MintQuoteState::Paid { - return Err(Error::RequestAlreadyPaid); } - let inputs_amount_quote_unit = melt_request.proofs_amount().map_err(|_| { - tracing::error!("Proof inputs in melt quote overflowed"); - Error::AmountOverflow - })?; + #[cfg(feature = "prometheus")] + global::inc_in_flight_requests("blind_sign"); - let mut mint_quote = mint_quote; + let result = self.signatory.blind_sign(blinded_message).await; - if mint_quote.amount > inputs_amount_quote_unit { - tracing::debug!( - "Not enough inuts provided: {} needed {}", - inputs_amount_quote_unit, - mint_quote.amount - ); - return Err(Error::InsufficientFunds); + #[cfg(feature = "prometheus")] + { + global::dec_in_flight_requests("blind_sign"); + global::record_mint_operation("blind_sign", result.is_ok()); } - mint_quote.state = MintQuoteState::Paid; + result + } - let amount = melt_quote.amount; + /// Verify [`Proof`] meets conditions and is signed + #[tracing::instrument(skip_all)] + pub async fn verify_proofs(&self, proofs: Proofs) -> Result<(), Error> { + // This ignore P2PK and HTLC, as all NUT-10 spending conditions are + // checked elsewhere. + #[cfg(feature = "prometheus")] + global::inc_in_flight_requests("verify_proofs"); - tx.add_or_replace_mint_quote(mint_quote).await?; + let result = self.signatory.verify_proofs(proofs).await; - Ok(Some(amount)) + #[cfg(feature = "prometheus")] + { + global::dec_in_flight_requests("verify_proofs"); + global::record_mint_operation("verify_proofs", result.is_ok()); + } + + result } /// Restore #[instrument(skip_all)] pub async fn restore(&self, request: RestoreRequest) -> Result { - let output_len = request.outputs.len(); + #[cfg(feature = "prometheus")] + global::inc_in_flight_requests("restore"); - let mut outputs = Vec::with_capacity(output_len); - let mut signatures = Vec::with_capacity(output_len); + let result = async { + let output_len = request.outputs.len(); - let blinded_message: Vec = - request.outputs.iter().map(|b| b.blinded_secret).collect(); + let mut outputs = Vec::with_capacity(output_len); + let mut signatures = Vec::with_capacity(output_len); - let blinded_signatures = self - .localstore - .get_blind_signatures(&blinded_message) - .await?; + let blinded_message: Vec = + request.outputs.iter().map(|b| b.blinded_secret).collect(); - assert_eq!(blinded_signatures.len(), output_len); + let blinded_signatures = self + .localstore + .get_blind_signatures(&blinded_message) + .await?; - for (blinded_message, blinded_signature) in - request.outputs.into_iter().zip(blinded_signatures) - { - if let Some(blinded_signature) = blinded_signature { - outputs.push(blinded_message); - signatures.push(blinded_signature); + assert_eq!(blinded_signatures.len(), output_len); + + for (blinded_message, blinded_signature) in + request.outputs.into_iter().zip(blinded_signatures) + { + if let Some(blinded_signature) = blinded_signature { + outputs.push(blinded_message); + signatures.push(blinded_signature); + } } + + Ok(RestoreResponse { + outputs, + signatures: signatures.clone(), + promises: Some(signatures), + }) } + .await; - Ok(RestoreResponse { - outputs, - signatures: signatures.clone(), - promises: Some(signatures), - }) + #[cfg(feature = "prometheus")] + { + global::dec_in_flight_requests("restore"); + global::record_mint_operation("restore", result.is_ok()); + } + + result } /// Get the total amount issed by keyset #[instrument(skip_all)] pub async fn total_issued(&self) -> Result, Error> { - let keysets = self.keysets().keysets; - - let mut total_issued = HashMap::new(); + #[cfg(feature = "prometheus")] + global::inc_in_flight_requests("total_issued"); - for keyset in keysets { - let blinded = self - .localstore - .get_blind_signatures_for_keyset(&keyset.id) - .await?; - - let total = Amount::try_sum(blinded.iter().map(|b| b.amount))?; + let result = async { + let mut total_issued = self.localstore.get_total_issued().await?; + for keyset in self.keysets().keysets { + total_issued.entry(keyset.id).or_default(); + } + Ok(total_issued) + } + .await; - total_issued.insert(keyset.id, total); + #[cfg(feature = "prometheus")] + { + global::dec_in_flight_requests("total_issued"); + global::record_mint_operation("total_issued", result.is_ok()); } - Ok(total_issued) + result } /// Total redeemed for keyset #[instrument(skip_all)] pub async fn total_redeemed(&self) -> Result, Error> { - let keysets = self.keysets().keysets; + #[cfg(feature = "prometheus")] + global::inc_in_flight_requests("total_redeemed"); - let mut total_redeemed = HashMap::new(); - - for keyset in keysets { - let (proofs, state) = self.localstore.get_proofs_by_keyset_id(&keyset.id).await?; + let total_redeemed = async { + let mut total_redeemed = self.localstore.get_total_redeemed().await?; + for keyset in self.keysets().keysets { + total_redeemed.entry(keyset.id).or_default(); + } + Ok(total_redeemed) + } + .await; - let total_spent = - Amount::try_sum(proofs.iter().zip(state).filter_map(|(p, s)| { - match s == Some(State::Spent) { - true => Some(p.amount), - false => None, - } - }))?; + #[cfg(feature = "prometheus")] + global::dec_in_flight_requests("total_redeemed"); - total_redeemed.insert(keyset.id, total_spent); - } + total_redeemed + } - Ok(total_redeemed) + /// Get unit metadata + pub fn get_unit_metadata(&self, unit: CurrencyUnit) -> Option { + self.keys_metadata.get(&unit).cloned() } + } #[cfg(test)] mod tests { + use std::str::FromStr; use cdk_sqlite::mint::memory::new_with_state; @@ -584,7 +1045,7 @@ mod tests { .expect("Failed to create signatory"), ); - Mint::new(signatory, localstore, HashMap::new()) + Mint::new(MintInfo::default(), signatory, localstore, HashMap::new(), HashMap::new()) .await .unwrap() } @@ -633,7 +1094,7 @@ mod tests { let first_keyset_id = keysets.keysets[0].id; // set the first keyset to inactive and generate a new keyset - mint.rotate_keyset(CurrencyUnit::default(), 1, 1) + mint.rotate_keyset(CurrencyUnit::default(), vec![1], 1) .await .expect("test"); @@ -671,4 +1132,31 @@ mod tests { assert_eq!(expected_keys, serde_json::to_string(&keys.clone()).unwrap()); } + + #[tokio::test] + async fn test_start_stop_lifecycle() { + let mut supported_units = HashMap::new(); + supported_units.insert(CurrencyUnit::default(), (0, 32)); + let config = MintConfig::<'_> { + supported_units, + ..Default::default() + }; + let mint = create_mint(config).await; + + // Start should succeed (async) + mint.start().await.expect("Failed to start mint"); + + // Starting again should fail (already running) + assert!(mint.start().await.is_err()); + + // Stop should succeed (still async) + mint.stop().await.expect("Failed to stop mint"); + + // Stopping again should succeed (idempotent) + mint.stop().await.expect("Second stop should be fine"); + + // Should be able to start again after stopping + mint.start().await.expect("Should be able to restart"); + mint.stop().await.expect("Final stop should work"); + } } diff --git a/crates/cdk/src/mint/proof_writer.rs b/crates/cdk/src/mint/proof_writer.rs deleted file mode 100644 index a9919e91f..000000000 --- a/crates/cdk/src/mint/proof_writer.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! Proof writer -use std::collections::{HashMap, HashSet}; -use std::sync::Arc; - -use cdk_common::database::{self, MintDatabase, MintTransaction}; -use cdk_common::{Error, Proofs, ProofsMethods, PublicKey, State}; - -use super::subscription::PubSubManager; - -type Db = Arc + Send + Sync>; -type Tx<'a, 'b> = Box + Send + Sync + 'b>; - -/// Proof writer -/// -/// This is a proof 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 proofs or reset their -/// status. -/// -/// This struct is not fully ACID. If the process exits due to a panic, and the `Drop` function -/// cannot be run, the reset process should reset the state. -pub struct ProofWriter { - db: Option, - pubsub_manager: Arc, - proof_original_states: Option>>, -} - -impl ProofWriter { - /// Creates a new ProofWriter on top of the database - pub fn new(db: Db, pubsub_manager: Arc) -> Self { - Self { - db: Some(db), - pubsub_manager, - proof_original_states: 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.proof_original_states.take(); - } - - /// Add proofs - pub async fn add_proofs( - &mut self, - tx: &mut Tx<'_, '_>, - proofs: &Proofs, - ) -> Result, Error> { - let proof_states = if let Some(proofs) = self.proof_original_states.as_mut() { - proofs - } else { - return Err(Error::Internal); - }; - - if let Some(err) = tx.add_proofs(proofs.clone(), None).await.err() { - return match err { - cdk_common::database::Error::Duplicate => Err(Error::TokenPending), - cdk_common::database::Error::AttemptUpdateSpentProof => { - Err(Error::TokenAlreadySpent) - } - err => Err(Error::Database(err)), - }; - } - - let ys = proofs.ys()?; - - for pk in ys.iter() { - proof_states.insert(*pk, None); - } - - self.update_proofs_states(tx, &ys, State::Pending).await?; - - Ok(ys) - } - - /// Update proof status - pub async fn update_proofs_states( - &mut self, - tx: &mut Tx<'_, '_>, - ys: &[PublicKey], - new_proof_state: State, - ) -> Result<(), Error> { - let proof_states = if let Some(proofs) = self.proof_original_states.as_mut() { - proofs - } else { - return Err(Error::Internal); - }; - - let original_proofs_state = match tx.update_proofs_states(ys, new_proof_state).await { - Ok(states) => states, - Err(database::Error::AttemptUpdateSpentProof) - | Err(database::Error::AttemptRemoveSpentProof) => { - return Err(Error::TokenAlreadySpent) - } - Err(err) => return Err(err.into()), - }; - - if ys.len() != original_proofs_state.len() { - return Err(Error::Internal); - } - - let proofs_state = original_proofs_state - .iter() - .flatten() - .map(|x| x.to_owned()) - .collect::>(); - - let forbidden_states = if new_proof_state == State::Pending { - // If the new state is `State::Pending` it cannot be pending already - vec![State::Pending, State::Spent] - } else { - // For other state it cannot be spent - vec![State::Spent] - }; - - for forbidden_state in forbidden_states.iter() { - if proofs_state.contains(forbidden_state) { - reset_proofs_to_original_state(tx, ys, original_proofs_state).await?; - - return Err(if proofs_state.contains(&State::Pending) { - Error::TokenPending - } else { - Error::TokenAlreadySpent - }); - } - } - - for (idx, ys) in ys.iter().enumerate() { - proof_states - .entry(*ys) - .or_insert(original_proofs_state[idx]); - } - - for pk in ys { - self.pubsub_manager.proof_state((*pk, new_proof_state)); - } - - Ok(()) - } - - /// Rollback all changes in this ProofWriter consuming it. - pub async fn rollback(mut self, tx: &mut Tx<'_, '_>) -> Result<(), Error> { - let (ys, original_states) = if let Some(proofs) = self.proof_original_states.take() { - proofs.into_iter().unzip::<_, _, Vec<_>, Vec<_>>() - } else { - return Ok(()); - }; - reset_proofs_to_original_state(tx, &ys, original_states).await?; - Ok(()) - } -} - -/// Resets proofs to their original states or removes them -#[inline(always)] -async fn reset_proofs_to_original_state( - tx: &mut Tx<'_, '_>, - ys: &[PublicKey], - original_states: Vec>, -) -> Result<(), Error> { - let mut ys_by_state = HashMap::new(); - let mut unknown_proofs = Vec::new(); - for (y, state) in ys.iter().zip(original_states) { - if let Some(state) = state { - // Skip attempting to update proofs that were originally spent - if state != State::Spent { - ys_by_state.entry(state).or_insert_with(Vec::new).push(*y); - } - } else { - unknown_proofs.push(*y); - } - } - - for (state, ys) in ys_by_state { - tx.update_proofs_states(&ys, state).await?; - } - - tx.remove_proofs(&unknown_proofs, None).await?; - - Ok(()) -} - -#[inline(always)] -async fn rollback( - db: Arc + Send + Sync>, - ys: Vec, - original_states: Vec>, -) -> Result<(), Error> { - let mut tx = db.begin_transaction().await?; - reset_proofs_to_original_state(&mut tx, &ys, original_states).await?; - tx.commit().await?; - - Ok(()) -} - -impl Drop for ProofWriter { - fn drop(&mut self) { - let db = if let Some(db) = self.db.take() { - db - } else { - return; - }; - let (ys, states) = if let Some(proofs) = self.proof_original_states.take() { - proofs.into_iter().unzip() - } else { - return; - }; - - tokio::spawn(rollback(db, ys, states)); - } -} diff --git a/crates/cdk/src/mint/start_up_check.rs b/crates/cdk/src/mint/start_up_check.rs index 4fe08bc0f..d6cd843b6 100644 --- a/crates/cdk/src/mint/start_up_check.rs +++ b/crates/cdk/src/mint/start_up_check.rs @@ -3,96 +3,605 @@ //! 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 std::str::FromStr; + +use cdk_common::mint::OperationKind; +use cdk_common::QuoteId; + use super::{Error, Mint}; -use crate::mint::{MeltQuote, MeltQuoteState, PaymentMethod}; +use crate::mint::swap::swap_saga::compensation::{CompensatingAction, RemoveSwapSetup}; +use crate::mint::{MeltQuote, MeltQuoteState}; use crate::types::PaymentProcessorKey; impl Mint { - /// Check the status of all pending and unpaid mint quotes in the mint db - /// with all the lighting backends. This check that any payments - /// received while the mint was offline are accounted for, and the wallet can mint associated ecash - pub async fn check_pending_mint_quotes(&self) -> Result<(), Error> { - let pending_quotes = self.get_pending_mint_quotes().await?; - let unpaid_quotes = self.get_unpaid_mint_quotes().await?; + /// Checks the payment status of a melt quote with the LN backend + /// + /// This is a helper function used by saga recovery to determine whether to + /// finalize or compensate an incomplete melt operation. + /// + /// # Returns + /// + /// - `Ok(MakePaymentResponse)`: Payment status successfully retrieved from backend + /// - `Err(Error)`: Failed to check payment status (backend unavailable, no lookup_id, etc.) + async fn check_melt_payment_status( + &self, + quote: &MeltQuote, + ) -> Result { + let ln_key = PaymentProcessorKey { + unit: quote.unit.clone(), + method: quote.payment_method.clone(), + }; + + let ln_backend = self.payment_processors.get(&ln_key).ok_or_else(|| { + tracing::warn!("No backend for ln key: {:?}", ln_key); + Error::UnsupportedUnit + })?; + + let lookup_id = quote.request_lookup_id.as_ref().ok_or_else(|| { + tracing::warn!( + "No lookup_id for melt quote {}, cannot check payment status", + quote.id + ); + Error::Internal + })?; - let all_quotes = [pending_quotes, unpaid_quotes].concat(); + // Check payment status with LN backend + let pay_invoice_response = + ln_backend + .check_outgoing_payment(lookup_id) + .await + .map_err(|err| { + tracing::error!( + "Failed to check payment status for quote {}: {}", + quote.id, + err + ); + Error::Internal + })?; tracing::info!( - "There are {} pending and unpaid mint quotes.", - all_quotes.len() + "Payment status for melt quote {}: {}", + quote.id, + pay_invoice_response.status ); - for mut quote in all_quotes.into_iter() { - tracing::debug!("Checking status of mint quote: {}", quote.id); - match self - .check_mint_quote_paid(self.localstore.begin_transaction().await?, &mut quote) - .await - { - Ok(tx) => tx.commit().await?, - Err(err) => tracing::error!("Could not check status of {}, {}", quote.id, err), + + Ok(pay_invoice_response) + } + + /// Finalizes a paid melt quote during startup check + /// + /// Uses shared finalization logic from melt::shared module + async fn finalize_paid_melt_quote( + &self, + quote: &MeltQuote, + total_spent: cdk_common::Amount, + payment_preimage: Option, + payment_lookup_id: &cdk_common::payment::PaymentIdentifier, + ) -> Result<(), Error> { + tracing::info!("Finalizing paid melt quote {} during startup", quote.id); + + // Use shared finalization + super::melt::shared::finalize_melt_quote( + self, + &self.localstore, + &self.pubsub_manager, + quote, + total_spent, + payment_preimage, + payment_lookup_id, + ) + .await?; + + tracing::info!( + "Successfully finalized melt quote {} during startup check", + quote.id + ); + + Ok(()) + } + + /// 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(()) } - /// Checks the states of melt quotes that are **PENDING** or **UNKNOWN** to the mint with the ln node - pub async fn check_pending_melt_quotes(&self) -> Result<(), Error> { - let melt_quotes = self.localstore.get_melt_quotes().await?; - let pending_quotes: Vec = melt_quotes - .into_iter() - .filter(|q| q.state == MeltQuoteState::Pending || q.state == MeltQuoteState::Unknown) - .collect(); - tracing::info!("There are {} pending melt quotes.", pending_quotes.len()); + /// Recover from incomplete melt sagas + /// + /// Checks all persisted sagas for melt operations and determines whether to: + /// - **Finalize**: If payment was confirmed as PAID on LN backend + /// - **Compensate**: If payment was confirmed as UNPAID/FAILED or never sent + /// - **Skip**: If payment is PENDING/UNKNOWN (leave for check_pending_melt_quotes) + /// + /// This recovery handles SetupComplete state which means: + /// - Proofs were reserved (marked as PENDING) + /// - Change outputs were added + /// - Payment may or may not have been sent + /// + /// # Critical Bug Fix + /// + /// Previously, this function always compensated (rolled back) incomplete sagas without + /// checking if the payment actually succeeded on the LN backend. This could cause the + /// mint to lose funds if: + /// 1. Payment succeeded on LN backend + /// 2. Mint crashed before finalize() committed + /// 3. Recovery compensated (returned proofs) instead of finalizing + /// + /// Now we check the LN backend payment status before deciding whether to compensate or finalize. + pub async fn recover_from_incomplete_melt_sagas(&self) -> Result<(), Error> { + let incomplete_sagas = self + .localstore + .get_incomplete_sagas(OperationKind::Melt) + .await?; - let mut tx = self.localstore.begin_transaction().await?; + if incomplete_sagas.is_empty() { + tracing::info!("No incomplete melt sagas found to recover."); + return Ok(()); + } - for pending_quote in pending_quotes { - tracing::debug!("Checking status for melt quote {}.", pending_quote.id); + let total_sagas = incomplete_sagas.len(); + tracing::info!("Found {} incomplete melt sagas to recover.", total_sagas); - let ln_key = PaymentProcessorKey { - unit: pending_quote.unit, - method: PaymentMethod::Bolt11, - }; + for saga in incomplete_sagas { + tracing::info!( + "Recovering melt saga {} in state '{}' (created: {}, updated: {})", + saga.operation_id, + saga.state.state(), + saga.created_at, + saga.updated_at + ); - let ln_backend = match self.ln.get(&ln_key) { - Some(ln_backend) => ln_backend, + // Get quote_id from saga (new field added for efficient lookup) + let quote_id = match saga.quote_id { + Some(ref qid) => qid.clone(), None => { - tracing::warn!("No backend for ln key: {:?}", ln_key); + tracing::warn!( + "Saga {} has no quote_id (old saga format) - attempting fallback lookup", + saga.operation_id + ); + + // Fallback: Find quote by matching input_ys (for backward compatibility) + let melt_quotes = match self.localstore.get_melt_quotes().await { + Ok(quotes) => quotes, + Err(e) => { + tracing::error!( + "Failed to get melt quotes for saga {}: {}", + saga.operation_id, + e + ); + continue; + } + }; + + let mut quote_id_found = None; + for quote in melt_quotes { + let tx = self.localstore.begin_transaction().await?; + let proof_ys = tx.get_proof_ys_by_quote_id("e.id).await?; + tx.rollback().await?; + + if !saga.input_ys.is_empty() + && !proof_ys.is_empty() + && saga.input_ys.iter().any(|y| proof_ys.contains(y)) + { + quote_id_found = Some(quote.id.clone()); + break; + } + } + + match quote_id_found { + Some(qid) => qid.to_string(), + None => { + tracing::warn!( + "Could not find quote_id for saga {} - may have been cleaned up already. Deleting orphaned saga.", + saga.operation_id + ); + + let mut delete_tx = self.localstore.begin_transaction().await?; + if let Err(e) = delete_tx.delete_saga(&saga.operation_id).await { + tracing::error!( + "Failed to delete orphaned saga {}: {}", + saga.operation_id, + e + ); + delete_tx.rollback().await?; + } else { + delete_tx.commit().await?; + } + continue; + } + } + } + }; + + // Get the quote from database + let quote_id_parsed = match QuoteId::from_str("e_id) { + Ok(id) => id, + Err(e) => { + tracing::error!( + "Failed to parse quote_id '{}' for saga {}: {:?}. Skipping saga.", + quote_id, + saga.operation_id, + e + ); continue; } }; - let pay_invoice_response = ln_backend - .check_outgoing_payment(&pending_quote.request_lookup_id) - .await?; + let quote = match self.localstore.get_melt_quote("e_id_parsed).await { + Ok(Some(q)) => q, + Ok(None) => { + tracing::warn!( + "Quote {} for saga {} not found - may have been cleaned up. Deleting orphaned saga.", + quote_id, + saga.operation_id + ); - tracing::warn!( - "There is no stored melt request for pending melt quote: {}", - pending_quote.id - ); + let mut delete_tx = self.localstore.begin_transaction().await?; + if let Err(e) = delete_tx.delete_saga(&saga.operation_id).await { + tracing::error!( + "Failed to delete orphaned saga {}: {}", + saga.operation_id, + e + ); + delete_tx.rollback().await?; + } else { + delete_tx.commit().await?; + } + continue; + } + Err(e) => { + tracing::error!( + "Failed to get quote {} for saga {}: {}. Skipping saga.", + quote_id, + saga.operation_id, + e + ); + continue; + } + }; + + // Check saga state to determine if payment was attempted + // SetupComplete means setup transaction committed but payment NOT yet attempted + // PaymentAttempted means payment was attempted - must check LN backend + let should_compensate = match &saga.state { + cdk_common::mint::SagaStateEnum::Melt(state) => { + match state { + cdk_common::mint::MeltSagaState::SetupComplete => { + // Setup complete but payment never attempted - always compensate + tracing::info!( + "Saga {} in SetupComplete state - payment never attempted, will compensate", + saga.operation_id + ); + true + } + cdk_common::mint::MeltSagaState::PaymentAttempted => { + // Payment was attempted - check for internal settlement first, then LN backend + tracing::info!( + "Saga {} in PaymentAttempted state - checking for internal or external payment", + saga.operation_id + ); + + // Check if this was an internal settlement by looking for a mint quote + // that was paid by this melt quote + let is_internal_settlement = match self + .localstore + .get_mint_quote_by_request("e.request.to_string()) + .await + { + Ok(Some(mint_quote)) => { + // Check if this mint quote was paid by our melt quote + let melt_quote_id_str = quote.id.to_string(); + mint_quote.payment_ids().contains(&&melt_quote_id_str) + } + Ok(None) => false, + Err(e) => { + tracing::warn!( + "Error checking for internal settlement for saga {}: {}", + saga.operation_id, + e + ); + false + } + }; + + if is_internal_settlement { + // Internal settlement was completed - finalize directly + tracing::info!( + "Saga {} was internal settlement - will finalize directly", + saga.operation_id + ); + + // Get payment info for finalization + let total_spent = quote.amount; + let payment_lookup_id = + quote.request_lookup_id.clone().unwrap_or_else(|| { + cdk_common::payment::PaymentIdentifier::CustomId( + quote.id.to_string(), + ) + }); - let melt_quote_state = match pay_invoice_response.status { - MeltQuoteState::Unpaid => MeltQuoteState::Unpaid, - MeltQuoteState::Paid => MeltQuoteState::Paid, - MeltQuoteState::Pending => MeltQuoteState::Pending, - MeltQuoteState::Failed => MeltQuoteState::Unpaid, - MeltQuoteState::Unknown => MeltQuoteState::Unpaid, + if let Err(err) = self + .finalize_paid_melt_quote( + "e, + total_spent, + None, // No preimage for internal settlement + &payment_lookup_id, + ) + .await + { + tracing::error!( + "Failed to finalize internal settlement saga {}: {}", + saga.operation_id, + err + ); + } + + // Delete saga after successful finalization + 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?; + } else { + tx.commit().await?; + tracing::info!( + "Successfully recovered and finalized internal settlement saga {}", + saga.operation_id + ); + } + + continue; // Skip to next saga + } + + false // Will check LN payment status below + } + } + } + _ => { + continue; // Skip non-melt sagas + } }; - if let Err(err) = tx - .update_melt_quote_state(&pending_quote.id, melt_quote_state) - .await - { - tracing::error!( - "Could not update quote {} to state {}, current state {}, {}", - pending_quote.id, - melt_quote_state, - pending_quote.state, - err + let should_compensate = if should_compensate { + true + } else if quote.request_lookup_id.is_none() { + // Fallback: No request_lookup_id means payment likely never sent + tracing::info!( + "Saga {} for quote {} has no request_lookup_id - payment never sent, will compensate", + saga.operation_id, + quote_id + ); + true + } else { + // Payment was attempted - check LN backend status + tracing::info!( + "Saga {} for quote {} has request_lookup_id - checking payment status with LN backend", + saga.operation_id, + quote_id ); + + match self.check_melt_payment_status("e).await { + Ok(payment_response) => { + match payment_response.status { + MeltQuoteState::Paid => { + // Payment succeeded - finalize instead of compensating + tracing::info!( + "Saga {} for quote {} - payment PAID on LN backend, will finalize", + saga.operation_id, + quote_id + ); + + if let Err(err) = self + .finalize_paid_melt_quote( + "e, + payment_response.total_spent, + payment_response.payment_proof, + &payment_response.payment_lookup_id, + ) + .await + { + tracing::error!( + "Failed to finalize paid melt saga {}: {}", + saga.operation_id, + err + ); + } + + // Delete saga after successful finalization + 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?; + } else { + tx.commit().await?; + tracing::info!( + "Successfully recovered and finalized melt saga {}", + saga.operation_id + ); + } + + continue; // Skip compensation, saga handled + } + MeltQuoteState::Unpaid | MeltQuoteState::Failed => { + // Payment failed - compensate + tracing::info!( + "Saga {} for quote {} - payment {} on LN backend, will compensate", + saga.operation_id, + quote_id, + payment_response.status + ); + true + } + MeltQuoteState::Pending | MeltQuoteState::Unknown => { + // Payment still pending - skip for check_pending_melt_quotes + tracing::info!( + "Saga {} for quote {} - payment {} on LN backend, skipping (will be handled by check_pending_melt_quotes)", + saga.operation_id, + quote_id, + payment_response.status + ); + continue; // Skip this saga, don't compensate or finalize + } + } + } + Err(err) => { + // LN backend unavailable - skip this saga, will retry on next recovery cycle + tracing::warn!( + "Failed to check payment status for saga {} quote {}: {}. Skipping for now, will retry on next recovery cycle.", + saga.operation_id, + quote_id, + err + ); + continue; // Skip this saga + } + } }; + + // Compensate if needed + if should_compensate { + // Use saga data directly for compensation (like swap does) + tracing::info!( + "Compensating melt saga {} (removing {} proofs, {} change outputs)", + saga.operation_id, + saga.input_ys.len(), + saga.blinded_secrets.len() + ); + + // Compensate using saga data only - don't rely on quote state + let mut tx = self.localstore.begin_transaction().await?; + + // Remove blinded messages (change outputs) + if !saga.blinded_secrets.is_empty() { + if let Err(e) = tx.delete_blinded_messages(&saga.blinded_secrets).await { + tracing::error!( + "Failed to delete blinded messages for saga {}: {}", + saga.operation_id, + e + ); + tx.rollback().await?; + continue; + } + } + + // Remove proofs (inputs) - use None for quote_id like swap does + if !saga.input_ys.is_empty() { + if let Err(e) = tx.remove_proofs(&saga.input_ys, None).await { + tracing::error!( + "Failed to remove proofs for saga {}: {}", + saga.operation_id, + e + ); + tx.rollback().await?; + continue; + } + } + + // Reset quote state to Unpaid (melt-specific, unlike swap) + if let Err(e) = tx + .update_melt_quote_state("e_id_parsed, MeltQuoteState::Unpaid, None) + .await + { + tracing::error!( + "Failed to reset quote state for saga {}: {}", + saga.operation_id, + e + ); + tx.rollback().await?; + continue; + } + + // Delete melt request tracking record + if let Err(e) = tx.delete_melt_request("e_id_parsed).await { + tracing::error!( + "Failed to delete melt request for saga {}: {}", + saga.operation_id, + e + ); + // Don't fail if melt request doesn't exist - it might not have been created yet + } + + // Delete saga after successful compensation + 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 and compensated melt saga {}", + saga.operation_id + ); + } } - tx.commit().await?; + tracing::info!( + "Successfully recovered {} incomplete melt sagas.", + total_sagas + ); Ok(()) } diff --git a/crates/cdk/src/mint/subscription.rs b/crates/cdk/src/mint/subscription.rs new file mode 100644 index 000000000..ada422cb9 --- /dev/null +++ b/crates/cdk/src/mint/subscription.rs @@ -0,0 +1,282 @@ +//! Specific Subscription for the cdk crate + +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::Arc; + +use cdk_common::common::PaymentProcessorKey; +use cdk_common::database::DynMintDatabase; +use cdk_common::mint::MintQuote; +use cdk_common::nut17::NotificationId; +use cdk_common::payment::DynMintPayment; +use cdk_common::pub_sub::{Pubsub, Spec, Subscriber}; +use cdk_common::subscription::SubId; +use cdk_common::{ + Amount, BlindSignature, MeltQuoteBolt11Response, MeltQuoteState, MintQuoteBolt11Response, + MintQuoteBolt12Response, MintQuoteState, PaymentMethod, ProofState, PublicKey, QuoteId, +}; + +use super::Mint; +use crate::event::MintEvent; + +/// Mint subtopics +#[derive(Clone)] +pub struct MintPubSubSpec { + db: DynMintDatabase, + payment_processors: Arc>, +} + +impl MintPubSubSpec { + /// Call Mint::check_mint_quote_payments to update the quote pinging the payment backend + async fn get_mint_quote( + &self, + quote_id: &QuoteId, + ) -> Result, cdk_common::Error> { + let mut quote = if let Some(quote) = self.db.get_mint_quote(quote_id).await? { + quote + } else { + return Ok(None); + }; + + Mint::check_mint_quote_payments( + self.db.clone(), + self.payment_processors.clone(), + None, + &mut quote, + ) + .await?; + + Ok(Some(quote)) + } + + async fn get_events_from_db( + &self, + request: &[NotificationId], + ) -> Result>, String> { + let mut to_return = vec![]; + let mut public_keys: Vec = Vec::new(); + let mut melt_queries = Vec::new(); + let mut mint_queries = Vec::new(); + + for idx in request.iter() { + match idx { + NotificationId::ProofState(pk) => public_keys.push(*pk), + NotificationId::MeltQuoteBolt11(uuid) => { + melt_queries.push(self.db.get_melt_quote(uuid)) + } + NotificationId::MintQuoteBolt11(uuid) => { + mint_queries.push(self.get_mint_quote(uuid)) + } + NotificationId::MintQuoteBolt12(uuid) => { + mint_queries.push(self.get_mint_quote(uuid)) + } + NotificationId::MeltQuoteBolt12(uuid) => { + melt_queries.push(self.db.get_melt_quote(uuid)) + } + } + } + + if !melt_queries.is_empty() { + to_return.extend( + futures::future::try_join_all(melt_queries) + .await + .map(|quotes| { + quotes + .into_iter() + .filter_map(|quote| quote.map(|x| x.into())) + .map(|x: MeltQuoteBolt11Response| x.into()) + .collect::>() + }) + .map_err(|e| e.to_string())?, + ); + } + + if !mint_queries.is_empty() { + to_return.extend( + futures::future::try_join_all(mint_queries) + .await + .map(|quotes| { + quotes + .into_iter() + .filter_map(|quote| { + quote.and_then(|mint_quotes| match mint_quotes.payment_method { + PaymentMethod::Bolt11 => { + let response: MintQuoteBolt11Response = + mint_quotes.into(); + Some(response.into()) + } + PaymentMethod::Bolt12 => match mint_quotes.try_into() { + Ok(response) => { + let response: MintQuoteBolt12Response = + response; + Some(response.into()) + } + Err(_) => None, + }, + PaymentMethod::Custom(_) => None, + }) + }) + .collect::>() + }) + .map_err(|e| e.to_string())?, + ); + } + + if !public_keys.is_empty() { + to_return.extend( + self.db + .get_proofs_states(public_keys.as_slice()) + .await + .map_err(|e| e.to_string())? + .into_iter() + .enumerate() + .filter_map(|(idx, state)| state.map(|state| (public_keys[idx], state).into())) + .map(|state: ProofState| state.into()), + ); + } + + Ok(to_return) + } +} + +#[async_trait::async_trait] +impl Spec for MintPubSubSpec { + type SubscriptionId = SubId; + + type Topic = NotificationId; + + type Event = MintEvent; + + type Context = ( + DynMintDatabase, + Arc>, + ); + + fn new_instance(context: Self::Context) -> Arc { + Arc::new(Self { + db: context.0, + payment_processors: context.1, + }) + } + + async fn fetch_events(self: &Arc, topics: Vec, reply_to: Subscriber) { + for event in self + .get_events_from_db(&topics) + .await + .inspect_err(|err| tracing::error!("Error reading events from db {err:?}")) + .unwrap_or_default() + { + let _ = reply_to.send(event); + } + } +} + +/// PubsubManager +pub struct PubSubManager(Pubsub); + +impl PubSubManager { + /// Create a new instance + pub fn new( + context: ( + DynMintDatabase, + Arc>, + ), + ) -> Arc { + Arc::new(Self(Pubsub::new(MintPubSubSpec::new_instance(context)))) + } + + /// Helper function to emit a ProofState status + pub fn proof_state>(&self, event: E) { + self.publish(event.into()); + } + + /// Helper function to publish even of a mint quote being paid + pub fn mint_quote_issue(&self, mint_quote: &MintQuote, total_issued: Amount) { + match mint_quote.payment_method { + PaymentMethod::Bolt11 => { + self.mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Issued); + } + PaymentMethod::Bolt12 => { + self.mint_quote_bolt12_status( + mint_quote.clone(), + mint_quote.amount_paid(), + total_issued, + ); + } + _ => { + // We don't send ws updates for unknown methods + } + } + } + + /// Helper function to publish even of a mint quote being paid + pub fn mint_quote_payment(&self, mint_quote: &MintQuote, total_paid: Amount) { + match mint_quote.payment_method { + PaymentMethod::Bolt11 => { + self.mint_quote_bolt11_status(mint_quote.clone(), MintQuoteState::Paid); + } + PaymentMethod::Bolt12 => { + self.mint_quote_bolt12_status( + mint_quote.clone(), + total_paid, + mint_quote.amount_issued(), + ); + } + _ => { + // We don't send ws updates for unknown methods + } + } + } + + /// Helper function to emit a MintQuoteBolt11Response status + pub fn mint_quote_bolt11_status>>( + &self, + quote: E, + new_state: MintQuoteState, + ) { + let mut event = quote.into(); + event.state = new_state; + + self.publish(event); + } + + /// Helper function to emit a MintQuoteBolt11Response status + pub fn mint_quote_bolt12_status>>( + &self, + quote: E, + amount_paid: Amount, + amount_issued: Amount, + ) { + if let Ok(mut event) = quote.try_into() { + event.amount_paid = amount_paid; + event.amount_issued = amount_issued; + + self.publish(event); + } else { + tracing::warn!("Could not convert quote to MintQuoteResponse"); + } + } + + /// Helper function to emit a MeltQuoteBolt11Response status + pub fn melt_quote_status>>( + &self, + quote: E, + payment_preimage: Option, + change: Option>, + new_state: MeltQuoteState, + ) { + let mut quote = quote.into(); + quote.state = new_state; + quote.payment_preimage = payment_preimage; + quote.change = change; + self.publish(quote); + } +} + +impl Deref for PubSubManager { + type Target = Pubsub; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} diff --git a/crates/cdk/src/mint/subscription/manager.rs b/crates/cdk/src/mint/subscription/manager.rs deleted file mode 100644 index 345b94fbb..000000000 --- a/crates/cdk/src/mint/subscription/manager.rs +++ /dev/null @@ -1,235 +0,0 @@ -//! Specific Subscription for the cdk crate -use std::ops::Deref; -use std::sync::Arc; - -use cdk_common::database::{self, MintDatabase}; -use cdk_common::nut17::Notification; -use cdk_common::NotificationPayload; -use uuid::Uuid; - -use super::OnSubscription; -use crate::nuts::{ - BlindSignature, MeltQuoteBolt11Response, MeltQuoteState, MintQuoteBolt11Response, - MintQuoteState, ProofState, -}; -use crate::pub_sub; - -/// Manager -/// Publish–subscribe manager -/// -/// Nut-17 implementation is system-wide and not only through the WebSocket, so -/// it is possible for another part of the system to subscribe to events. -pub struct PubSubManager(pub_sub::Manager, Notification, OnSubscription>); - -#[allow(clippy::default_constructed_unit_structs)] -impl Default for PubSubManager { - fn default() -> Self { - PubSubManager(OnSubscription::default().into()) - } -} - -impl From + Send + Sync>> for PubSubManager { - fn from(val: Arc + Send + Sync>) -> Self { - PubSubManager(OnSubscription(Some(val)).into()) - } -} - -impl Deref for PubSubManager { - type Target = pub_sub::Manager, Notification, OnSubscription>; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl PubSubManager { - /// Helper function to emit a ProofState status - pub fn proof_state>(&self, event: E) { - self.broadcast(event.into().into()); - } - - /// Helper function to emit a MintQuoteBolt11Response status - pub fn mint_quote_bolt11_status>>( - &self, - quote: E, - new_state: MintQuoteState, - ) { - let mut event = quote.into(); - event.state = new_state; - - self.broadcast(event.into()); - } - - /// Helper function to emit a MeltQuoteBolt11Response status - pub fn melt_quote_status>>( - &self, - quote: E, - payment_preimage: Option, - change: Option>, - new_state: MeltQuoteState, - ) { - let mut quote = quote.into(); - quote.state = new_state; - quote.paid = Some(new_state == MeltQuoteState::Paid); - quote.payment_preimage = payment_preimage; - quote.change = change; - self.broadcast(quote.into()); - } -} - -#[cfg(test)] -mod test { - use std::time::Duration; - - use tokio::time::sleep; - - use super::*; - use crate::nuts::nut17::Kind; - use crate::nuts::{PublicKey, State}; - use crate::subscription::{IndexableParams, Params}; - - #[tokio::test] - async fn active_and_drop() { - let manager = PubSubManager::default(); - let params: IndexableParams = Params { - kind: Kind::ProofState, - filters: vec![ - "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d264bdc074209b107ba2".to_owned(), - ], - id: "uno".into(), - } - .into(); - - // Although the same param is used, two subscriptions are created, that - // is because each index is unique, thanks to `Unique`, it is the - // responsibility of the implementor to make sure that SubId are unique - // either globally or per client - let subscriptions = vec![ - manager - .try_subscribe(params.clone()) - .await - .expect("valid subscription"), - manager - .try_subscribe(params) - .await - .expect("valid subscription"), - ]; - assert_eq!(2, manager.active_subscriptions()); - drop(subscriptions); - - sleep(Duration::from_millis(10)).await; - - assert_eq!(0, manager.active_subscriptions()); - } - - #[tokio::test] - async fn broadcast() { - let manager = PubSubManager::default(); - let mut subscriptions = [ - manager - .try_subscribe::( - Params { - kind: Kind::ProofState, - filters: vec![ - "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" - .to_string(), - ], - id: "uno".into(), - } - .into(), - ) - .await - .expect("valid subscription"), - manager - .try_subscribe::( - Params { - kind: Kind::ProofState, - filters: vec![ - "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104" - .to_string(), - ], - id: "dos".into(), - } - .into(), - ) - .await - .expect("valid subscription"), - ]; - - let event = ProofState { - y: PublicKey::from_hex( - "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104", - ) - .expect("valid pk"), - state: State::Pending, - witness: None, - }; - - manager.broadcast(event.into()); - - sleep(Duration::from_millis(10)).await; - - let (sub1, _) = subscriptions[0].try_recv().expect("valid message"); - assert_eq!("uno", *sub1); - - let (sub1, _) = subscriptions[1].try_recv().expect("valid message"); - assert_eq!("dos", *sub1); - - assert!(subscriptions[0].try_recv().is_err()); - assert!(subscriptions[1].try_recv().is_err()); - } - - #[test] - fn parsing_request() { - let json = r#"{"kind":"proof_state","filters":["x"],"subId":"uno"}"#; - let params: Params = serde_json::from_str(json).expect("valid json"); - assert_eq!(params.kind, Kind::ProofState); - assert_eq!(params.filters, vec!["x"]); - assert_eq!(*params.id, "uno"); - } - - #[tokio::test] - async fn json_test() { - let manager = PubSubManager::default(); - let mut subscription = manager - .try_subscribe::( - serde_json::from_str(r#"{"kind":"proof_state","filters":["02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104"],"subId":"uno"}"#) - .expect("valid json"), - ) - .await.expect("valid subscription"); - - manager.broadcast( - ProofState { - y: PublicKey::from_hex( - "02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104", - ) - .expect("valid pk"), - state: State::Pending, - witness: None, - } - .into(), - ); - - // no one is listening for this event - manager.broadcast( - ProofState { - y: PublicKey::from_hex( - "020000000000000000000000000000000000000000000000000000000000000001", - ) - .expect("valid pk"), - state: State::Pending, - witness: None, - } - .into(), - ); - - sleep(Duration::from_millis(10)).await; - let (sub1, msg) = subscription.try_recv().expect("valid message"); - assert_eq!("uno", *sub1); - assert_eq!( - r#"{"Y":"02194603ffa36356f4a56b7df9371fc3192472351453ec7398b8da8117e7c3e104","state":"PENDING","witness":null}"#, - serde_json::to_string(&msg).expect("valid json") - ); - assert!(subscription.try_recv().is_err()); - } -} diff --git a/crates/cdk/src/mint/subscription/mod.rs b/crates/cdk/src/mint/subscription/mod.rs deleted file mode 100644 index 20216fab6..000000000 --- a/crates/cdk/src/mint/subscription/mod.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Specific Subscription for the cdk crate - -#[cfg(feature = "mint")] -mod manager; -#[cfg(feature = "mint")] -mod on_subscription; -#[cfg(feature = "mint")] -pub use manager::PubSubManager; -#[cfg(feature = "mint")] -pub use on_subscription::OnSubscription; - -pub use crate::pub_sub::SubId; diff --git a/crates/cdk/src/mint/subscription/on_subscription.rs b/crates/cdk/src/mint/subscription/on_subscription.rs deleted file mode 100644 index 829cc6e15..000000000 --- a/crates/cdk/src/mint/subscription/on_subscription.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! On Subscription -//! -//! This module contains the code that is triggered when a new subscription is created. -use std::sync::Arc; - -use cdk_common::database::{self, MintDatabase}; -use cdk_common::nut17::Notification; -use cdk_common::pub_sub::OnNewSubscription; -use cdk_common::NotificationPayload; -use uuid::Uuid; - -use crate::nuts::{MeltQuoteBolt11Response, MintQuoteBolt11Response, ProofState, PublicKey}; - -#[derive(Default)] -/// Subscription Init -/// -/// This struct triggers code when a new subscription is created. -/// -/// It is used to send the initial state of the subscription to the client. -pub struct OnSubscription(pub(crate) Option + Send + Sync>>); - -#[async_trait::async_trait] -impl OnNewSubscription for OnSubscription { - type Event = NotificationPayload; - type Index = Notification; - - async fn on_new_subscription( - &self, - request: &[&Self::Index], - ) -> Result, String> { - let datastore = if let Some(localstore) = self.0.as_ref() { - localstore - } else { - return Ok(vec![]); - }; - - let mut to_return = vec![]; - let mut public_keys: Vec = Vec::new(); - let mut melt_queries = Vec::new(); - let mut mint_queries = Vec::new(); - - for idx in request.iter() { - match idx { - Notification::ProofState(pk) => public_keys.push(*pk), - Notification::MeltQuoteBolt11(uuid) => { - melt_queries.push(datastore.get_melt_quote(uuid)) - } - Notification::MintQuoteBolt11(uuid) => { - mint_queries.push(datastore.get_mint_quote(uuid)) - } - } - } - - to_return.extend( - futures::future::try_join_all(melt_queries) - .await - .map(|quotes| { - quotes - .into_iter() - .filter_map(|quote| quote.map(|x| x.into())) - .map(|x: MeltQuoteBolt11Response| x.into()) - .collect::>() - }) - .map_err(|e| e.to_string())?, - ); - to_return.extend( - futures::future::try_join_all(mint_queries) - .await - .map(|quotes| { - quotes - .into_iter() - .filter_map(|quote| quote.map(|x| x.into())) - .map(|x: MintQuoteBolt11Response| x.into()) - .collect::>() - }) - .map_err(|e| e.to_string())?, - ); - - to_return.extend( - datastore - .get_proofs_states(public_keys.as_slice()) - .await - .map_err(|e| e.to_string())? - .into_iter() - .enumerate() - .filter_map(|(idx, state)| state.map(|state| (public_keys[idx], state).into())) - .map(|state: ProofState| state.into()), - ); - - Ok(to_return) - } -} diff --git a/crates/cdk/src/mint/swap.rs b/crates/cdk/src/mint/swap.rs deleted file mode 100644 index ab2648288..000000000 --- a/crates/cdk/src/mint/swap.rs +++ /dev/null @@ -1,80 +0,0 @@ -use tracing::instrument; - -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 { - let mut tx = self.localstore.begin_transaction().await?; - - if let Err(err) = self - .verify_transaction_balanced(&mut tx, swap_request.inputs(), swap_request.outputs()) - .await - { - tracing::debug!("Attempt to swap unbalanced transaction, aborting: {err}"); - return Err(err); - }; - - self.validate_sig_flag(&swap_request).await?; - - let mut proof_writer = - ProofWriter::new(self.localstore.clone(), self.pubsub_manager.clone()); - let input_ys = proof_writer - .add_proofs(&mut tx, swap_request.inputs()) - .await?; - - let mut promises = Vec::with_capacity(swap_request.outputs().len()); - - for blinded_message in swap_request.outputs() { - let blinded_signature = self.blind_sign(blinded_message.clone()).await?; - promises.push(blinded_signature); - } - - proof_writer - .update_proofs_states(&mut tx, &input_ys, State::Spent) - .await?; - - tx.add_blind_signatures( - &swap_request - .outputs() - .iter() - .map(|o| o.blinded_secret) - .collect::>(), - &promises, - None, - ) - .await?; - - proof_writer.commit(); - tx.commit().await?; - - Ok(SwapResponse::new(promises)) - } - - async fn validate_sig_flag(&self, swap_request: &SwapRequest) -> Result<(), Error> { - let EnforceSigFlag { - sig_flag, - pubkeys, - sigs_required, - } = enforce_sig_flag(swap_request.inputs().clone()); - - if sig_flag.eq(&SigFlag::SigAll) { - let pubkeys = pubkeys.into_iter().collect(); - for blinded_message in swap_request.outputs() { - if let Err(err) = blinded_message.verify_p2pk(&pubkeys, sigs_required) { - tracing::info!("Could not verify p2pk in swap request"); - return Err(err.into()); - } - } - } - - Ok(()) - } -} diff --git a/crates/cdk/src/mint/swap/mod.rs b/crates/cdk/src/mint/swap/mod.rs new file mode 100644 index 000000000..a1dd9f5b4 --- /dev/null +++ b/crates/cdk/src/mint/swap/mod.rs @@ -0,0 +1,81 @@ +use cdk_common::SpendingConditionVerification; +#[cfg(feature = "prometheus")] +use cdk_prometheus::METRICS; +use swap_saga::SwapSaga; +use tracing::instrument; + +use super::{Mint, SwapRequest, SwapResponse}; +use crate::Error; + +pub mod swap_saga; + +#[cfg(test)] +mod tests; + +impl Mint { + /// Process Swap + #[instrument(skip_all)] + pub async fn process_swap_request( + &self, + swap_request: SwapRequest, + ) -> Result { + #[cfg(feature = "prometheus")] + METRICS.inc_in_flight_requests("process_swap_request"); + + swap_request.input_amount()?; + swap_request.output_amount()?; + + // Verify spending conditions (NUT-10/NUT-11/NUT-14), i.e. P2PK + // and HTLC (including SIGALL) + swap_request.verify_spending_conditions()?; + + // We don't need to check P2PK or HTLC again. It has all been checked above + // and the code doesn't reach here unless such verifications were satisfactory + + // Verify inputs (cryptographic verification, no DB needed) + let input_verification = + self.verify_inputs(swap_request.inputs()) + .await + .map_err(|err| { + #[cfg(feature = "prometheus")] + self.record_swap_failure("process_swap_request"); + + tracing::debug!("Input verification failed: {:?}", err); + err + })?; + + // 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) + } + + #[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(); + } +} diff --git a/crates/cdk/src/mint/swap/swap_saga/compensation.rs b/crates/cdk/src/mint/swap/swap_saga/compensation.rs new file mode 100644 index 000000000..3f8cc3687 --- /dev/null +++ b/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, + /// Y values (public keys) from the input proofs + pub input_ys: Vec, +} + +#[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" + } +} diff --git a/crates/cdk/src/mint/swap/swap_saga/mod.rs b/crates/cdk/src/mint/swap/swap_saga/mod.rs new file mode 100644 index 000000000..8c08c56b2 --- /dev/null +++ b/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 +/// └─> setup_swap() -> SwapSaga +/// └─> sign_outputs() -> SwapSaga +/// └─> 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` unwrapping needed +/// - IDE autocomplete only shows valid operations for each state +pub struct SwapSaga<'a, S> { + mint: &'a super::Mint, + db: DynMintDatabase, + pubsub: Arc, + /// Compensating actions in LIFO order (most recent first) + compensations: Arc>>>, + operation: Operation, + state_data: S, +} + +impl<'a> SwapSaga<'a, Initial> { + pub fn new(mint: &'a super::Mint, db: DynMintDatabase, pubsub: Arc) -> 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, + input_verification: crate::mint::Verification, + ) -> Result, 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 = 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, 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 { + tracing::info!("TX2: Finalizing swap (signatures + mark spent)"); + + let blinded_secrets: Vec = 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 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(()) + } +} diff --git a/crates/cdk/src/mint/swap/swap_saga/state.rs b/crates/cdk/src/mint/swap/swap_saga/state.rs new file mode 100644 index 000000000..5ddfffb69 --- /dev/null +++ b/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, + pub ys: Vec, +} + +/// 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, + pub ys: Vec, + pub signatures: Vec, +} diff --git a/crates/cdk/src/mint/swap/swap_saga/tests.rs b/crates/cdk/src/mint/swap/swap_saga/tests.rs new file mode 100644 index 000000000..878c16e0b --- /dev/null +++ b/crates/cdk/src/mint/swap/swap_saga/tests.rs @@ -0,0 +1,2992 @@ +//! 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 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. +} diff --git a/crates/cdk/src/mint/swap/tests/htlc_sigall_spending_conditions_tests.rs b/crates/cdk/src/mint/swap/tests/htlc_sigall_spending_conditions_tests.rs new file mode 100644 index 000000000..7ff6fb4c8 --- /dev/null +++ b/crates/cdk/src/mint/swap/tests/htlc_sigall_spending_conditions_tests.rs @@ -0,0 +1,389 @@ +//! HTLC SIG_ALL tests for swap functionality +//! +//! These tests verify that the mint correctly enforces SIG_ALL flag behavior for HTLC + +use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions}; +use cdk_common::Amount; + +use crate::test_helpers::nut10::{ + create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper, +}; + +/// Test: HTLC SIG_ALL requiring preimage and one signature +/// +/// Creates HTLC-locked proofs with SIG_ALL flag and verifies: +/// 1. Spending with only preimage fails (signature required) +/// 2. Spending with only signature fails (preimage required) +/// 3. Spending with both preimage and SIG_ALL signature succeeds +#[tokio::test] +async fn test_htlc_sig_all_requiring_preimage_and_one_signature() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for Alice + let (alice_secret, alice_pubkey) = create_test_keypair(); + + // Create hash and preimage + let (hash, preimage) = create_test_hash_and_preimage(); + + println!("Alice pubkey: {}", alice_pubkey); + println!("Hash: {}", hash); + println!("Preimage: {}", preimage); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create HTLC spending conditions with SIG_ALL flag (hash locked to Alice's key) + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![alice_pubkey]), + refund_keys: None, + num_sigs: None, // Default (1) + sig_flag: SigFlag::SigAll, // <-- SIG_ALL flag + num_sigs_refund: None, + }), + ) + .unwrap(); + println!("Created HTLC spending conditions with SIG_ALL flag"); + + // Step 3: Create HTLC blinded messages (outputs) + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + println!( + "Created {} HTLC outputs locked to alice with hash", + htlc_outputs.len() + ); + + // Step 4: Swap regular proofs for HTLC proofs (no signature needed on inputs) + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for HTLC proofs"); + println!("Swap successful! Got BlindSignatures for our HTLC outputs"); + + // Step 5: Construct the HTLC proofs + use cdk_common::dhke::construct_proofs; + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = htlc_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} HTLC proof(s) [{}]", + htlc_proofs.len(), + proof_amounts.join("+") + ); + + // Step 6: Try to spend with only preimage (should fail - signature required) + use crate::test_helpers::mint::create_test_blinded_messages; + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_preimage_only = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + // Add only preimage to first proof (no signature) + swap_request_preimage_only.inputs_mut()[0].add_preimage(preimage.clone()); + + let result = mint.process_swap_request(swap_request_preimage_only).await; + assert!( + result.is_err(), + "Should fail with only preimage (no signature)" + ); + println!( + "✓ Spending with ONLY preimage failed as expected: {:?}", + result.err() + ); + + // Step 7: Try to spend with only signature (should fail - preimage required) + let mut swap_request_signature_only = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + // Add only SIG_ALL signature to first proof (no preimage) + // Note: Must create HTLCWitness first, otherwise sign_sig_all creates P2PKWitness + swap_request_signature_only.inputs_mut()[0].add_preimage(String::new()); // Empty preimage + swap_request_signature_only + .sign_sig_all(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_signature_only).await; + assert!( + result.is_err(), + "Should fail with only signature (no preimage)" + ); + println!( + "✓ Spending with ONLY signature failed as expected: {:?}", + result.err() + ); + + // Step 8: Now try to spend with both preimage and SIG_ALL signature + let mut swap_request_both = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + // Add preimage to first proof + swap_request_both.inputs_mut()[0].add_preimage(preimage.clone()); + // Add SIG_ALL signature + swap_request_both + .sign_sig_all(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_both).await; + assert!( + result.is_ok(), + "Should succeed with correct preimage and SIG_ALL signature: {:?}", + result.err() + ); + println!("✓ HTLC SIG_ALL spent successfully with correct preimage AND signature"); +} + +/// Test: HTLC SIG_ALL with wrong preimage +/// +/// Verifies that providing an incorrect preimage fails even with correct SIG_ALL signature +#[tokio::test] +async fn test_htlc_sig_all_wrong_preimage() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (hash, _correct_preimage) = create_test_hash_and_preimage(); + + // Mint regular proofs and swap for HTLC SIG_ALL proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![alice_pubkey]), + refund_keys: None, + num_sigs: None, + sig_flag: SigFlag::SigAll, + num_sigs_refund: None, + }), + ) + .unwrap(); + + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + use cdk_common::dhke::construct_proofs; + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Try to spend with WRONG preimage (but correct SIG_ALL signature) + use crate::test_helpers::mint::create_test_blinded_messages; + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + let wrong_preimage = "this_is_the_wrong_preimage"; + swap_request.inputs_mut()[0].add_preimage(wrong_preimage.to_string()); + swap_request.sign_sig_all(alice_secret.clone()).unwrap(); + + let result = mint.process_swap_request(swap_request).await; + assert!(result.is_err(), "Should fail with wrong preimage"); + println!( + "✓ HTLC SIG_ALL with wrong preimage failed as expected: {:?}", + result.err() + ); +} + +/// Test: HTLC SIG_ALL locktime after expiry (refund path) +/// +/// Verifies that after locktime expires, refund keys can spend without preimage using SIG_ALL +#[tokio::test] +async fn test_htlc_sig_all_locktime_after_expiry() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (_alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (hash, _preimage) = create_test_hash_and_preimage(); + + // Create HTLC with locktime in the PAST (already expired) and Bob as refund key + let past_locktime = cdk_common::util::unix_time() - 1000; + + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: Some(past_locktime), + pubkeys: Some(vec![alice_pubkey]), + refund_keys: Some(vec![bob_pubkey]), + num_sigs: None, + sig_flag: SigFlag::SigAll, + num_sigs_refund: None, + }), + ) + .unwrap(); + + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + use cdk_common::dhke::construct_proofs; + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // After locktime, Bob (refund key) can spend WITHOUT preimage using SIG_ALL + use crate::test_helpers::mint::create_test_blinded_messages; + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + // Bob signs with SIG_ALL (no preimage needed after locktime) + // Note: Must call add_preimage first (even with empty string) to create HTLC witness + swap_request.inputs_mut()[0].add_preimage(String::new()); + swap_request.sign_sig_all(bob_secret.clone()).unwrap(); + + let result = mint.process_swap_request(swap_request).await; + assert!( + result.is_ok(), + "Bob should be able to spend after locktime without preimage: {:?}", + result.err() + ); + println!("✓ HTLC SIG_ALL spent by refund key after locktime (no preimage needed)"); +} + +/// Test: HTLC SIG_ALL with multisig (preimage + 2-of-3 signatures) +/// +/// Verifies that HTLC SIG_ALL can require preimage AND multiple signatures +#[tokio::test] +async fn test_htlc_sig_all_multisig_2of3() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (_charlie_secret, charlie_pubkey) = create_test_keypair(); + let (hash, preimage) = create_test_hash_and_preimage(); + + // Create HTLC requiring preimage + 2-of-3 signatures (Alice, Bob, Charlie) with SIG_ALL + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![alice_pubkey, bob_pubkey, charlie_pubkey]), + refund_keys: None, + num_sigs: Some(2), // Require 2 of 3 + sig_flag: SigFlag::SigAll, + num_sigs_refund: None, + }), + ) + .unwrap(); + + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + use cdk_common::dhke::construct_proofs; + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Try with preimage + only 1 SIG_ALL signature (should fail - need 2) + use crate::test_helpers::mint::create_test_blinded_messages; + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_one_sig = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + swap_request_one_sig.inputs_mut()[0].add_preimage(preimage.clone()); + swap_request_one_sig + .sign_sig_all(alice_secret.clone()) + .unwrap(); // Only Alice signs + + let result = mint.process_swap_request(swap_request_one_sig).await; + assert!( + result.is_err(), + "Should fail with only 1 signature (need 2)" + ); + println!("✓ HTLC SIG_ALL with 1-of-3 signatures failed as expected"); + + // Now with preimage + 2 SIG_ALL signatures (Alice and Bob) - should succeed + let mut swap_request_two_sigs = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + swap_request_two_sigs.inputs_mut()[0].add_preimage(preimage.clone()); + swap_request_two_sigs + .sign_sig_all(alice_secret.clone()) + .unwrap(); + swap_request_two_sigs + .sign_sig_all(bob_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_two_sigs).await; + assert!( + result.is_ok(), + "Should succeed with preimage + 2-of-3 SIG_ALL signatures: {:?}", + result.err() + ); + println!("✓ HTLC SIG_ALL spent with preimage + 2-of-3 signatures"); +} diff --git a/crates/cdk/src/mint/swap/tests/htlc_spending_conditions_tests.rs b/crates/cdk/src/mint/swap/tests/htlc_spending_conditions_tests.rs new file mode 100644 index 000000000..996948ab1 --- /dev/null +++ b/crates/cdk/src/mint/swap/tests/htlc_spending_conditions_tests.rs @@ -0,0 +1,396 @@ +//! HTLC (NUT-14) tests for swap functionality +//! +//! These tests verify that the mint correctly validates HTLC spending conditions +//! during swap operations, including: +//! - Hash preimage verification +//! - Locktime enforcement +//! - Refund keys +//! - Signature validation + +use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions}; +use cdk_common::Amount; + +use crate::test_helpers::nut10::{ + create_test_hash_and_preimage, create_test_keypair, unzip3, TestMintHelper, +}; + +/// Test: HTLC requiring preimage and one signature +/// +/// Creates HTLC-locked proofs and verifies: +/// 1. Spending with only preimage fails (signature required) +/// 2. Spending with only signature fails (preimage required) +/// 3. Spending with both preimage and signature succeeds +#[tokio::test] +async fn test_htlc_requiring_preimage_and_one_signature() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for Alice + let (alice_secret, alice_pubkey) = create_test_keypair(); + + // Create hash and preimage + let (hash, preimage) = create_test_hash_and_preimage(); + + println!("Alice pubkey: {}", alice_pubkey); + println!("Hash: {}", hash); + println!("Preimage: {}", preimage); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create HTLC spending conditions (hash locked to Alice's key) + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![alice_pubkey]), + refund_keys: None, + num_sigs: None, // Default (1) + sig_flag: SigFlag::default(), + num_sigs_refund: None, + }), + ) + .unwrap(); + println!("Created HTLC spending conditions"); + + // Step 3: Create HTLC blinded messages (outputs) + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + println!( + "Created {} HTLC outputs locked to alice with hash", + htlc_outputs.len() + ); + + // Step 4: Swap regular proofs for HTLC proofs (no signature needed on inputs) + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for HTLC proofs"); + println!("Swap successful! Got BlindSignatures for our HTLC outputs"); + + // Step 5: Construct the HTLC proofs + use cdk_common::dhke::construct_proofs; + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = htlc_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} HTLC proof(s) [{}]", + htlc_proofs.len(), + proof_amounts.join("+") + ); + + // Step 6: Try to spend with only preimage (should fail - signature required) + use crate::test_helpers::mint::create_test_blinded_messages; + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_preimage_only = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + // Add only preimage (no signature) + for proof in swap_request_preimage_only.inputs_mut() { + proof.add_preimage(preimage.clone()); + } + + let result = mint.process_swap_request(swap_request_preimage_only).await; + assert!( + result.is_err(), + "Should fail with only preimage (no signature)" + ); + println!( + "✓ Spending with ONLY preimage failed as expected: {:?}", + result.err() + ); + + // Step 7: Try to spend with only signature (should fail - preimage required) + let mut swap_request_signature_only = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + // Add only signature (no preimage) + for proof in swap_request_signature_only.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_signature_only).await; + assert!( + result.is_err(), + "Should fail with only signature (no preimage)" + ); + println!( + "✓ Spending with ONLY signature failed as expected: {:?}", + result.err() + ); + + // Step 8: Now try to spend the HTLC proofs with correct preimage + signature + let mut swap_request_both = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + // Add preimage and sign all proofs + for proof in swap_request_both.inputs_mut() { + proof.add_preimage(preimage.clone()); + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_both).await; + assert!( + result.is_ok(), + "Should succeed with correct preimage and signature: {:?}", + result.err() + ); + println!("✓ HTLC spent successfully with correct preimage AND signature"); +} + +/// Test: HTLC with wrong preimage +/// +/// Verifies that providing an incorrect preimage fails even with correct signature +#[tokio::test] +async fn test_htlc_wrong_preimage() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (hash, _correct_preimage) = create_test_hash_and_preimage(); + + // Mint regular proofs and swap for HTLC proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![alice_pubkey]), + refund_keys: None, + num_sigs: None, + sig_flag: SigFlag::default(), + num_sigs_refund: None, + }), + ) + .unwrap(); + + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + use cdk_common::dhke::construct_proofs; + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Try to spend with WRONG preimage (but correct signature) + use crate::test_helpers::mint::create_test_blinded_messages; + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + let wrong_preimage = "this_is_the_wrong_preimage"; + for proof in swap_request.inputs_mut() { + proof.add_preimage(wrong_preimage.to_string()); + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request).await; + assert!(result.is_err(), "Should fail with wrong preimage"); + println!( + "✓ HTLC with wrong preimage failed as expected: {:?}", + result.err() + ); +} + +/// Test: HTLC locktime after expiry (refund path) +/// +/// Verifies that after locktime expires, refund keys can spend without preimage +#[tokio::test] +async fn test_htlc_locktime_after_expiry() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (_alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (hash, _preimage) = create_test_hash_and_preimage(); + + // Create HTLC with locktime in the PAST (already expired) and Bob as refund key + let past_locktime = cdk_common::util::unix_time() - 1000; + + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: Some(past_locktime), + pubkeys: Some(vec![alice_pubkey]), + refund_keys: Some(vec![bob_pubkey]), + num_sigs: None, + sig_flag: SigFlag::default(), + num_sigs_refund: None, + }), + ) + .unwrap(); + + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + use cdk_common::dhke::construct_proofs; + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // After locktime, Bob (refund key) can spend WITHOUT preimage + use crate::test_helpers::mint::create_test_blinded_messages; + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + // Bob signs (no preimage needed after locktime) + // Note: Must call add_preimage first (even with empty string) to create HTLC witness, + // otherwise sign_p2pk creates P2PK witness instead + for proof in swap_request.inputs_mut() { + proof.add_preimage(String::new()); // Empty preimage for refund path + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request).await; + assert!( + result.is_ok(), + "Bob should be able to spend after locktime without preimage: {:?}", + result.err() + ); + println!("✓ HTLC spent by refund key after locktime (no preimage needed)"); +} + +/// Test: HTLC with multisig (preimage + 2-of-3 signatures) +/// +/// Verifies that HTLC can require preimage AND multiple signatures +#[tokio::test] +async fn test_htlc_multisig_2of3() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (_charlie_secret, charlie_pubkey) = create_test_keypair(); + let (hash, preimage) = create_test_hash_and_preimage(); + + // Create HTLC requiring preimage + 2-of-3 signatures (Alice, Bob, Charlie) + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + let spending_conditions = SpendingConditions::new_htlc_hash( + &hash, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![alice_pubkey, bob_pubkey, charlie_pubkey]), + refund_keys: None, + num_sigs: Some(2), // Require 2 of 3 + sig_flag: SigFlag::default(), + num_sigs_refund: None, + }), + ) + .unwrap(); + + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (htlc_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), htlc_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + use cdk_common::dhke::construct_proofs; + let htlc_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Try with preimage + only 1 signature (should fail - need 2) + use crate::test_helpers::mint::create_test_blinded_messages; + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_one_sig = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + for proof in swap_request_one_sig.inputs_mut() { + proof.add_preimage(preimage.clone()); + proof.sign_p2pk(alice_secret.clone()).unwrap(); // Only Alice signs + } + + let result = mint.process_swap_request(swap_request_one_sig).await; + assert!( + result.is_err(), + "Should fail with only 1 signature (need 2)" + ); + println!("✓ HTLC with 1-of-3 signatures failed as expected"); + + // Now with preimage + 2 signatures (Alice and Bob) - should succeed + let mut swap_request_two_sigs = + cdk_common::nuts::SwapRequest::new(htlc_proofs.clone(), new_outputs.clone()); + + for proof in swap_request_two_sigs.inputs_mut() { + proof.add_preimage(preimage.clone()); + proof.sign_p2pk(alice_secret.clone()).unwrap(); + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_two_sigs).await; + assert!( + result.is_ok(), + "Should succeed with preimage + 2-of-3 signatures: {:?}", + result.err() + ); + println!("✓ HTLC spent with preimage + 2-of-3 signatures"); +} diff --git a/crates/cdk/src/mint/swap/tests/mod.rs b/crates/cdk/src/mint/swap/tests/mod.rs new file mode 100644 index 000000000..fa7e3558a --- /dev/null +++ b/crates/cdk/src/mint/swap/tests/mod.rs @@ -0,0 +1,4 @@ +mod htlc_sigall_spending_conditions_tests; +mod htlc_spending_conditions_tests; +mod p2pk_sigall_spending_conditions_tests; +mod p2pk_spending_conditions_tests; diff --git a/crates/cdk/src/mint/swap/tests/p2pk_sigall_spending_conditions_tests.rs b/crates/cdk/src/mint/swap/tests/p2pk_sigall_spending_conditions_tests.rs new file mode 100644 index 000000000..ebb63969f --- /dev/null +++ b/crates/cdk/src/mint/swap/tests/p2pk_sigall_spending_conditions_tests.rs @@ -0,0 +1,1441 @@ +//! P2PK SIG_ALL tests for swap functionality +//! +//! These tests verify that the mint correctly enforces SIG_ALL flag behavior + +use cdk_common::dhke::construct_proofs; +use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions}; +use cdk_common::Amount; + +use crate::test_helpers::mint::create_test_blinded_messages; +use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper}; +use crate::util::unix_time; + +/// Test: P2PK with SIG_ALL flag requires transaction signature +/// +/// Creates P2PK proofs with SIG_ALL flag and verifies: +/// 1. Spending without signature is rejected +/// 2. Spending with SIG_INPUTS signatures (individual proof signatures) is rejected +/// 3. Spending with SIG_ALL signature (transaction signature) succeeds +#[tokio::test] +async fn test_p2pk_sig_all_requires_transaction_signature() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for P2PK + let (alice_secret, alice_pubkey) = create_test_keypair(); + println!("Alice pubkey: {}", alice_pubkey); + + // Step 1: Create regular unencumbered proofs that we'll swap for P2PK proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create P2PK blinded messages (outputs locked to alice_pubkey) with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + None, // no locktime + None, // no additional pubkeys + None, // no refund keys + None, // default num_sigs (1) + Some(SigFlag::SigAll), // SIG_ALL flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + println!("Created P2PK spending conditions with SIG_ALL flag"); + + // Split the input amount into power-of-2 denominations + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + // Create blinded messages for each split amount + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + println!( + "Created {} P2PK outputs locked to alice", + p2pk_outputs.len() + ); + + // Step 3: Swap regular proofs for P2PK proofs (no signature needed on inputs) + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for P2PK proofs"); + println!("Swap successful! Got BlindSignatures for our P2PK outputs"); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} P2PK proof(s) [{}]", + p2pk_proofs.len(), + proof_amounts.join("+") + ); + + // Step 5: Try to spend P2PK proof WITHOUT signature (should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let swap_request_no_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + let result = mint.process_swap_request(swap_request_no_sig).await; + assert!(result.is_err(), "Should fail without signature"); + println!( + "✓ Spending WITHOUT signature failed as expected: {:?}", + result.err() + ); + + // Step 6: Sign all proofs individually (SIG_INPUTS way) - should fail for SIG_ALL + let mut swap_request_sig_inputs = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign each proof individually (SIG_INPUTS mode) + for proof in swap_request_sig_inputs.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_sig_inputs).await; + assert!( + result.is_err(), + "Should fail - SIG_INPUTS signatures not valid for SIG_ALL" + ); + println!( + "✓ Spending with SIG_INPUTS signatures failed as expected: {:?}", + result.err() + ); + + // Step 7: Sign the transaction with SIG_ALL (should succeed) + let mut swap_request_with_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Use sign_sig_all to sign the transaction (signature goes on first proof's witness) + swap_request_with_sig + .sign_sig_all(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_with_sig).await; + assert!( + result.is_ok(), + "Should succeed with valid signature: {:?}", + result.err() + ); + println!("✓ Spending WITH ALL signatures (SIG_ALL) succeeded"); +} + +/// Test: P2PK multisig (2-of-3) with SIG_ALL +/// +/// Creates proofs requiring 2 signatures from a set of 3 public keys with SIG_ALL flag and verifies: +/// 1. Spending with only 1 signature fails (Alice only) +/// 2. Spending with 2 invalid signatures fails (wrong keys) +/// 3. Spending with 2 valid signatures succeeds (Alice + Bob) +#[tokio::test] +async fn test_p2pk_sig_all_multisig_2of3() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate 3 keypairs for the multisig + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (_carol_secret, carol_pubkey) = create_test_keypair(); + + // Generate 2 wrong keypairs (not in the multisig set) + let (dave_secret, _dave_pubkey) = create_test_keypair(); + let (eve_secret, _eve_pubkey) = create_test_keypair(); + + println!("Alice: {}", alice_pubkey); + println!("Bob: {}", bob_pubkey); + println!("Carol: {}", carol_pubkey); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create 2-of-3 multisig conditions with SIG_ALL + // Primary key: Alice + // Additional keys: Bob, Carol + // Requires 2 signatures total + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + None, // no locktime + Some(vec![bob_pubkey, carol_pubkey]), // additional pubkeys + None, // no refund keys + Some(2), // require 2 signatures + Some(SigFlag::SigAll), // SIG_ALL flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + println!("Created 2-of-3 multisig spending conditions with SIG_ALL (Alice, Bob, Carol)"); + + // Step 3: Create P2PK blinded messages with multisig conditions + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK multisig proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + println!("Created P2PK multisig proofs (2-of-3) with SIG_ALL"); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with only 1 signature (Alice only - should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_one_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with only Alice (SIG_ALL mode) + swap_request_one_sig + .sign_sig_all(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_one_sig).await; + assert!( + result.is_err(), + "Should fail with only 1 signature (need 2)" + ); + println!( + "✓ Spending with only 1 signature (Alice) failed as expected: {:?}", + result.err() + ); + + // Step 7: Try to spend with 2 invalid signatures (Dave + Eve - not in multisig set) + let mut swap_request_invalid_sigs = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Dave and Eve (wrong keys!) - add signatures one at a time + swap_request_invalid_sigs + .sign_sig_all(dave_secret.clone()) + .unwrap(); + swap_request_invalid_sigs + .sign_sig_all(eve_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_invalid_sigs).await; + assert!(result.is_err(), "Should fail with 2 invalid signatures"); + println!( + "✓ Spending with 2 INVALID signatures (Dave + Eve) failed as expected: {:?}", + result.err() + ); + + // Step 8: Spend with 2 valid signatures (Alice + Bob - should succeed) + let mut swap_request_valid_sigs = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice and Bob - add signatures one at a time + swap_request_valid_sigs + .sign_sig_all(alice_secret.clone()) + .unwrap(); + swap_request_valid_sigs + .sign_sig_all(bob_secret.clone()) + .unwrap(); + + // print the json serializiation of this final swap. It should succeed + // as it has sufficient signatures + println!( + "{}", + serde_json::to_string_pretty(&swap_request_valid_sigs.clone()).unwrap() + ); + + let result = mint.process_swap_request(swap_request_valid_sigs).await; + assert!( + result.is_ok(), + "Should succeed with 2 valid signatures: {:?}", + result.err() + ); + println!("✓ Spending with 2 VALID signatures (Alice + Bob) succeeded"); +} + +/// Test: P2PK with SIG_ALL signed by wrong person is rejected +/// +/// Creates proofs locked to Alice's public key with SIG_ALL flag and verifies that +/// signing with Bob's key (wrong key) is rejected +#[tokio::test] +async fn test_p2pk_sig_all_signed_by_wrong_person() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypairs for Alice and Bob + let (_alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, _bob_pubkey) = create_test_keypair(); + println!("Alice pubkey: {}", alice_pubkey); + println!("Bob will try to spend Alice's proofs"); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create P2PK blinded messages locked to Alice's pubkey with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + None, // no locktime + None, // no additional pubkeys + None, // no refund keys + None, // default num_sigs (1) + Some(SigFlag::SigAll), // SIG_ALL flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 3: Swap for P2PK proofs locked to Alice + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + println!("Created P2PK proofs locked to Alice with SIG_ALL"); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 5: Try to spend Alice's proofs by signing with Bob's key (wrong key!) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_wrong_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Bob's key instead of Alice's key (SIG_ALL mode) + swap_request_wrong_sig + .sign_sig_all(bob_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_wrong_sig).await; + assert!(result.is_err(), "Should fail when signed with wrong key"); + println!( + "✓ Spending signed by wrong person failed as expected: {:?}", + result.err() + ); +} + +/// Test: Duplicate signatures are rejected (SIG_ALL) +/// +/// Verifies that using the same signature twice doesn't count as multiple signers +/// in a 2-of-2 multisig scenario with SIG_ALL flag +#[tokio::test] +async fn test_p2pk_sig_all_duplicate_signatures() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (_bob_secret, bob_pubkey) = create_test_keypair(); + + println!("Alice: {}", alice_pubkey); + println!("Bob: {}", bob_pubkey); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create 2-of-2 multisig (Alice and Bob, need both) with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + None, // no locktime + Some(vec![bob_pubkey]), // Bob is additional pubkey + None, // no refund keys + Some(2), // require 2 signatures (Alice + Bob) + Some(SigFlag::SigAll), // SIG_ALL flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + println!("Created 2-of-2 multisig (Alice, Bob) with SIG_ALL"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with Alice's signature TWICE (should fail - need Alice + Bob, not Alice + Alice) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_duplicate = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice twice instead of Alice + Bob (SIG_ALL mode) + swap_request_duplicate + .sign_sig_all(alice_secret.clone()) + .unwrap(); + swap_request_duplicate + .sign_sig_all(alice_secret.clone()) + .unwrap(); // Duplicate! + + let result = mint.process_swap_request(swap_request_duplicate).await; + assert!( + result.is_err(), + "Should fail - duplicate signatures not allowed" + ); + println!( + "✓ Spending with duplicate signatures (Alice + Alice) failed as expected: {:?}", + result.err() + ); +} + +/// Test: P2PK with locktime (before expiry) - SIG_ALL +/// +/// Verifies that before locktime expires with SIG_ALL: +/// 1. Spending with primary key (Alice) succeeds +/// 2. Spending with refund key (Bob) fails +#[tokio::test] +async fn test_p2pk_sig_all_locktime_before_expiry() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + + // Set locktime 1 hour in the future + let locktime = unix_time() + 3600; + + println!("Alice (primary): {}", alice_pubkey); + println!("Bob (refund): {}", bob_pubkey); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expires in 1 hour)", locktime); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create conditions with Alice as primary and Bob as refund key with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + Some(locktime), // locktime in the future + None, // no additional pubkeys + Some(vec![bob_pubkey]), // Bob is refund key + None, // default num_sigs (1) + Some(SigFlag::SigAll), // SIG_ALL flag + None, // default num_sigs_refund (1) + ) + .unwrap(), + ), + ); + println!("Created P2PK with locktime and refund key with SIG_ALL"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with refund key (Bob) BEFORE locktime expires (should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_refund = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Bob (refund key) using SIG_ALL + swap_request_refund + .sign_sig_all(bob_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_refund).await; + assert!( + result.is_err(), + "Should fail - refund key cannot spend before locktime" + ); + println!( + "✓ Spending with refund key (Bob) BEFORE locktime failed as expected: {:?}", + result.err() + ); + + // Step 7: Spend with primary key (Alice) BEFORE locktime (should succeed) + let mut swap_request_primary = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice (primary key) using SIG_ALL + swap_request_primary + .sign_sig_all(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_primary).await; + assert!( + result.is_ok(), + "Should succeed - primary key can spend before locktime: {:?}", + result.err() + ); + println!("✓ Spending with primary key (Alice) BEFORE locktime succeeded"); +} + +/// Test: P2PK with locktime (after expiry) - SIG_ALL +/// +/// Verifies that after locktime expires with SIG_ALL: +/// 1. Spending with primary key (Alice) fails +/// 2. Spending with refund key (Bob) succeeds +#[tokio::test] +async fn test_p2pk_sig_all_locktime_after_expiry() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + + // Set locktime in the past (already expired) + let locktime = unix_time() - 3600; + + println!("Alice (primary): {}", alice_pubkey); + println!("Bob (refund): {}", bob_pubkey); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expired 1 hour ago)", locktime); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create conditions with Alice as primary and Bob as refund key with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // locktime in the past (expired) + pubkeys: None, // no additional pubkeys + refund_keys: Some(vec![bob_pubkey]), // Bob is refund key + num_sigs: None, // default (1) + sig_flag: SigFlag::SigAll, // SIG_ALL flag + num_sigs_refund: None, // default (1) + }), + ); + println!("Created P2PK with expired locktime and refund key with SIG_ALL"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with primary key (Alice) AFTER locktime expires (should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_primary = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice (primary key) using SIG_ALL + swap_request_primary + .sign_sig_all(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_primary).await; + assert!( + result.is_err(), + "Should fail - primary key cannot spend after locktime expires" + ); + println!( + "✓ Spending with primary key (Alice) AFTER locktime failed as expected: {:?}", + result.err() + ); + + // Step 7: Spend with refund key (Bob) AFTER locktime (should succeed) + let mut swap_request_refund = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Bob (refund key) using SIG_ALL + swap_request_refund + .sign_sig_all(bob_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_refund).await; + assert!( + result.is_ok(), + "Should succeed - refund key can spend after locktime: {:?}", + result.err() + ); + println!("✓ Spending with refund key (Bob) AFTER locktime succeeded"); +} + +/// Test: P2PK with locktime after expiry, no refund keys (anyone can spend) - SIG_ALL +/// +/// Verifies that after locktime expires with NO refund keys configured and SIG_ALL, +/// anyone can spend the proofs without providing any signatures at all. +#[tokio::test] +async fn test_p2pk_sig_all_locktime_after_expiry_no_refund_anyone_can_spend() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (_alice_secret, alice_pubkey) = create_test_keypair(); + + // Set locktime in the past (already expired) + let locktime = unix_time() - 3600; + + println!("Alice (primary): {}", alice_pubkey); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expired 1 hour ago)", locktime); + println!("No refund keys configured - anyone can spend after locktime"); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create conditions with Alice as primary, NO refund keys, with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // locktime in the past (expired) + pubkeys: None, // no additional pubkeys + refund_keys: None, // NO refund keys - anyone can spend! + num_sigs: None, // default (1) + sig_flag: SigFlag::SigAll, // SIG_ALL flag + num_sigs_refund: None, // default (1) + }), + ); + println!("Created P2PK with expired locktime, NO refund keys, and SIG_ALL"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Spend WITHOUT any signatures (should succeed - anyone can spend!) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let swap_request_no_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // No signatures added at all! + + let result = mint.process_swap_request(swap_request_no_sig).await; + assert!( + result.is_ok(), + "Should succeed - anyone can spend after locktime with no refund keys: {:?}", + result.err() + ); + println!("✓ Spending WITHOUT any signatures succeeded (anyone can spend)"); +} + +/// Test: P2PK multisig with locktime (2-of-3 before, 1-of-2 after) - SIG_ALL +/// +/// Complex scenario with SIG_ALL: Different multisig requirements before and after locktime +/// Before locktime: Need 2-of-3 from (Alice, Bob, Carol) +/// After locktime: Need 1-of-2 from (Dave, Eve) as refund keys +#[tokio::test] +async fn test_p2pk_sig_all_multisig_locktime() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Before locktime: Need 2-of-3 from (Alice, Bob, Carol) + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (_carol_secret, carol_pubkey) = create_test_keypair(); + + // After locktime: Need 1-of-2 from (Dave, Eve) as refund keys + let (dave_secret, dave_pubkey) = create_test_keypair(); + let (_eve_secret, eve_pubkey) = create_test_keypair(); + + let locktime = unix_time() - 100; // Already expired + + println!("Primary multisig: Alice, Bob, Carol (need 2-of-3)"); + println!("Refund multisig: Dave, Eve (need 1-of-2)"); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expired)", locktime); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create complex conditions with SIG_ALL + // Before locktime: 2-of-3 (Alice, Bob, Carol) + // After locktime: 1-of-2 (Dave, Eve) + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // Already expired + pubkeys: Some(vec![bob_pubkey, carol_pubkey]), // Bob and Carol (with Alice = 3 total) + refund_keys: Some(vec![dave_pubkey, eve_pubkey]), // Dave and Eve for refund + num_sigs: Some(2), // Need 2 signatures before locktime + sig_flag: SigFlag::SigAll, // SIG_ALL flag + num_sigs_refund: Some(1), // Need 1 signature after locktime + }), + ); + println!("Created complex P2PK with SIG_ALL: 2-of-3 before locktime, 1-of-2 after locktime"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with primary keys (Alice + Bob) AFTER locktime (should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_primary = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice + Bob (primary multisig) using SIG_ALL + swap_request_primary + .sign_sig_all(alice_secret.clone()) + .unwrap(); + swap_request_primary + .sign_sig_all(bob_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_primary).await; + assert!( + result.is_err(), + "Should fail - locktime expired, only refund keys valid" + ); + println!( + "✓ Spending with primary keys (Alice + Bob) AFTER locktime failed as expected: {:?}", + result.err() + ); + + // Step 7: Spend with refund key (Dave) AFTER locktime (should succeed - only need 1-of-2) + let mut swap_request_refund = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Dave only (refund key, need 1-of-2) using SIG_ALL + swap_request_refund + .sign_sig_all(dave_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_refund).await; + assert!( + result.is_ok(), + "Should succeed - refund key can spend after locktime: {:?}", + result.err() + ); + println!("✓ Spending with refund key (Dave, 1-of-2) AFTER locktime succeeded"); +} + +/// Test: SIG_ALL with mixed proofs (different data) should fail +/// +/// Per NUT-11, when any proof has SIG_ALL, all proofs must have: +/// 1. Same kind, 2. SIG_ALL flag, 3. Same data, 4. Same tags +/// This test verifies that mixing proofs with different pubkeys (different data) is rejected. +#[tokio::test] +async fn test_p2pk_sig_all_mixed_proofs_different_data() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Create two different keypairs + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + + println!("Alice pubkey: {}", alice_pubkey); + println!("Bob pubkey: {}", bob_pubkey); + + // Step 1: Mint regular proofs for Alice + let alice_input_amount = Amount::from(10); + let alice_input_proofs = test_mint.mint_proofs(alice_input_amount).await.unwrap(); + + // Step 2: Create Alice's P2PK spending conditions with SIG_ALL + let alice_spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: None, + pubkeys: None, + refund_keys: None, + num_sigs: None, + sig_flag: SigFlag::SigAll, + num_sigs_refund: None, + }), + ); + + // Step 3: Swap for Alice's P2PK proofs + let alice_split_amounts = test_mint.split_amount(alice_input_amount).unwrap(); + let (alice_outputs, alice_blinding_factors, alice_secrets) = unzip3( + alice_split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &alice_spending_conditions)) + .collect(), + ); + + let swap_request_alice = + cdk_common::nuts::SwapRequest::new(alice_input_proofs, alice_outputs.clone()); + let swap_response_alice = mint.process_swap_request(swap_request_alice).await.unwrap(); + + let alice_proofs = construct_proofs( + swap_response_alice.signatures.clone(), + alice_blinding_factors.clone(), + alice_secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + println!( + "Created {} Alice proofs (locked to Alice with SIG_ALL)", + alice_proofs.len() + ); + + // Step 4: Mint regular proofs for Bob + let bob_input_amount = Amount::from(10); + let bob_input_proofs = test_mint.mint_proofs(bob_input_amount).await.unwrap(); + + // Step 5: Create Bob's P2PK spending conditions with SIG_ALL (different data!) + let bob_spending_conditions = SpendingConditions::new_p2pk( + bob_pubkey, + Some(Conditions { + locktime: None, + pubkeys: None, + refund_keys: None, + num_sigs: None, + sig_flag: SigFlag::SigAll, + num_sigs_refund: None, + }), + ); + + // Step 6: Swap for Bob's P2PK proofs + let bob_split_amounts = test_mint.split_amount(bob_input_amount).unwrap(); + let (bob_outputs, bob_blinding_factors, bob_secrets) = unzip3( + bob_split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &bob_spending_conditions)) + .collect(), + ); + + let swap_request_bob = + cdk_common::nuts::SwapRequest::new(bob_input_proofs, bob_outputs.clone()); + let swap_response_bob = mint.process_swap_request(swap_request_bob).await.unwrap(); + + let bob_proofs = construct_proofs( + swap_response_bob.signatures.clone(), + bob_blinding_factors.clone(), + bob_secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + println!( + "Created {} Bob proofs (locked to Bob with SIG_ALL)", + bob_proofs.len() + ); + + // Step 7: Try to spend Alice's and Bob's proofs together in one transaction (should FAIL!) + // This violates NUT-11 requirement that all SIG_ALL proofs must have same data + let total_amount = alice_input_amount + bob_input_amount; + let (new_outputs, _) = create_test_blinded_messages(mint, total_amount) + .await + .unwrap(); + + let mut mixed_proofs = alice_proofs.clone(); + mixed_proofs.extend(bob_proofs.clone()); + + let mut swap_request_mixed = + cdk_common::nuts::SwapRequest::new(mixed_proofs, new_outputs.clone()); + + // Sign with both Alice's and Bob's keys (no client-side validation, so this succeeds) + swap_request_mixed + .sign_sig_all(alice_secret.clone()) + .unwrap(); + swap_request_mixed.sign_sig_all(bob_secret.clone()).unwrap(); + + // But the mint should reject it due to mismatched data, even though both signed + let result = mint.process_swap_request(swap_request_mixed).await; + assert!(result.is_err(), "Should fail - cannot mix proofs with different data in SIG_ALL transaction, even with both signatures"); + + let error_msg = format!("{:?}", result.err().unwrap()); + println!( + "✓ Mixing Alice and Bob proofs in SIG_ALL transaction failed at mint verification: {}", + error_msg + ); + + // Step 8: Alice should be able to spend her proofs alone (should succeed) + let (alice_new_outputs, _) = create_test_blinded_messages(mint, alice_input_amount) + .await + .unwrap(); + let mut swap_request_alice_only = + cdk_common::nuts::SwapRequest::new(alice_proofs.clone(), alice_new_outputs.clone()); + swap_request_alice_only + .sign_sig_all(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_alice_only).await; + assert!( + result.is_ok(), + "Should succeed - Alice spending her own proofs: {:?}", + result.err() + ); + println!("✓ Alice successfully spent her own proofs separately"); + + // Step 9: Bob should be able to spend his proofs alone (should succeed) + let (bob_new_outputs, _) = create_test_blinded_messages(mint, bob_input_amount) + .await + .unwrap(); + let mut swap_request_bob_only = + cdk_common::nuts::SwapRequest::new(bob_proofs.clone(), bob_new_outputs.clone()); + swap_request_bob_only + .sign_sig_all(bob_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_bob_only).await; + assert!( + result.is_ok(), + "Should succeed - Bob spending his own proofs: {:?}", + result.err() + ); + println!("✓ Bob successfully spent his own proofs separately"); +} + +/// Test: P2PK multisig BEFORE locktime expires (2-of-3) - SIG_ALL +/// +/// Tests that a 2-of-3 multisig with SIG_ALL works correctly BEFORE locktime expires. +/// This complements the existing test that verifies refund keys work AFTER locktime. +#[tokio::test] +async fn test_p2pk_sig_all_multisig_before_locktime() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Create 3 keypairs for primary multisig (Alice, Bob, Carol) + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (_carol_secret, carol_pubkey) = create_test_keypair(); + + // Create refund keys (Dave, Eve) - won't be used since we're before locktime + let (_dave_secret, dave_pubkey) = create_test_keypair(); + let (_eve_secret, eve_pubkey) = create_test_keypair(); + + let locktime = unix_time() + 3600; // Locktime is 1 hour in the future + + println!("Primary multisig: Alice, Bob, Carol (need 2-of-3)"); + println!("Refund multisig: Dave, Eve (need 1-of-2)"); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expires in 1 hour)", locktime); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create complex conditions with SIG_ALL + // Before locktime: 2-of-3 (Alice, Bob, Carol) + // After locktime: 1-of-2 (Dave, Eve) + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // 1 hour in the future + pubkeys: Some(vec![bob_pubkey, carol_pubkey]), // Bob and Carol (with Alice = 3 total) + refund_keys: Some(vec![dave_pubkey, eve_pubkey]), // Dave and Eve for refund + num_sigs: Some(2), // Need 2 signatures before locktime + sig_flag: SigFlag::SigAll, // SIG_ALL flag + num_sigs_refund: Some(1), // Need 1 signature after locktime + }), + ); + println!("Created complex P2PK with SIG_ALL: 2-of-3 before locktime, 1-of-2 after locktime"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with only 1 signature (Alice) BEFORE locktime (should fail - need 2-of-3) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_one_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice only (need 2-of-3) + swap_request_one_sig + .sign_sig_all(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_one_sig).await; + assert!( + result.is_err(), + "Should fail - need 2-of-3 signatures before locktime" + ); + println!( + "✓ Spending with only 1 signature (Alice) BEFORE locktime failed as expected: {:?}", + result.err() + ); + + // Step 7: Spend with 2 signatures (Alice + Bob) BEFORE locktime (should succeed - 2-of-3) + let mut swap_request_two_sigs = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice + Bob (2-of-3, should succeed) + swap_request_two_sigs + .sign_sig_all(alice_secret.clone()) + .unwrap(); + swap_request_two_sigs + .sign_sig_all(bob_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_two_sigs).await; + assert!( + result.is_ok(), + "Should succeed - 2-of-3 signatures before locktime: {:?}", + result.err() + ); + println!("✓ Spending with 2 signatures (Alice + Bob, 2-of-3) BEFORE locktime succeeded"); +} + +/// Test: P2PK with more signatures than required - SIG_ALL +/// +/// Tests that providing MORE valid signatures than required succeeds. +/// For example, 3 valid signatures for a 2-of-3 multisig should work fine. +#[tokio::test] +async fn test_p2pk_sig_all_more_signatures_than_required() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Create 3 keypairs for multisig (Alice, Bob, Carol) + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (carol_secret, carol_pubkey) = create_test_keypair(); + + println!("Multisig: Alice, Bob, Carol (need 2-of-3)"); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create 2-of-3 multisig conditions with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: None, + pubkeys: Some(vec![bob_pubkey, carol_pubkey]), // Bob and Carol (with Alice = 3 total) + refund_keys: None, + num_sigs: Some(2), // Need 2 signatures (but we'll provide 3) + sig_flag: SigFlag::SigAll, + num_sigs_refund: None, + }), + ); + println!("Created 2-of-3 multisig with SIG_ALL"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Spend with ALL 3 signatures (Alice + Bob + Carol) even though only 2 required + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_all_sigs = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with all 3 keys (more than the required 2-of-3) + swap_request_all_sigs + .sign_sig_all(alice_secret.clone()) + .unwrap(); + swap_request_all_sigs + .sign_sig_all(bob_secret.clone()) + .unwrap(); + swap_request_all_sigs + .sign_sig_all(carol_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_all_sigs).await; + assert!( + result.is_ok(), + "Should succeed - 3 valid signatures when only 2-of-3 required: {:?}", + result.err() + ); + println!("✓ Spending with 3 signatures (all of Alice, Bob, Carol) when only 2-of-3 required succeeded"); +} + +/// Test: P2PK with 2-of-2 refund multisig after locktime - SIG_ALL +/// +/// Tests that after locktime expires, BOTH refund signatures are required (2-of-2). +/// Verifies that 1-of-2 fails and 2-of-2 succeeds. +#[tokio::test] +async fn test_p2pk_sig_all_refund_multisig_2of2() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Primary key (Alice) + let (_alice_secret, alice_pubkey) = create_test_keypair(); + + // Refund keys (Dave, Eve) - need both after locktime + let (dave_secret, dave_pubkey) = create_test_keypair(); + let (eve_secret, eve_pubkey) = create_test_keypair(); + + let locktime = unix_time() - 3600; // Already expired (1 hour ago) + + println!("Alice (primary)"); + println!("Dave, Eve (refund, need 2-of-2)"); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expired 1 hour ago)", locktime); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create conditions with 2-of-2 refund multisig and SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // Already expired + pubkeys: None, + refund_keys: Some(vec![dave_pubkey, eve_pubkey]), // Dave and Eve for refund + num_sigs: None, // Default (1) for primary + sig_flag: SigFlag::SigAll, + num_sigs_refund: Some(2), // Need BOTH refund signatures (2-of-2) + }), + ); + println!("Created P2PK with SIG_ALL: 2-of-2 refund multisig after locktime"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with only Dave's signature (1-of-2, should fail - need 2-of-2) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_one_refund = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Dave only (need both Dave and Eve) + swap_request_one_refund + .sign_sig_all(dave_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_one_refund).await; + assert!( + result.is_err(), + "Should fail - need 2-of-2 refund signatures" + ); + println!( + "✓ Spending with only 1 refund signature (Dave) AFTER locktime failed as expected: {:?}", + result.err() + ); + + // Step 7: Spend with both Dave and Eve (2-of-2, should succeed) + let mut swap_request_both_refunds = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with both Dave and Eve (2-of-2 refund multisig) + swap_request_both_refunds + .sign_sig_all(dave_secret.clone()) + .unwrap(); + swap_request_both_refunds + .sign_sig_all(eve_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_both_refunds).await; + assert!( + result.is_ok(), + "Should succeed - 2-of-2 refund signatures after locktime: {:?}", + result.err() + ); + println!("✓ Spending with 2-of-2 refund signatures (Dave + Eve) AFTER locktime succeeded"); +} + +/// Test: SIG_ALL should reject if output amounts are swapped +/// +/// Creates two P2PK proofs (8+2 sats) with SIG_ALL flag, swaps the output amounts +/// after signing, and verifies that the mint should reject this (but currently doesn't). +#[tokio::test] +async fn test_sig_all_should_reject_if_the_output_amounts_are_swapped() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for Alice + let (alice_secret, alice_pubkey) = create_test_keypair(); + println!("Alice pubkey: {}", alice_pubkey); + + // Step 1: Mint regular proofs (10 sats = 8+2) + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + println!("Minted {} sats", input_amount); + + // Step 2: Create P2PK spending conditions with SIG_ALL + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + None, // no locktime + None, // no additional pubkeys + None, // no refund keys + None, // default num_sigs (1) + Some(SigFlag::SigAll), // SIG_ALL flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + + // Step 3: Swap for P2PK proofs with SIG_ALL + let split_amounts = vec![Amount::from(8), Amount::from(2)]; + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + let swap_request = cdk_common::nuts::SwapRequest::new(input_proofs, p2pk_outputs); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures, + blinding_factors, + secrets, + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + println!("Created {} P2PK proofs with SIG_ALL", p2pk_proofs.len()); + assert_eq!(p2pk_proofs.len(), 2, "Should have 2 proofs (8+2)"); + + // Step 5: Create new swap request and sign with SIG_ALL + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request = cdk_common::nuts::SwapRequest::new(p2pk_proofs, new_outputs); + + // Inspect the outputs + println!("Outputs in swap request:"); + for (i, output) in swap_request.outputs().iter().enumerate() { + println!( + " Output {}: amount={}, blinded_secret={}", + i, + output.amount, + output.blinded_secret.to_hex() + ); + } + + // Sign the transaction with SIG_ALL + swap_request.sign_sig_all(alice_secret).unwrap(); + + // Swap the amounts of the two outputs + let outputs = swap_request.outputs_mut(); + let temp_amount = outputs[0].amount; + outputs[0].amount = outputs[1].amount; + outputs[1].amount = temp_amount; + + // Print outputs after swapping amounts + println!("Outputs after swapping amounts:"); + for (i, output) in swap_request.outputs().iter().enumerate() { + println!( + " Output {}: amount={}, blinded_secret={}", + i, + output.amount, + output.blinded_secret.to_hex() + ); + } + + // Step 6: Try to execute the swap - should now FAIL because the signature is invalid + let result = mint.process_swap_request(swap_request.clone()).await; + assert!( + result.is_err(), + "Swap should fail - amounts were tampered with after signing" + ); + println!("✓ Swap correctly rejected after output amounts were swapped!"); + println!(" Error: {:?}", result.err()); + + // Step 7: Swap the amounts back to original and verify it succeeds + let outputs = swap_request.outputs_mut(); + let temp_amount = outputs[0].amount; + outputs[0].amount = outputs[1].amount; + outputs[1].amount = temp_amount; + + println!("Outputs after swapping back to original:"); + for (i, output) in swap_request.outputs().iter().enumerate() { + println!( + " Output {}: amount={}, blinded_secret={}", + i, + output.amount, + output.blinded_secret.to_hex() + ); + } + + let result = mint.process_swap_request(swap_request).await; + assert!( + result.is_ok(), + "Swap should succeed with original amounts: {:?}", + result.err() + ); + println!("✓ Swap succeeded after restoring original amounts!"); +} diff --git a/crates/cdk/src/mint/swap/tests/p2pk_spending_conditions_tests.rs b/crates/cdk/src/mint/swap/tests/p2pk_spending_conditions_tests.rs new file mode 100644 index 000000000..c6d8741f1 --- /dev/null +++ b/crates/cdk/src/mint/swap/tests/p2pk_spending_conditions_tests.rs @@ -0,0 +1,804 @@ +//! P2PK (NUT-11) tests for swap functionality +//! +//! These tests verify that the mint correctly validates P2PK spending conditions +//! during swap operations, including: +//! - Single signature P2PK +//! - Multisig (m-of-n) +//! - Locktime enforcement +//! - Refund keys +//! - Signature validation + +use cdk_common::dhke::construct_proofs; +use cdk_common::nuts::{Conditions, SigFlag, SpendingConditions}; +use cdk_common::Amount; + +use crate::test_helpers::mint::create_test_blinded_messages; +use crate::test_helpers::nut10::{create_test_keypair, unzip3, TestMintHelper}; +use crate::util::unix_time; + +/// Test: P2PK with single pubkey requires all proofs signed +/// +/// Creates proofs locked to a single public key and verifies: +/// 1. Spending without any signatures is rejected +/// 2. Spending with partial signatures (only some proofs signed) is rejected +/// 3. Spending with all proofs signed succeeds +#[tokio::test] +async fn test_p2pk_single_pubkey_requires_all_proofs_signed() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypair for P2PK + let (alice_secret, alice_pubkey) = create_test_keypair(); + println!("Alice pubkey: {}", alice_pubkey); + + // Step 1: Create regular unencumbered proofs that we'll swap for P2PK proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create P2PK blinded messages (outputs locked to alice_pubkey) + let spending_conditions = SpendingConditions::new_p2pk(alice_pubkey, None); + + // Split the input amount into power-of-2 denominations + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Split {} into [{}]", input_amount, split_display.join("+")); + + // Create blinded messages for each split amount + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + println!( + "Created {} P2PK outputs locked to alice", + p2pk_outputs.len() + ); + + // Step 3: Swap regular proofs for P2PK proofs (no signature needed on inputs) + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint + .process_swap_request(swap_request) + .await + .expect("Failed to swap for P2PK proofs"); + println!("Swap successful! Got BlindSignatures for our P2PK outputs"); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + let proof_amounts: Vec = p2pk_proofs.iter().map(|p| p.amount.to_string()).collect(); + println!( + "Constructed {} P2PK proof(s) [{}]", + p2pk_proofs.len(), + proof_amounts.join("+") + ); + + // Step 5: Try to spend P2PK proof WITHOUT signature (should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let swap_request_no_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + let result = mint.process_swap_request(swap_request_no_sig).await; + assert!(result.is_err(), "Should fail without signature"); + println!( + "✓ Spending WITHOUT signature failed as expected: {:?}", + result.err() + ); + + // Step 6: Sign only ONE of the proofs and try (should fail - need all signatures) + let mut swap_request_partial_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign only the first proof + swap_request_partial_sig.inputs_mut()[0] + .sign_p2pk(alice_secret.clone()) + .unwrap(); + + let result = mint.process_swap_request(swap_request_partial_sig).await; + assert!(result.is_err(), "Should fail with only partial signatures"); + println!( + "✓ Spending with PARTIAL signatures failed as expected: {:?}", + result.err() + ); + + // Step 7: Now sign ALL the proofs and try again (should succeed) + let mut swap_request_with_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign all the P2PK proofs with Alice's key + for proof in swap_request_with_sig.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_with_sig).await; + assert!( + result.is_ok(), + "Should succeed with valid signature: {:?}", + result.err() + ); + println!("✓ Spending WITH ALL signatures succeeded"); +} + +/// Test: P2PK multisig (2-of-3) +/// +/// Creates proofs requiring 2 signatures from a set of 3 public keys and verifies: +/// 1. Spending with only 1 valid signature fails (Alice only) +/// 2. Spending with 2 invalid signatures fails (wrong keys) +/// 3. Spending with 2 valid signatures succeeds (Alice + Bob) +#[tokio::test] +async fn test_p2pk_multisig_2of3() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate 3 keypairs for the multisig + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (_carol_secret, carol_pubkey) = create_test_keypair(); + + // Generate 2 wrong keypairs (not in the multisig set) + let (dave_secret, _dave_pubkey) = create_test_keypair(); + let (eve_secret, _eve_pubkey) = create_test_keypair(); + + println!("Alice: {}", alice_pubkey); + println!("Bob: {}", bob_pubkey); + println!("Carol: {}", carol_pubkey); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create 2-of-3 multisig conditions + // Primary key: Alice + // Additional keys: Bob, Carol + // Requires 2 signatures total + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + None, // no locktime + Some(vec![bob_pubkey, carol_pubkey]), // additional pubkeys + None, // no refund keys + Some(2), // require 2 signatures + None, // default sig_flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + println!("Created 2-of-3 multisig spending conditions (Alice, Bob, Carol)"); + + // Step 3: Create P2PK blinded messages with multisig conditions + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK multisig proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + println!("Created P2PK multisig proofs (2-of-3)"); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with only 1 signature (Alice only - should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_one_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with only Alice + for proof in swap_request_one_sig.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_one_sig).await; + assert!( + result.is_err(), + "Should fail with only 1 signature (need 2)" + ); + println!( + "✓ Spending with only 1 signature (Alice) failed as expected: {:?}", + result.err() + ); + + // Step 7: Try to spend with 2 invalid signatures (Dave + Eve - not in multisig set) + let mut swap_request_invalid_sigs = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Dave and Eve (wrong keys!) + for proof in swap_request_invalid_sigs.inputs_mut() { + proof.sign_p2pk(dave_secret.clone()).unwrap(); + proof.sign_p2pk(eve_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_invalid_sigs).await; + assert!(result.is_err(), "Should fail with 2 invalid signatures"); + println!( + "✓ Spending with 2 INVALID signatures (Dave + Eve) failed as expected: {:?}", + result.err() + ); + + // Step 8: Spend with 2 valid signatures (Alice + Bob - should succeed) + let mut swap_request_valid_sigs = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice and Bob + for proof in swap_request_valid_sigs.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_valid_sigs).await; + assert!( + result.is_ok(), + "Should succeed with 2 valid signatures: {:?}", + result.err() + ); + println!("✓ Spending with 2 VALID signatures (Alice + Bob) succeeded"); +} + +/// Test: P2PK with locktime (before expiry) +/// +/// Verifies that before locktime expires: +/// 1. Spending with primary key (Alice) succeeds +/// 2. Spending with refund key (Bob) fails +#[tokio::test] +async fn test_p2pk_locktime_before_expiry() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + + // Set locktime 1 hour in the future + let locktime = unix_time() + 3600; + + println!("Alice (primary): {}", alice_pubkey); + println!("Bob (refund): {}", bob_pubkey); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expires in 1 hour)", locktime); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create conditions with Alice as primary and Bob as refund key + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + Some(locktime), // locktime in the future + None, // no additional pubkeys + Some(vec![bob_pubkey]), // Bob is refund key + None, // default num_sigs (1) + None, // default sig_flag + None, // default num_sigs_refund (1) + ) + .unwrap(), + ), + ); + println!("Created P2PK with locktime and refund key"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with refund key (Bob) BEFORE locktime expires (should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_refund = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Bob (refund key) + for proof in swap_request_refund.inputs_mut() { + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_refund).await; + assert!( + result.is_err(), + "Should fail - refund key cannot spend before locktime" + ); + println!( + "✓ Spending with refund key (Bob) BEFORE locktime failed as expected: {:?}", + result.err() + ); + + // Step 7: Spend with primary key (Alice) BEFORE locktime (should succeed) + let mut swap_request_primary = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice (primary key) + for proof in swap_request_primary.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_primary).await; + assert!( + result.is_ok(), + "Should succeed - primary key can spend before locktime: {:?}", + result.err() + ); + println!("✓ Spending with primary key (Alice) BEFORE locktime succeeded"); +} + +/// Test: P2PK with locktime (after expiry) +/// +/// Verifies that after locktime expires: +/// 1. Spending with refund key (Bob) succeeds +/// 2. Spending with primary key (Alice) fails +#[tokio::test] +async fn test_p2pk_locktime_after_expiry() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + + // Set locktime in the past (already expired) + let locktime = unix_time() - 3600; + + println!("Alice (primary): {}", alice_pubkey); + println!("Bob (refund): {}", bob_pubkey); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expired 1 hour ago)", locktime); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create conditions with Alice as primary and Bob as refund key + // Note: We create the Conditions struct directly to bypass the validation + // that rejects locktimes in the past (since we're testing the expired case) + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // locktime in the past (expired) + pubkeys: None, // no additional pubkeys + refund_keys: Some(vec![bob_pubkey]), // Bob is refund key + num_sigs: None, // default (1) + sig_flag: SigFlag::default(), + num_sigs_refund: None, // default (1) + }), + ); + println!("Created P2PK with expired locktime and refund key"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with primary key (Alice) AFTER locktime expires (should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_primary = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice (primary key) + for proof in swap_request_primary.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_primary).await; + assert!( + result.is_err(), + "Should fail - primary key cannot spend after locktime expires" + ); + println!( + "✓ Spending with primary key (Alice) AFTER locktime failed as expected: {:?}", + result.err() + ); + + // Step 7: Spend with refund key (Bob) AFTER locktime (should succeed) + let mut swap_request_refund = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Bob (refund key) + for proof in swap_request_refund.inputs_mut() { + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_refund).await; + assert!( + result.is_ok(), + "Should succeed - refund key can spend after locktime: {:?}", + result.err() + ); + println!("✓ Spending with refund key (Bob) AFTER locktime succeeded"); +} + +/// Test: P2PK with locktime after expiry, no refund keys (anyone can spend) +/// +/// Verifies that after locktime expires with NO refund keys configured, +/// anyone can spend the proofs without providing any signatures at all. +#[tokio::test] +async fn test_p2pk_locktime_after_expiry_no_refund_anyone_can_spend() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (_alice_secret, alice_pubkey) = create_test_keypair(); + + // Set locktime in the past (already expired) + let locktime = unix_time() - 3600; + + println!("Alice (primary): {}", alice_pubkey); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expired 1 hour ago)", locktime); + println!("No refund keys configured - anyone can spend after locktime"); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create conditions with Alice as primary, NO refund keys + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // locktime in the past (expired) + pubkeys: None, // no additional pubkeys + refund_keys: None, // NO refund keys - anyone can spend! + num_sigs: None, // default (1) + sig_flag: SigFlag::default(), + num_sigs_refund: None, // default (1) + }), + ); + println!("Created P2PK with expired locktime and NO refund keys"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Spend WITHOUT any signatures (should succeed - anyone can spend!) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let swap_request_no_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // No signatures added at all! + + let result = mint.process_swap_request(swap_request_no_sig).await; + assert!( + result.is_ok(), + "Should succeed - anyone can spend after locktime with no refund keys: {:?}", + result.err() + ); + println!("✓ Spending WITHOUT any signatures succeeded (anyone can spend)"); +} + +/// Test: P2PK multisig with locktime (2-of-3 before, 1-of-2 after) +/// +/// Complex scenario: Different multisig requirements before and after locktime +/// Before locktime: Need 2-of-3 from (Alice, Bob, Carol) +/// After locktime: Need 1-of-2 from (Dave, Eve) as refund keys +#[tokio::test] +async fn test_p2pk_multisig_locktime() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Before locktime: Need 2-of-3 from (Alice, Bob, Carol) + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, bob_pubkey) = create_test_keypair(); + let (_carol_secret, carol_pubkey) = create_test_keypair(); + + // After locktime: Need 1-of-2 from (Dave, Eve) as refund keys + let (dave_secret, dave_pubkey) = create_test_keypair(); + let (_eve_secret, eve_pubkey) = create_test_keypair(); + + let locktime = unix_time() - 100; // Already expired + + println!("Primary multisig: Alice, Bob, Carol (need 2-of-3)"); + println!("Refund multisig: Dave, Eve (need 1-of-2)"); + println!("Current time: {}", unix_time()); + println!("Locktime: {} (expired)", locktime); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create complex conditions + // Before locktime: 2-of-3 (Alice, Bob, Carol) + // After locktime: 1-of-2 (Dave, Eve) + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some(Conditions { + locktime: Some(locktime), // Already expired + pubkeys: Some(vec![bob_pubkey, carol_pubkey]), // Bob and Carol (with Alice = 3 total) + refund_keys: Some(vec![dave_pubkey, eve_pubkey]), // Dave and Eve for refund + num_sigs: Some(2), // Need 2 signatures before locktime + sig_flag: SigFlag::default(), + num_sigs_refund: Some(1), // Need 1 signature after locktime + }), + ); + println!("Created complex P2PK: 2-of-3 before locktime, 1-of-2 after locktime"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with primary keys (Alice + Bob) AFTER locktime (should fail) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_primary = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice + Bob (primary multisig) + for proof in swap_request_primary.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_primary).await; + assert!( + result.is_err(), + "Should fail - locktime expired, only refund keys valid" + ); + println!( + "✓ Spending with primary keys (Alice + Bob) AFTER locktime failed as expected: {:?}", + result.err() + ); + + // Step 7: Spend with refund key (Dave) AFTER locktime (should succeed - only need 1-of-2) + let mut swap_request_refund = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Dave only (refund key, need 1-of-2) + for proof in swap_request_refund.inputs_mut() { + proof.sign_p2pk(dave_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_refund).await; + assert!( + result.is_ok(), + "Should succeed - refund key can spend after locktime: {:?}", + result.err() + ); + println!("✓ Spending with refund key (Dave, 1-of-2) AFTER locktime succeeded"); +} + +/// Test: P2PK signed by wrong person is rejected +/// +/// Creates proofs locked to Alice's public key and verifies that +/// signing with Bob's key (wrong key) is rejected +#[tokio::test] +async fn test_p2pk_signed_by_wrong_person() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + // Generate keypairs for Alice and Bob + let (_alice_secret, alice_pubkey) = create_test_keypair(); + let (bob_secret, _bob_pubkey) = create_test_keypair(); + println!("Alice pubkey: {}", alice_pubkey); + println!("Bob will try to spend Alice's proofs"); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create P2PK blinded messages locked to Alice's pubkey + let spending_conditions = SpendingConditions::new_p2pk(alice_pubkey, None); + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 3: Swap for P2PK proofs locked to Alice + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + println!("Created P2PK proofs locked to Alice"); + + // Step 4: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 5: Try to spend Alice's proofs by signing with Bob's key (wrong key!) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_wrong_sig = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Bob's key instead of Alice's key + for proof in swap_request_wrong_sig.inputs_mut() { + proof.sign_p2pk(bob_secret.clone()).unwrap(); + } + + let result = mint.process_swap_request(swap_request_wrong_sig).await; + assert!(result.is_err(), "Should fail when signed with wrong key"); + println!( + "✓ Spending signed by wrong person failed as expected: {:?}", + result.err() + ); +} + +/// Test: Duplicate signatures are rejected +/// +/// Verifies that using the same signature twice doesn't count as multiple signers +/// in a 2-of-2 multisig scenario +#[tokio::test] +async fn test_p2pk_duplicate_signatures() { + let test_mint = TestMintHelper::new().await.unwrap(); + let mint = test_mint.mint(); + + let (alice_secret, alice_pubkey) = create_test_keypair(); + let (_bob_secret, bob_pubkey) = create_test_keypair(); + + println!("Alice: {}", alice_pubkey); + println!("Bob: {}", bob_pubkey); + + // Step 1: Mint regular proofs + let input_amount = Amount::from(10); + let input_proofs = test_mint.mint_proofs(input_amount).await.unwrap(); + + // Step 2: Create 2-of-2 multisig (Alice and Bob, need both) + let spending_conditions = SpendingConditions::new_p2pk( + alice_pubkey, + Some( + Conditions::new( + None, // no locktime + Some(vec![bob_pubkey]), // Bob is additional pubkey + None, // no refund keys + Some(2), // require 2 signatures (Alice + Bob) + None, // default sig_flag + None, // no num_sigs_refund + ) + .unwrap(), + ), + ); + println!("Created 2-of-2 multisig (Alice, Bob)"); + + // Step 3: Create P2PK blinded messages + let split_amounts = test_mint.split_amount(input_amount).unwrap(); + let (p2pk_outputs, blinding_factors, secrets) = unzip3( + split_amounts + .iter() + .map(|&amt| test_mint.create_blinded_message(amt, &spending_conditions)) + .collect(), + ); + + // Step 4: Swap for P2PK proofs + let swap_request = + cdk_common::nuts::SwapRequest::new(input_proofs.clone(), p2pk_outputs.clone()); + let swap_response = mint.process_swap_request(swap_request).await.unwrap(); + + // Step 5: Construct the P2PK proofs + let p2pk_proofs = construct_proofs( + swap_response.signatures.clone(), + blinding_factors.clone(), + secrets.clone(), + &test_mint.public_keys_of_the_active_sat_keyset, + ) + .unwrap(); + + // Step 6: Try to spend with Alice's signature TWICE (should fail - need Alice + Bob, not Alice + Alice) + let (new_outputs, _) = create_test_blinded_messages(mint, input_amount) + .await + .unwrap(); + let mut swap_request_duplicate = + cdk_common::nuts::SwapRequest::new(p2pk_proofs.clone(), new_outputs.clone()); + + // Sign with Alice twice instead of Alice + Bob + for proof in swap_request_duplicate.inputs_mut() { + proof.sign_p2pk(alice_secret.clone()).unwrap(); + proof.sign_p2pk(alice_secret.clone()).unwrap(); // Duplicate! + } + + let result = mint.process_swap_request(swap_request_duplicate).await; + assert!( + result.is_err(), + "Should fail - duplicate signatures not allowed" + ); + println!( + "✓ Spending with duplicate signatures (Alice + Alice) failed as expected: {:?}", + result.err() + ); +} diff --git a/crates/cdk/src/mint/verification.rs b/crates/cdk/src/mint/verification.rs index 1a4c024e1..5534a7d8f 100644 --- a/crates/cdk/src/mint/verification.rs +++ b/crates/cdk/src/mint/verification.rs @@ -58,10 +58,7 @@ impl Mint { /// /// Checks that the outputs are all of the same unit and the keyset is active #[instrument(skip_all)] - pub async fn verify_outputs_keyset( - &self, - outputs: &[BlindedMessage], - ) -> Result { + pub fn verify_outputs_keyset(&self, outputs: &[BlindedMessage]) -> Result { let mut keyset_units = HashSet::new(); let output_keyset_ids: HashSet = outputs.iter().map(|p| p.keyset_id).collect(); @@ -189,7 +186,7 @@ impl Mint { Mint::check_outputs_unique(outputs)?; self.check_output_already_signed(tx, outputs).await?; - let unit = self.verify_outputs_keyset(outputs).await?; + let unit = self.verify_outputs_keyset(outputs)?; let amount = Amount::try_sum(outputs.iter().map(|o| o.amount).collect::>())?; @@ -221,6 +218,7 @@ impl Mint { pub async fn verify_transaction_balanced( &self, tx: &mut Box + Send + Sync + '_>, + input_verification: Verification, inputs: &Proofs, outputs: &[BlindedMessage], ) -> Result<(), Error> { @@ -228,10 +226,6 @@ impl Mint { tracing::debug!("Output verification failed: {:?}", err); err })?; - let input_verification = self.verify_inputs(inputs).await.map_err(|err| { - tracing::debug!("Input verification failed: {:?}", err); - err - })?; if output_verification.unit != input_verification.unit { tracing::debug!( diff --git a/crates/cdk/src/oidc_client.rs b/crates/cdk/src/oidc_client.rs index a01f2368e..59d10ffc7 100644 --- a/crates/cdk/src/oidc_client.rs +++ b/crates/cdk/src/oidc_client.rs @@ -32,9 +32,9 @@ pub enum Error { /// Unsupported Algo #[error("Unsupported signing algo")] UnsupportedSigningAlgo, - /// Access token not returned - #[error("Error getting access token")] - AccessTokenMissing, + /// Invalid Client ID + #[error("Invalid Client ID")] + InvalidClientId, } impl From for cdk_common::error::Error { @@ -58,6 +58,7 @@ pub struct OidcConfig { pub struct OidcClient { client: Client, openid_discovery: String, + client_id: Option, oidc_config: Arc>>, jwks_set: Arc>>, } @@ -88,10 +89,11 @@ pub struct TokenResponse { impl OidcClient { /// Create new [`OidcClient`] - pub fn new(openid_discovery: String) -> Self { + pub fn new(openid_discovery: String, client_id: Option) -> Self { Self { client: Client::new(), openid_discovery, + client_id, oidc_config: Arc::new(RwLock::new(None)), jwks_set: Arc::new(RwLock::new(None)), } @@ -192,11 +194,40 @@ impl OidcClient { validation }; - if let Err(err) = - decode::>(cat_jwt, &decoding_key, &validation) - { - tracing::debug!("Could not verify cat: {}", err); - return Err(err.into()); + match decode::>(cat_jwt, &decoding_key, &validation) { + Ok(claims) => { + tracing::debug!("Successfully verified cat"); + tracing::debug!("Claims: {:?}", claims.claims); + if let Some(client_id) = &self.client_id { + if let Some(token_client_id) = claims.claims.get("client_id") { + if let Some(token_client_id_value) = token_client_id.as_str() { + if token_client_id_value != client_id { + tracing::warn!( + "Client ID mismatch: expected {}, got {}", + client_id, + token_client_id_value + ); + return Err(Error::InvalidClientId); + } + } + } else if let Some(azp) = claims.claims.get("azp") { + if let Some(azp_value) = azp.as_str() { + if azp_value != client_id { + tracing::warn!( + "Client ID (azp) mismatch: expected {}, got {}", + client_id, + azp_value + ); + return Err(Error::InvalidClientId); + } + } + } + } + } + Err(err) => { + tracing::debug!("Could not verify cat: {}", err); + return Err(err.into()); + } } Ok(()) diff --git a/crates/cdk/src/pub_sub.rs b/crates/cdk/src/pub_sub.rs deleted file mode 100644 index ceec2ed3d..000000000 --- a/crates/cdk/src/pub_sub.rs +++ /dev/null @@ -1,326 +0,0 @@ -//! Publish–subscribe pattern. -//! -//! This is a generic implementation for -//! [NUT-17]() with a type -//! agnostic Publish-subscribe manager. -//! -//! The manager has a method for subscribers to subscribe to events with a -//! generic type that must be converted to a vector of indexes. -//! -//! Events are also generic that should implement the `Indexable` trait. -use std::cmp::Ordering; -use std::collections::{BTreeMap, HashSet}; -use std::fmt::Debug; -use std::ops::{Deref, DerefMut}; -use std::sync::atomic::{self, AtomicUsize}; -use std::sync::Arc; - -pub use cdk_common::pub_sub::index::{Index, Indexable, SubscriptionGlobalId}; -use cdk_common::pub_sub::OnNewSubscription; -pub use cdk_common::pub_sub::SubId; -use tokio::sync::{mpsc, RwLock}; -use tokio::task::JoinHandle; - -type IndexTree = Arc, mpsc::Sender<(SubId, T)>>>>; - -/// Default size of the remove channel -pub const DEFAULT_REMOVE_SIZE: usize = 10_000; - -/// Default channel size for subscription buffering -pub const DEFAULT_CHANNEL_SIZE: usize = 10; - -/// Subscription manager -/// -/// This object keep track of all subscription listener and it is also -/// responsible for broadcasting events to all listeners -/// -/// The content of the notification is not relevant to this scope and it is up -/// to the application, therefore the generic T is used instead of a specific -/// type -pub struct Manager -where - T: Indexable + Clone + Send + Sync + 'static, - I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, -{ - indexes: IndexTree, - on_new_subscription: Option, - unsubscription_sender: mpsc::Sender<(SubId, Vec>)>, - active_subscriptions: Arc, - background_subscription_remover: Option>, -} - -impl Default for Manager -where - T: Indexable + Clone + Send + Sync + 'static, - I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, -{ - fn default() -> Self { - let (sender, receiver) = mpsc::channel(DEFAULT_REMOVE_SIZE); - let active_subscriptions: Arc = Default::default(); - let storage: IndexTree = Arc::new(Default::default()); - - Self { - background_subscription_remover: Some(tokio::spawn(Self::remove_subscription( - receiver, - storage.clone(), - active_subscriptions.clone(), - ))), - on_new_subscription: None, - unsubscription_sender: sender, - active_subscriptions, - indexes: storage, - } - } -} - -impl From for Manager -where - T: Indexable + Clone + Send + Sync + 'static, - I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, -{ - fn from(value: F) -> Self { - let mut manager: Self = Default::default(); - manager.on_new_subscription = Some(value); - manager - } -} - -impl Manager -where - T: Indexable + Clone + Send + Sync + 'static, - I: PartialOrd + Clone + Debug + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, -{ - #[inline] - /// Broadcast an event to all listeners - /// - /// This function takes an Arc to the storage struct, the event_id, the kind - /// and the vent to broadcast - async fn broadcast_impl(storage: &IndexTree, event: T) { - let index_storage = storage.read().await; - let mut sent = HashSet::new(); - for index in event.to_indexes() { - for (key, sender) in index_storage.range(index.clone()..) { - if index.cmp_prefix(key) != Ordering::Equal { - break; - } - let sub_id = key.unique_id(); - if sent.contains(&sub_id) { - continue; - } - sent.insert(sub_id); - let _ = sender.try_send((key.into(), event.clone())); - } - } - } - - /// Broadcasts an event to all listeners - /// - /// This public method will not block the caller, it will spawn a new task - /// instead - pub fn broadcast(&self, event: T) { - let storage = self.indexes.clone(); - tokio::spawn(async move { - Self::broadcast_impl(&storage, event).await; - }); - } - - /// Broadcasts an event to all listeners - /// - /// This method is async and will await for the broadcast to be completed - pub async fn broadcast_async(&self, event: T) { - Self::broadcast_impl(&self.indexes, event).await; - } - - /// Specific of the subscription, this is the abstraction between `subscribe` and `try_subscribe` - #[inline(always)] - async fn subscribe_inner( - &self, - sub_id: SubId, - indexes: Vec>, - ) -> ActiveSubscription { - let (sender, receiver) = mpsc::channel(10); - if let Some(on_new_subscription) = self.on_new_subscription.as_ref() { - match on_new_subscription - .on_new_subscription(&indexes.iter().map(|x| x.deref()).collect::>()) - .await - { - Ok(events) => { - for event in events { - let _ = sender.try_send((sub_id.clone(), event)); - } - } - Err(err) => { - tracing::info!( - "Failed to get initial state for subscription: {:?}, {}", - sub_id, - err - ); - } - } - } - - let mut index_storage = self.indexes.write().await; - for index in indexes.clone() { - index_storage.insert(index, sender.clone()); - } - drop(index_storage); - - self.active_subscriptions - .fetch_add(1, atomic::Ordering::Relaxed); - - ActiveSubscription { - sub_id, - receiver, - indexes, - drop: self.unsubscription_sender.clone(), - } - } - - /// Try to subscribe to a specific event - pub async fn try_subscribe + TryInto>>>( - &self, - params: P, - ) -> Result, P::Error> { - Ok(self - .subscribe_inner(params.as_ref().clone(), params.try_into()?) - .await) - } - - /// Subscribe to a specific event - pub async fn subscribe + Into>>>( - &self, - params: P, - ) -> ActiveSubscription { - self.subscribe_inner(params.as_ref().clone(), params.into()) - .await - } - - /// Return number of active subscriptions - pub fn active_subscriptions(&self) -> usize { - self.active_subscriptions.load(atomic::Ordering::SeqCst) - } - - /// Task to remove dropped subscriptions from the storage struct - /// - /// This task will run in the background (and will be dropped when the [`Manager`] - /// is) and will remove subscriptions from the storage struct it is dropped. - async fn remove_subscription( - mut receiver: mpsc::Receiver<(SubId, Vec>)>, - storage: IndexTree, - active_subscriptions: Arc, - ) { - while let Some((sub_id, indexes)) = receiver.recv().await { - tracing::info!("Removing subscription: {}", *sub_id); - - active_subscriptions.fetch_sub(1, atomic::Ordering::AcqRel); - - let mut index_storage = storage.write().await; - for key in indexes { - index_storage.remove(&key); - } - drop(index_storage); - } - } -} - -/// Manager goes out of scope, stop all background tasks -impl Drop for Manager -where - T: Indexable + Clone + Send + Sync + 'static, - I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, - F: OnNewSubscription + 'static, -{ - fn drop(&mut self) { - if let Some(handler) = self.background_subscription_remover.take() { - handler.abort(); - } - } -} - -/// Active Subscription -/// -/// This struct is a wrapper around the `mpsc::Receiver` and it also used -/// to keep track of the subscription itself. When this struct goes out of -/// scope, it will notify the Manager about it, so it can be removed from the -/// list of active listeners -pub struct ActiveSubscription -where - T: Send + Sync, - I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, -{ - /// The subscription ID - pub sub_id: SubId, - indexes: Vec>, - receiver: mpsc::Receiver<(SubId, T)>, - drop: mpsc::Sender<(SubId, Vec>)>, -} - -impl Deref for ActiveSubscription -where - T: Send + Sync, - I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, -{ - type Target = mpsc::Receiver<(SubId, T)>; - - fn deref(&self) -> &Self::Target { - &self.receiver - } -} - -impl DerefMut for ActiveSubscription -where - T: Indexable + Clone + Send + Sync + 'static, - I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, -{ - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.receiver - } -} - -/// The ActiveSubscription is Drop out of scope, notify the Manager about it, so -/// it can be removed from the list of active listeners -/// -/// Having this in place, we can avoid memory leaks and also makes it super -/// simple to implement the Unsubscribe method -impl Drop for ActiveSubscription -where - T: Send + Sync, - I: Clone + Debug + PartialOrd + Ord + Send + Sync + 'static, -{ - fn drop(&mut self) { - let _ = self - .drop - .try_send((self.sub_id.clone(), self.indexes.drain(..).collect())); - } -} - -#[cfg(test)] -mod test { - use tokio::sync::mpsc; - - use super::*; - - #[test] - fn test_active_subscription_drop() { - let (tx, rx) = mpsc::channel::<(SubId, ())>(10); - let sub_id = SubId::from("test_sub_id"); - let indexes: Vec> = vec![Index::from(("test".to_string(), sub_id.clone()))]; - let (drop_tx, mut drop_rx) = mpsc::channel(10); - - { - let _active_subscription = ActiveSubscription { - sub_id: sub_id.clone(), - indexes, - receiver: rx, - drop: drop_tx, - }; - // When it goes out of scope, it should notify - } - assert_eq!(drop_rx.try_recv().unwrap().0, sub_id); // it should have notified - assert!(tx.try_send(("foo".into(), ())).is_err()); // subscriber is dropped - } -} diff --git a/crates/cdk/src/test_helpers/mint.rs b/crates/cdk/src/test_helpers/mint.rs new file mode 100644 index 000000000..17884044c --- /dev/null +++ b/crates/cdk/src/test_helpers/mint.rs @@ -0,0 +1,211 @@ +#![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_ + 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 { + 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 { + // 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(); + + let keys = mint + .keyset_pubkeys(&keysets)? + .keysets + .first() + .unwrap() + .keys + .clone(); + + let fees: (u64, Vec) = (0, keys.iter().map(|a| a.0.to_u64()).collect::>()); + + 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, 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::>())).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 { + let keys = mint + .pubkeys() + .keysets + .first() + .ok_or(Error::Internal)? + .clone(); + keys.verify_id()?; + Ok(keys.id) +} diff --git a/crates/cdk/src/test_helpers/mod.rs b/crates/cdk/src/test_helpers/mod.rs new file mode 100644 index 000000000..1b88d53cb --- /dev/null +++ b/crates/cdk/src/test_helpers/mod.rs @@ -0,0 +1,11 @@ +//! 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; +#[cfg(feature = "mint")] +pub mod nut10; diff --git a/crates/cdk/src/test_helpers/nut10.rs b/crates/cdk/src/test_helpers/nut10.rs new file mode 100644 index 000000000..c4363096d --- /dev/null +++ b/crates/cdk/src/test_helpers/nut10.rs @@ -0,0 +1,145 @@ +#![cfg(test)] +//! Shared test helpers for spending condition tests (P2PK, HTLC, etc.) + +use cdk_common::dhke::blind_message; +use cdk_common::nuts::nut10::Secret as Nut10Secret; +use cdk_common::nuts::{ + BlindedMessage, CurrencyUnit, Id, Keys, PublicKey, SecretKey, SpendingConditions, +}; +use cdk_common::Amount; + +use crate::mint::Mint; +use crate::secret::Secret; +use crate::test_helpers::mint::{create_test_mint, mint_test_proofs}; +use crate::Error; + +/// Test mint wrapper with convenient access to common keyset info +pub struct TestMintHelper { + pub mint: Mint, + pub active_sat_keyset_id: Id, + pub public_keys_of_the_active_sat_keyset: Keys, + /// Available denominations sorted largest first (e.g., [2147483648, 1073741824, ..., 2, 1]) + pub available_amounts_sorted: Vec, +} + +impl TestMintHelper { + pub async fn new() -> Result { + let mint = create_test_mint().await?; + + // Get the active SAT keyset ID + let active_sat_keyset_id = mint + .get_active_keysets() + .get(&CurrencyUnit::Sat) + .cloned() + .ok_or(Error::Internal)?; + + // Get the active SAT keyset keys + let lookup_by_that_id = mint.keyset_pubkeys(&active_sat_keyset_id)?; + let active_sat_keyset = lookup_by_that_id.keysets.first().ok_or(Error::Internal)?; + assert_eq!( + active_sat_keyset.id, active_sat_keyset_id, + "Keyset ID mismatch" + ); + let public_keys_of_the_active_sat_keyset = active_sat_keyset.keys.clone(); + + // Get the available denominations from the keyset, sorted largest first + let mut available_amounts_sorted: Vec = public_keys_of_the_active_sat_keyset + .iter() + .map(|(amt, _)| amt.to_u64()) + .collect(); + available_amounts_sorted.sort_by(|a, b| b.cmp(a)); // Sort descending (largest first) + + Ok(TestMintHelper { + mint, + active_sat_keyset_id, + public_keys_of_the_active_sat_keyset, + available_amounts_sorted, + }) + } + + /// Get a reference to the underlying mint + pub fn mint(&self) -> &Mint { + &self.mint + } + + /// Split an amount into power-of-2 denominations + /// Returns the amounts that sum to the total (e.g., 10 -> [8, 2]) + pub fn split_amount(&self, amount: Amount) -> Result, Error> { + // Simple greedy algorithm: start from largest and work down + let mut result = Vec::new(); + let mut remaining = amount.to_u64(); + + for &amt in &self.available_amounts_sorted { + if remaining >= amt { + result.push(Amount::from(amt)); + remaining -= amt; + } + } + + if remaining != 0 { + return Err(Error::Internal); + } + + Ok(result) + } + + /// Mint proofs for the given amount + /// Prints a message like "Minted 10 sats [8+2]" + pub async fn mint_proofs(&self, amount: Amount) -> Result { + let proofs = mint_test_proofs(&self.mint, amount).await?; + + // Build the split display string (e.g., "8+2") + let split_amounts = self.split_amount(amount)?; + let split_display: Vec = split_amounts.iter().map(|a| a.to_string()).collect(); + println!("Minted {} sats [{}]", amount, split_display.join("+")); + + Ok(proofs) + } + + /// Create a single blinded message with spending conditions for the given amount + /// Returns (blinded_message, blinding_factor, secret) + pub fn create_blinded_message( + &self, + amount: Amount, + spending_conditions: &SpendingConditions, + ) -> (BlindedMessage, SecretKey, Secret) { + let nut10_secret: Nut10Secret = spending_conditions.clone().into(); + let secret: Secret = nut10_secret.try_into().unwrap(); + let (blinded_point, blinding_factor) = blind_message(&secret.to_bytes(), None).unwrap(); + let blinded_msg = BlindedMessage::new(amount, self.active_sat_keyset_id, blinded_point); + (blinded_msg, blinding_factor, secret) + } +} + +/// Helper: Create a keypair for testing +pub fn create_test_keypair() -> (SecretKey, PublicKey) { + let secret = SecretKey::generate(); + let pubkey = secret.public_key(); + (secret, pubkey) +} + +/// Helper: Create a hash and preimage for testing +/// Returns (hash_hex_string, preimage_hex_string) +pub fn create_test_hash_and_preimage() -> (String, String) { + use bitcoin::hashes::sha256::Hash as Sha256Hash; + use bitcoin::hashes::Hash; + + // Create a 32-byte preimage + let preimage_bytes = [0x42u8; 32]; + let hash = Sha256Hash::hash(&preimage_bytes); + // Return hex-encoded hash and hex-encoded preimage + (hash.to_string(), crate::util::hex::encode(preimage_bytes)) +} + +/// Helper: Unzip a vector of 3-tuples into 3 separate vectors +pub fn unzip3(vec: Vec<(A, B, C)>) -> (Vec, Vec, Vec) { + let mut vec_a = Vec::new(); + let mut vec_b = Vec::new(); + let mut vec_c = Vec::new(); + for (a, b, c) in vec { + vec_a.push(a); + vec_b.push(b); + vec_c.push(c); + } + (vec_a, vec_b, vec_c) +} diff --git a/crates/cdk/src/wallet/README.md b/crates/cdk/src/wallet/README.md index f18d5d0fb..e5f60afa0 100644 --- a/crates/cdk/src/wallet/README.md +++ b/crates/cdk/src/wallet/README.md @@ -15,12 +15,12 @@ The CDK [`Wallet`] is a high level Cashu wallet. The [`Wallet`] is for a single #[tokio::main] async fn main() -> anyhow::Result<()> { - let seed = random::<[u8; 32]>(); + let seed = random::<[u8; 64]>(); let mint_url = "https://fake.thesimplekid.dev"; let unit = CurrencyUnit::Sat; let localstore = memory::empty().await?; - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None); + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None); Ok(()) } ``` diff --git a/crates/cdk/src/wallet/auth/auth_wallet.rs b/crates/cdk/src/wallet/auth/auth_wallet.rs index fa6718eaa..9f843c9ad 100644 --- a/crates/cdk/src/wallet/auth/auth_wallet.rs +++ b/crates/cdk/src/wallet/auth/auth_wallet.rs @@ -18,11 +18,12 @@ use crate::nuts::{ }; use crate::types::ProofInfo; use crate::wallet::mint_connector::AuthHttpClient; +use crate::wallet::mint_metadata_cache::MintMetadataCache; use crate::{Amount, Error, OidcClient}; /// JWT Claims structure for decoding tokens #[derive(Debug, Serialize, Deserialize)] -struct Claims { +struct _Claims { /// Subject sub: Option, /// Expiration time (as UTC timestamp) @@ -39,11 +40,13 @@ pub struct AuthWallet { pub mint_url: MintUrl, /// Storage backend pub localstore: Arc + Send + Sync>, + /// Mint metadata cache (lock-free cached access to keys, keysets, and mint info) + pub metadata_cache: Arc, /// Protected methods pub protected_endpoints: Arc>>, /// Refresh token for auth refresh_token: Arc>>, - client: Arc, + auth_client: Arc, /// OIDC client for authentication oidc_client: Arc>>, } @@ -54,6 +57,7 @@ impl AuthWallet { mint_url: MintUrl, cat: Option, localstore: Arc + Send + Sync>, + metadata_cache: Arc, protected_endpoints: HashMap, oidc_client: Option, ) -> Self { @@ -61,9 +65,10 @@ impl AuthWallet { Self { mint_url, localstore, + metadata_cache, protected_endpoints: Arc::new(RwLock::new(protected_endpoints)), refresh_token: Arc::new(RwLock::new(None)), - client: http_client, + auth_client: http_client, oidc_client: Arc::new(RwLock::new(oidc_client)), } } @@ -71,7 +76,7 @@ impl AuthWallet { /// Get the current auth token #[instrument(skip(self))] pub async fn get_auth_token(&self) -> Result { - self.client.get_auth_token().await + self.auth_client.get_auth_token().await } /// Set a new auth token @@ -98,7 +103,7 @@ impl AuthWallet { if let Some(oidc) = self.oidc_client.read().await.as_ref() { oidc.verify_cat(clear_token).await?; } - self.client.set_auth_token(token).await + self.auth_client.set_auth_token(token).await } AuthToken::BlindAuth(_) => Err(Error::Custom( "Cannot set blind auth token directly".to_string(), @@ -164,86 +169,96 @@ impl AuthWallet { /// Query mint for current mint information #[instrument(skip(self))] pub async fn get_mint_info(&self) -> Result, Error> { - self.client.get_mint_info().await.map(Some).or(Ok(None)) + self.auth_client + .get_mint_info() + .await + .map(Some) + .or(Ok(None)) } - /// Get keys for mint keyset + /// Fetch keys for mint keyset /// - /// Selected keys from localstore if they are already known - /// If they are not known queries mint for keyset id and stores the [`Keys`] + /// Returns keys from metadata cache if available, fetches from mint if not. #[instrument(skip(self))] - pub async fn get_keyset_keys(&self, keyset_id: Id) -> Result { - let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? { - keys - } else { - let keys = self.client.get_mint_blind_auth_keyset(keyset_id).await?; - - keys.verify_id()?; - - self.localstore.add_keys(keys.clone()).await?; - - keys.keys - }; - - Ok(keys) + pub async fn load_keyset_keys(&self, keyset_id: Id) -> Result { + let metadata = self + .metadata_cache + .load_auth(&self.localstore, &self.auth_client) + .await?; + let active = metadata + .active_keysets + .iter() + .find(|x| x.unit == CurrencyUnit::Auth) + .cloned() + .ok_or(Error::NoActiveKeyset)?; + + metadata + .keys + .get(&active.id) + .map(|x| (*(x.clone())).clone()) + .ok_or(Error::NoActiveKeyset) } - /// Get active keyset for mint + /// Get blind auth keysets from metadata cache /// - /// Queries mint for current keysets then gets [`Keys`] for any unknown - /// keysets + /// Checks the metadata cache for auth keysets. If cache is not populated, + /// fetches from the mint server and updates the cache. + /// This is the main method for getting auth keysets in operations that can work offline + /// but will fall back to online if needed. #[instrument(skip(self))] - pub async fn get_active_mint_blind_auth_keysets(&self) -> Result, Error> { - let keysets = self.client.get_mint_blind_auth_keysets().await?; - let keysets = keysets.keysets; - - self.localstore - .add_mint_keysets(self.mint_url.clone(), keysets.clone()) + pub async fn load_mint_keysets(&self) -> Result, Error> { + let metadata = self + .metadata_cache + .load_auth(&self.localstore, &self.auth_client) .await?; - let active_keysets = keysets - .clone() - .into_iter() - .filter(|k| k.unit == CurrencyUnit::Auth) - .collect::>(); - - match self - .localstore - .get_mint_keysets(self.mint_url.clone()) - .await? - { - Some(known_keysets) => { - let unknown_keysets: Vec<&KeySetInfo> = keysets - .iter() - .filter(|k| known_keysets.contains(k)) - .collect(); - - for keyset in unknown_keysets { - self.get_keyset_keys(keyset.id).await?; - } - } - None => { - for keyset in keysets { - self.get_keyset_keys(keyset.id).await?; + let auth_keysets = metadata + .keysets + .iter() + .filter_map(|(_, k)| { + if k.unit == CurrencyUnit::Auth { + Some((*(k.clone())).clone()) + } else { + None } - } + }) + .collect::>(); + + if !auth_keysets.is_empty() { + Ok(auth_keysets) + } else { + Err(Error::UnknownKeySet) } - Ok(active_keysets) } - /// Get active keyset for mint + /// Refresh blind auth keysets by fetching the latest from mint /// - /// Queries mint for current keysets then gets [`Keys`] for any unknown - /// keysets + /// Fetches the latest blind auth keyset information from the mint server, + /// updating the metadata cache and database. Returns only the keysets with + /// Auth currency unit. Use this when you need the most up-to-date keyset information. #[instrument(skip(self))] - pub async fn get_active_mint_blind_auth_keyset(&self) -> Result { - let active_keysets = self.get_active_mint_blind_auth_keysets().await?; + pub async fn refresh_keysets(&self) -> Result, Error> { + tracing::debug!("Refreshing auth keysets from mint"); + + self.load_mint_keysets().await + } - let keyset = active_keysets.first().ok_or(Error::NoActiveKeyset)?; + /// Get the first active blind auth keyset - always goes online + /// + /// This method always goes online to refresh keysets from the mint and then returns + /// the first active keyset found. Use this when you need the most up-to-date + /// keyset information for blind auth operations. + #[instrument(skip(self))] + pub async fn fetch_active_keyset(&self) -> Result { + let auth_keysets = self.refresh_keysets().await?; + let keyset = auth_keysets.first().ok_or(Error::NoActiveKeyset)?; Ok(keyset.clone()) } - /// Get unspent proofs for mint + /// Get unspent auth proofs from local database only - offline operation + /// + /// Returns auth proofs from the local database that are in the Unspent state. + /// This is an offline operation that does not contact the mint. #[instrument(skip(self))] pub async fn get_unspent_auth_proofs(&self) -> Result, Error> { Ok(self @@ -298,7 +313,7 @@ impl AuthWallet { Some(auth) => match auth { AuthRequired::Clear => { tracing::trace!("Clear auth needed for request."); - self.client.get_auth_token().await.map(Some) + self.auth_client.get_auth_token().await.map(Some) } AuthRequired::Blind => { tracing::trace!("Blind auth needed for request getting Auth proof."); @@ -332,10 +347,7 @@ impl AuthWallet { self.get_mint_info().await?; } - let auth_token = self.client.get_auth_token().await?; - - let active_keyset_id = self.get_active_mint_blind_auth_keysets().await?; - tracing::debug!("Active ketset: {:?}", active_keyset_id); + let auth_token = self.auth_client.get_auth_token().await?; match &auth_token { AuthToken::ClearAuth(cat) => { @@ -369,24 +381,47 @@ impl AuthWallet { } } - let active_keyset_id = self.get_active_mint_blind_auth_keyset().await?.id; - - let premint_secrets = - PreMintSecrets::random(active_keyset_id, amount, &SplitTarget::Value(1.into()))?; + let keysets = self + .load_mint_keysets() + .await? + .into_iter() + .map(|x| (x.id, x)) + .collect::>(); + + let active_keyset_id = self.fetch_active_keyset().await?.id; + let fee_and_amounts = ( + keysets + .get(&active_keyset_id) + .map(|x| x.input_fee_ppk) + .unwrap_or_default(), + self.load_keyset_keys(active_keyset_id) + .await? + .iter() + .map(|(amount, _)| amount.to_u64()) + .collect::>(), + ) + .into(); + + let premint_secrets = PreMintSecrets::random( + active_keyset_id, + amount, + &SplitTarget::Value(1.into()), + &fee_and_amounts, + )?; let request = MintAuthRequest { outputs: premint_secrets.blinded_messages(), }; - let mint_res = self.client.post_mint_blind_auth(request).await?; + let mint_res = self.auth_client.post_mint_blind_auth(request).await?; - let keys = self.get_keyset_keys(active_keyset_id).await?; + let keys = self.load_keyset_keys(active_keyset_id).await?; // Verify the signature DLEQ is valid { assert!(mint_res.signatures.len() == premint_secrets.secrets.len()); for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) { - let keys = self.get_keyset_keys(sig.keyset_id).await?; + let keys = self.load_keyset_keys(sig.keyset_id).await?; let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { Ok(_) => (), diff --git a/crates/cdk/src/wallet/auth/mod.rs b/crates/cdk/src/wallet/auth/mod.rs index bb5abed2b..2cc432f91 100644 --- a/crates/cdk/src/wallet/auth/mod.rs +++ b/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) { + 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; + } } diff --git a/crates/cdk/src/wallet/balance.rs b/crates/cdk/src/wallet/balance.rs index ce0951628..1f83002de 100644 --- a/crates/cdk/src/wallet/balance.rs +++ b/crates/cdk/src/wallet/balance.rs @@ -1,13 +1,23 @@ use tracing::instrument; use crate::nuts::nut00::ProofsMethods; +use crate::nuts::State; use crate::{Amount, Error, Wallet}; impl Wallet { /// Total unspent balance of wallet #[instrument(skip(self))] pub async fn total_balance(&self) -> Result { - Ok(self.get_unspent_proofs().await?.total_amount()?) + // Use the efficient balance query instead of fetching all proofs + let balance = self + .localstore + .get_balance( + Some(self.mint_url.clone()), + Some(self.unit.clone()), + Some(vec![State::Unspent]), + ) + .await?; + Ok(Amount::from(balance)) } /// Total pending balance diff --git a/crates/cdk/src/wallet/builder.rs b/crates/cdk/src/wallet/builder.rs index 1168e102e..557ace25a 100644 --- a/crates/cdk/src/wallet/builder.rs +++ b/crates/cdk/src/wallet/builder.rs @@ -1,14 +1,13 @@ -#[cfg(feature = "auth")] use std::collections::HashMap; use std::sync::Arc; +use std::time::Duration; -use bitcoin::bip32::Xpriv; -use bitcoin::Network; use cdk_common::database; +use cdk_common::parking_lot::RwLock; #[cfg(feature = "auth")] use cdk_common::AuthToken; #[cfg(feature = "auth")] -use tokio::sync::RwLock; +use tokio::sync::RwLock as TokioRwLock; use crate::cdk_database::WalletDatabase; use crate::error::Error; @@ -16,10 +15,10 @@ use crate::mint_url::MintUrl; use crate::nuts::CurrencyUnit; #[cfg(feature = "auth")] use crate::wallet::auth::AuthWallet; +use crate::wallet::mint_metadata_cache::MintMetadataCache; use crate::wallet::{HttpClient, MintConnector, SubscriptionManager, Wallet}; /// Builder for creating a new [`Wallet`] -#[derive(Debug)] pub struct WalletBuilder { mint_url: Option, unit: Option, @@ -27,8 +26,12 @@ pub struct WalletBuilder { target_proof_count: Option, #[cfg(feature = "auth")] auth_wallet: Option, - seed: Option>, + seed: Option<[u8; 64]>, + use_http_subscription: bool, client: Option>, + metadata_cache_ttl: Option, + metadata_cache: Option>, + metadata_caches: HashMap>, } impl Default for WalletBuilder { @@ -42,6 +45,10 @@ impl Default for WalletBuilder { auth_wallet: None, seed: None, client: None, + metadata_cache_ttl: None, + use_http_subscription: false, + metadata_cache: None, + metadata_caches: HashMap::new(), } } } @@ -52,6 +59,25 @@ impl WalletBuilder { Self::default() } + /// Use HTTP for wallet subscriptions to mint events + pub fn use_http_subscription(mut self) -> Self { + self.use_http_subscription = true; + self + } + + /// Set metadata_cache_ttl + pub fn set_metadata_cache_ttl(mut self, metadata_cache_ttl: Option) -> Self { + self.metadata_cache_ttl = metadata_cache_ttl; + self + } + + /// If WS is preferred (with fallback to HTTP is it is not supported by the mint) for the wallet + /// subscriptions to mint events + pub fn prefer_ws_subscription(mut self) -> Self { + self.use_http_subscription = false; + self + } + /// Set the mint URL pub fn mint_url(mut self, mint_url: MintUrl) -> Self { self.mint_url = Some(mint_url); @@ -87,8 +113,8 @@ impl WalletBuilder { } /// Set the seed bytes - pub fn seed(mut self, seed: &[u8]) -> Self { - self.seed = Some(seed.to_vec()); + pub fn seed(mut self, seed: [u8; 64]) -> Self { + self.seed = Some(seed); self } @@ -98,13 +124,55 @@ impl WalletBuilder { self } + /// Set a custom client connector from Arc + pub fn shared_client(mut self, client: Arc) -> Self { + self.client = Some(client); + self + } + + /// Set a shared MintMetadataCache + /// + /// This allows multiple wallets to share the same metadata cache instance for + /// optimal performance and memory usage. If not provided, a new cache + /// will be created for each wallet. + pub fn metadata_cache(mut self, metadata_cache: Arc) -> Self { + self.metadata_cache = Some(metadata_cache); + self + } + + /// Set a HashMap of MintMetadataCaches for reusing across multiple wallets + /// + /// This allows the builder to reuse existing cache instances or create new ones. + /// Useful when creating multiple wallets that share metadata caches. + pub fn metadata_caches( + mut self, + metadata_caches: HashMap>, + ) -> Self { + self.metadata_caches = metadata_caches; + self + } + /// Set auth CAT (Clear Auth Token) #[cfg(feature = "auth")] pub fn set_auth_cat(mut self, cat: String) -> Self { + let mint_url = self.mint_url.clone().expect("Mint URL required"); + let localstore = self.localstore.clone().expect("Localstore required"); + + let metadata_cache = self.metadata_cache.clone().unwrap_or_else(|| { + // Check if we already have a cache for this mint in the HashMap + if let Some(cache) = self.metadata_caches.get(&mint_url) { + cache.clone() + } else { + // Create a new one + Arc::new(MintMetadataCache::new(mint_url.clone())) + } + }); + self.auth_wallet = Some(AuthWallet::new( - self.mint_url.clone().expect("Mint URL required"), + mint_url, Some(AuthToken::ClearAuth(cat)), - self.localstore.clone().expect("Localstore required"), + localstore, + metadata_cache, HashMap::new(), None, )); @@ -122,13 +190,10 @@ impl WalletBuilder { let localstore = self .localstore .ok_or(Error::Custom("Localstore required".to_string()))?; - let seed = self + let seed: [u8; 64] = self .seed - .as_ref() .ok_or(Error::Custom("Seed required".to_string()))?; - let xpriv = Xpriv::new_master(Network::Bitcoin, seed)?; - let client = match self.client { Some(client) => client, None => { @@ -146,16 +211,31 @@ impl WalletBuilder { } }; + let metadata_cache_ttl = self.metadata_cache_ttl; + + let metadata_cache = self.metadata_cache.unwrap_or_else(|| { + // Check if we already have a cache for this mint in the HashMap + if let Some(cache) = self.metadata_caches.get(&mint_url) { + cache.clone() + } else { + // Create a new one + Arc::new(MintMetadataCache::new(mint_url.clone())) + } + }); + Ok(Wallet { mint_url, unit, localstore, + metadata_cache, + metadata_cache_ttl: Arc::new(RwLock::new(metadata_cache_ttl)), target_proof_count: self.target_proof_count.unwrap_or(3), #[cfg(feature = "auth")] - auth_wallet: Arc::new(RwLock::new(self.auth_wallet)), - xpriv, + auth_wallet: Arc::new(TokioRwLock::new(self.auth_wallet)), + seed, client: client.clone(), - subscription: SubscriptionManager::new(client), + subscription: SubscriptionManager::new(client, self.use_http_subscription), + in_error_swap_reverted_proofs: Arc::new(false.into()), }) } } diff --git a/crates/cdk/src/wallet/mint.rs b/crates/cdk/src/wallet/issue/issue_bolt11.rs similarity index 71% rename from crates/cdk/src/wallet/mint.rs rename to crates/cdk/src/wallet/issue/issue_bolt11.rs index 11def1e70..23361200e 100644 --- a/crates/cdk/src/wallet/mint.rs +++ b/crates/cdk/src/wallet/issue/issue_bolt11.rs @@ -1,10 +1,10 @@ use std::collections::HashMap; use cdk_common::nut04::MintMethodOptions; -use cdk_common::wallet::{Transaction, TransactionDirection}; +use cdk_common::wallet::{MintQuote, Transaction, TransactionDirection}; +use cdk_common::PaymentMethod; use tracing::instrument; -use super::MintQuote; use crate::amount::SplitTarget; use crate::dhke::construct_proofs; use crate::nuts::nut00::ProofsMethods; @@ -31,12 +31,12 @@ impl Wallet { /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { - /// let seed = random::<[u8; 32]>(); + /// let seed = random::<[u8; 64]>(); /// let mint_url = "https://fake.thesimplekid.dev"; /// let unit = CurrencyUnit::Sat; /// /// let localstore = memory::empty().await?; - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None)?; /// let amount = Amount::from(100); /// /// let quote = wallet.mint_quote(amount, None).await?; @@ -49,16 +49,14 @@ impl Wallet { amount: Amount, description: Option, ) -> Result { + let mint_info = self.load_mint_info().await?; + let mint_url = self.mint_url.clone(); let unit = self.unit.clone(); // If we have a description, we check that the mint supports it. if description.is_some() { - let settings = self - .localstore - .get_mint(mint_url.clone()) - .await? - .ok_or(Error::IncorrectMint)? + let settings = mint_info .nuts .nut04 .get_settings(&unit, &crate::nuts::PaymentMethod::Bolt11) @@ -81,16 +79,16 @@ impl Wallet { let quote_res = self.client.post_mint_quote(request).await?; - let quote = MintQuote { + let quote = MintQuote::new( + quote_res.quote, mint_url, - id: quote_res.quote, - amount, + PaymentMethod::Bolt11, + Some(amount), unit, - request: quote_res.request, - state: quote_res.state, - expiry: quote_res.expiry.unwrap_or(0), - secret_key: Some(secret_key), - }; + quote_res.request, + quote_res.expiry.unwrap_or(0), + Some(secret_key), + ); self.localstore.add_mint_quote(quote.clone()).await?; @@ -141,6 +139,20 @@ impl Wallet { Ok(total_amount) } + /// Get active mint quotes + /// Returns mint quotes that are not expired and not yet issued. + #[instrument(skip(self))] + pub async fn get_active_mint_quotes(&self) -> Result, Error> { + let mut mint_quotes = self.localstore.get_mint_quotes().await?; + let unix_time = unix_time(); + mint_quotes.retain(|quote| { + quote.mint_url == self.mint_url + && quote.state != MintQuoteState::Issued + && quote.expiry > unix_time + }); + Ok(mint_quotes) + } + /// Mint /// # Synopsis /// ```rust,no_run @@ -156,12 +168,12 @@ impl Wallet { /// /// #[tokio::main] /// async fn main() -> Result<()> { - /// let seed = random::<[u8; 32]>(); + /// let seed = random::<[u8; 64]>(); /// let mint_url = "https://fake.thesimplekid.dev"; /// let unit = CurrencyUnit::Sat; /// /// let localstore = memory::empty().await?; - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap(); /// let amount = Amount::from(100); /// /// let quote = wallet.mint_quote(amount, None).await?; @@ -180,51 +192,78 @@ impl Wallet { amount_split_target: SplitTarget, spending_conditions: Option, ) -> Result { - // Check that mint is in store of mints - if self - .localstore - .get_mint(self.mint_url.clone()) - .await? - .is_none() - { - self.get_mint_info().await?; - } - let quote_info = self .localstore .get_mint_quote(quote_id) .await? .ok_or(Error::UnknownQuote)?; + if quote_info.payment_method != PaymentMethod::Bolt11 { + return Err(Error::UnsupportedPaymentMethod); + } + + let amount_mintable = quote_info.amount_mintable(); + + if amount_mintable == Amount::ZERO { + tracing::debug!("Amount mintable 0."); + return Err(Error::AmountUndefined); + } + let unix_time = unix_time(); if quote_info.expiry > unix_time { tracing::warn!("Attempting to mint with expired quote."); } - let active_keyset_id = self.get_active_mint_keyset().await?.id; - - let count = self - .localstore - .get_keyset_counter(&active_keyset_id) + let active_keyset_id = self.fetch_active_keyset().await?.id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) .await?; - let count = count.map_or(0, |c| c + 1); + let split_target = match amount_split_target { + SplitTarget::None => { + self.determine_split_target_values(amount_mintable, &fee_and_amounts) + .await? + } + s => s, + }; let premint_secrets = match &spending_conditions { Some(spending_conditions) => PreMintSecrets::with_conditions( active_keyset_id, - quote_info.amount, - &amount_split_target, + amount_mintable, + &split_target, spending_conditions, + &fee_and_amounts, )?, - None => PreMintSecrets::from_xpriv( - active_keyset_id, - count, - self.xpriv, - quote_info.amount, - &amount_split_target, - )?, + None => { + let amount_split = + amount_mintable.split_targeted(&split_target, &fee_and_amounts)?; + let num_secrets = amount_split.len() as u32; + + tracing::debug!( + "Incrementing keyset {} counter by {}", + active_keyset_id, + num_secrets + ); + + // Atomically get the counter range we need + let new_counter = self + .localstore + .increment_keyset_counter(&active_keyset_id, num_secrets) + .await?; + + let count = new_counter - num_secrets; + + PreMintSecrets::from_seed( + active_keyset_id, + count, + &self.seed, + amount_mintable, + &split_target, + &fee_and_amounts, + )? + } }; let mut request = MintRequest { @@ -239,12 +278,12 @@ impl Wallet { let mint_res = self.client.post_mint(request).await?; - let keys = self.get_keyset_keys(active_keyset_id).await?; + let keys = self.load_keyset_keys(active_keyset_id).await?; // Verify the signature DLEQ is valid { for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) { - let keys = self.get_keyset_keys(sig.keyset_id).await?; + let keys = self.load_keyset_keys(sig.keyset_id).await?; let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { Ok(_) | Err(nut12::Error::MissingDleqProof) => (), @@ -263,19 +302,6 @@ impl Wallet { // Remove filled quote from store self.localstore.remove_mint_quote("e_info.id).await?; - if spending_conditions.is_none() { - tracing::debug!( - "Incrementing keyset {} counter by {}", - active_keyset_id, - proofs.len() - ); - - // Update counter for keyset - self.localstore - .increment_keyset_counter(&active_keyset_id, proofs.len() as u32) - .await?; - } - let proof_infos = proofs .iter() .map(|proof| { @@ -303,6 +329,9 @@ impl Wallet { timestamp: unix_time, memo: None, metadata: HashMap::new(), + quote_id: Some(quote_id.to_string()), + payment_request: Some(quote_info.request), + payment_proof: None, }) .await?; diff --git a/crates/cdk/src/wallet/issue/issue_bolt12.rs b/crates/cdk/src/wallet/issue/issue_bolt12.rs new file mode 100644 index 000000000..7637f4420 --- /dev/null +++ b/crates/cdk/src/wallet/issue/issue_bolt12.rs @@ -0,0 +1,267 @@ +use std::collections::HashMap; + +use cdk_common::nut04::MintMethodOptions; +use cdk_common::nut25::MintQuoteBolt12Request; +use cdk_common::wallet::{Transaction, TransactionDirection}; +use cdk_common::{Proofs, SecretKey}; +use tracing::instrument; + +use crate::amount::SplitTarget; +use crate::dhke::construct_proofs; +use crate::nuts::nut00::ProofsMethods; +use crate::nuts::{ + nut12, MintQuoteBolt12Response, MintRequest, PaymentMethod, PreMintSecrets, SpendingConditions, + State, +}; +use crate::types::ProofInfo; +use crate::util::unix_time; +use crate::wallet::MintQuote; +use crate::{Amount, Error, Wallet}; + +impl Wallet { + /// Mint Bolt12 + #[instrument(skip(self))] + pub async fn mint_bolt12_quote( + &self, + amount: Option, + description: Option, + ) -> Result { + let mint_info = self.load_mint_info().await?; + + let mint_url = self.mint_url.clone(); + let unit = &self.unit; + + // If we have a description, we check that the mint supports it. + if description.is_some() { + let mint_method_settings = mint_info + .nuts + .nut04 + .get_settings(unit, &crate::nuts::PaymentMethod::Bolt12) + .ok_or(Error::UnsupportedUnit)?; + + match mint_method_settings.options { + Some(MintMethodOptions::Bolt11 { description }) if description => (), + _ => return Err(Error::InvoiceDescriptionUnsupported), + } + } + + let secret_key = SecretKey::generate(); + + let mint_request = MintQuoteBolt12Request { + amount, + unit: self.unit.clone(), + description, + pubkey: secret_key.public_key(), + }; + + let quote_res = self.client.post_mint_bolt12_quote(mint_request).await?; + + let quote = MintQuote::new( + quote_res.quote, + mint_url, + PaymentMethod::Bolt12, + amount, + unit.clone(), + quote_res.request, + quote_res.expiry.unwrap_or(0), + Some(secret_key), + ); + + self.localstore.add_mint_quote(quote.clone()).await?; + + Ok(quote) + } + + /// Mint bolt12 + #[instrument(skip(self))] + pub async fn mint_bolt12( + &self, + quote_id: &str, + amount: Option, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> Result { + let quote_info = self.localstore.get_mint_quote(quote_id).await?; + + let quote_info = if let Some(quote) = quote_info { + if quote.expiry.le(&unix_time()) && quote.expiry.ne(&0) { + tracing::info!("Attempting to mint expired quote."); + } + + quote.clone() + } else { + return Err(Error::UnknownQuote); + }; + + let active_keyset_id = self.fetch_active_keyset().await?.id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; + + let amount = match amount { + Some(amount) => amount, + None => { + // If an amount it not supplied with check the status of the quote + // The mint will tell us how much can be minted + let state = self.mint_bolt12_quote_state(quote_id).await?; + + state.amount_paid - state.amount_issued + } + }; + + if amount == Amount::ZERO { + tracing::error!("Cannot mint zero amount."); + return Err(Error::UnpaidQuote); + } + + let split_target = match amount_split_target { + SplitTarget::None => { + self.determine_split_target_values(amount, &fee_and_amounts) + .await? + } + s => s, + }; + + let premint_secrets = match &spending_conditions { + Some(spending_conditions) => PreMintSecrets::with_conditions( + active_keyset_id, + amount, + &split_target, + spending_conditions, + &fee_and_amounts, + )?, + None => { + let amount_split = amount.split_targeted(&split_target, &fee_and_amounts)?; + let num_secrets = amount_split.len() as u32; + + tracing::debug!( + "Incrementing keyset {} counter by {}", + active_keyset_id, + num_secrets + ); + + // Atomically get the counter range we need + let new_counter = self + .localstore + .increment_keyset_counter(&active_keyset_id, num_secrets) + .await?; + + let count = new_counter - num_secrets; + + PreMintSecrets::from_seed( + active_keyset_id, + count, + &self.seed, + amount, + &split_target, + &fee_and_amounts, + )? + } + }; + + let mut request = MintRequest { + quote: quote_id.to_string(), + outputs: premint_secrets.blinded_messages(), + signature: None, + }; + + if let Some(secret_key) = quote_info.secret_key.clone() { + request.sign(secret_key)?; + } else { + tracing::error!("Signature is required for bolt12."); + return Err(Error::SignatureMissingOrInvalid); + } + + let mint_res = self.client.post_mint(request).await?; + + let keys = self.load_keyset_keys(active_keyset_id).await?; + + // Verify the signature DLEQ is valid + { + for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) { + let keys = self.load_keyset_keys(sig.keyset_id).await?; + let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; + match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { + Ok(_) | Err(nut12::Error::MissingDleqProof) => (), + Err(_) => return Err(Error::CouldNotVerifyDleq), + } + } + } + + let proofs = construct_proofs( + mint_res.signatures, + premint_secrets.rs(), + premint_secrets.secrets(), + &keys, + )?; + + // Remove filled quote from store + let mut quote_info = self + .localstore + .get_mint_quote(quote_id) + .await? + .ok_or(Error::UnpaidQuote)?; + quote_info.amount_issued += proofs.total_amount()?; + + self.localstore.add_mint_quote(quote_info.clone()).await?; + + let proof_infos = proofs + .iter() + .map(|proof| { + ProofInfo::new( + proof.clone(), + self.mint_url.clone(), + State::Unspent, + quote_info.unit.clone(), + ) + }) + .collect::, _>>()?; + + // Add new proofs to store + self.localstore.update_proofs(proof_infos, vec![]).await?; + + // Add transaction to store + self.localstore + .add_transaction(Transaction { + mint_url: self.mint_url.clone(), + direction: TransactionDirection::Incoming, + amount: proofs.total_amount()?, + fee: Amount::ZERO, + unit: self.unit.clone(), + ys: proofs.ys()?, + timestamp: unix_time(), + memo: None, + metadata: HashMap::new(), + quote_id: Some(quote_id.to_string()), + payment_request: Some(quote_info.request), + payment_proof: None, + }) + .await?; + + Ok(proofs) + } + + /// Check mint quote status + #[instrument(skip(self, quote_id))] + pub async fn mint_bolt12_quote_state( + &self, + quote_id: &str, + ) -> Result, Error> { + let response = self.client.get_mint_quote_bolt12_status(quote_id).await?; + + match self.localstore.get_mint_quote(quote_id).await? { + Some(quote) => { + let mut quote = quote; + quote.amount_issued = response.amount_issued; + quote.amount_paid = response.amount_paid; + + self.localstore.add_mint_quote(quote).await?; + } + None => { + tracing::info!("Quote mint {} unknown", quote_id); + } + } + + Ok(response) + } +} diff --git a/crates/cdk/src/wallet/issue/mod.rs b/crates/cdk/src/wallet/issue/mod.rs new file mode 100644 index 000000000..8d74d484f --- /dev/null +++ b/crates/cdk/src/wallet/issue/mod.rs @@ -0,0 +1,2 @@ +mod issue_bolt11; +mod issue_bolt12; diff --git a/crates/cdk/src/wallet/keysets.rs b/crates/cdk/src/wallet/keysets.rs index 095201944..2feb121c1 100644 --- a/crates/cdk/src/wallet/keysets.rs +++ b/crates/cdk/src/wallet/keysets.rs @@ -1,142 +1,174 @@ use std::collections::HashMap; +use cdk_common::amount::{FeeAndAmounts, KeysetFeeAndAmounts}; +use cdk_common::nut02::{KeySetInfos, KeySetInfosMethods}; use tracing::instrument; use crate::nuts::{Id, KeySetInfo, Keys}; use crate::{Error, Wallet}; impl Wallet { - /// Get keys for mint keyset + /// Load keys for mint keyset /// - /// Selected keys from localstore if they are already known - /// If they are not known queries mint for keyset id and stores the [`Keys`] + /// Returns keys from metadata cache if available. + /// If keys are not cached, fetches from mint server. #[instrument(skip(self))] - pub async fn get_keyset_keys(&self, keyset_id: Id) -> Result { - let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? { - keys - } else { - let keys = self.client.get_mint_keyset(keyset_id).await?; - - keys.verify_id()?; - - self.localstore.add_keys(keys.clone()).await?; - - keys.keys - }; - - Ok(keys) + pub async fn load_keyset_keys(&self, keyset_id: Id) -> Result { + self.metadata_cache + .load(&self.localstore, &self.client, { + let ttl = self.metadata_cache_ttl.read(); + *ttl + }) + .await? + .keys + .get(&keyset_id) + .map(|x| (*x.clone()).clone()) + .ok_or(Error::UnknownKeySet) } - /// Get keysets from DB or fetch them - /// - /// Checks the database for keysets and queries the Mint if - /// it can't find any. + /// Alias of get_mint_keysets, kept for backwards compatibility reasons #[instrument(skip(self))] pub async fn load_mint_keysets(&self) -> Result, Error> { - match self - .localstore - .get_mint_keysets(self.mint_url.clone()) - .await? - { - Some(keysets_info) => Ok(keysets_info), - None => self.get_mint_keysets().await, // Hit the keysets endpoint if we don't have the keysets for this Mint - } + self.get_mint_keysets().await } - /// Get keysets for wallet's mint + /// Get keysets from metadata cache (may fetch if not populated) /// - /// Queries mint for all keysets + /// Checks the metadata cache for keysets. If cache is not populated, + /// fetches from mint and updates cache. Returns error if no active keysets found. #[instrument(skip(self))] + #[inline(always)] pub async fn get_mint_keysets(&self) -> Result, Error> { - let keysets = self.client.get_mint_keysets().await?; - - self.localstore - .add_mint_keysets(self.mint_url.clone(), keysets.keysets.clone()) - .await?; + let keysets = self + .metadata_cache + .load(&self.localstore, &self.client, { + let ttl = self.metadata_cache_ttl.read(); + *ttl + }) + .await? + .keysets + .iter() + .filter_map(|(_, keyset)| { + if keyset.unit == self.unit && keyset.active { + Some((*keyset.clone()).clone()) + } else { + None + } + }) + .collect::>(); - Ok(keysets.keysets) + if !keysets.is_empty() { + Ok(keysets) + } else { + Err(Error::UnknownKeySet) + } } - /// Get active keyset for mint + /// Refresh keysets by fetching the latest from mint - always fetches fresh data /// - /// Queries mint for current keysets then gets [`Keys`] for any unknown - /// keysets + /// Forces a fresh fetch of keyset information from the mint server, + /// updating the metadata cache and database. Use this when you need + /// the most up-to-date keyset information. #[instrument(skip(self))] - pub async fn get_active_mint_keysets(&self) -> Result, Error> { - let keysets = self.client.get_mint_keysets().await?; - let keysets = keysets.keysets; - - self.localstore - .add_mint_keysets(self.mint_url.clone(), keysets.clone()) - .await?; - - let active_keysets = keysets - .clone() - .into_iter() - .filter(|k| k.active && k.unit == self.unit) - .collect::>(); + pub async fn refresh_keysets(&self) -> Result { + tracing::debug!("Refreshing keysets from mint"); - match self - .localstore - .get_mint_keysets(self.mint_url.clone()) + let keysets = self + .metadata_cache + .load_from_mint(&self.localstore, &self.client) .await? - { - Some(known_keysets) => { - let unknown_keysets: Vec<&KeySetInfo> = keysets - .iter() - .filter(|k| known_keysets.contains(k)) - .collect(); - - for keyset in unknown_keysets { - self.get_keyset_keys(keyset.id).await?; - } - } - None => { - for keyset in keysets { - self.get_keyset_keys(keyset.id).await?; + .keysets + .iter() + .filter_map(|(_, keyset)| { + if keyset.unit == self.unit && keyset.active { + Some((*keyset.clone()).clone()) + } else { + None } - } - } + }) + .collect::>(); - Ok(active_keysets) + if !keysets.is_empty() { + Ok(keysets) + } else { + Err(Error::UnknownKeySet) + } } - /// Get active keyset for mint with the lowest fees + /// Get the active keyset with the lowest fees - fetches fresh data from mint /// - /// Queries mint for current keysets then gets [`Keys`] for any unknown - /// keysets + /// Forces a fresh fetch of keysets from the mint and returns the active keyset + /// with the minimum input fees. Use this when you need the most up-to-date + /// keyset information for operations. #[instrument(skip(self))] - pub async fn get_active_mint_keyset(&self) -> Result { - // Important - let _ = self.get_mint_info().await?; - let active_keysets = self.get_active_mint_keysets().await?; - - let keyset_with_lowest_fee = active_keysets - .into_iter() - .min_by_key(|key| key.input_fee_ppk) - .ok_or(Error::NoActiveKeyset)?; - Ok(keyset_with_lowest_fee) + pub async fn fetch_active_keyset(&self) -> Result { + self.get_mint_keysets() + .await? + .active() + .min_by_key(|k| k.input_fee_ppk) + .cloned() + .ok_or(Error::NoActiveKeyset) } - /// Get keyset fees for mint - pub async fn get_keyset_fees(&self) -> Result, Error> { - let keysets = self - .localstore - .get_mint_keysets(self.mint_url.clone()) + /// Get the active keyset with the lowest fees from cache + /// + /// Returns the active keyset with minimum input fees from the metadata cache. + /// Uses cached data if available, fetches from mint if cache not populated. + #[instrument(skip(self))] + pub async fn get_active_keyset(&self) -> Result { + self.metadata_cache + .load(&self.localstore, &self.client, { + let ttl = self.metadata_cache_ttl.read(); + *ttl + }) .await? - .ok_or(Error::UnknownKeySet)?; + .active_keysets + .iter() + .min_by_key(|k| k.input_fee_ppk) + .map(|ks| (**ks).clone()) + .ok_or(Error::NoActiveKeyset) + } + + /// Get keyset fees and amounts for all keysets from metadata cache + /// + /// Returns a HashMap of keyset IDs to their input fee rates (per-proof-per-thousand) + /// and available amounts. Uses cached data if available, fetches from mint if not. + pub async fn get_keyset_fees_and_amounts(&self) -> Result { + let metadata = self + .metadata_cache + .load(&self.localstore, &self.client, { + let ttl = self.metadata_cache_ttl.read(); + *ttl + }) + .await?; let mut fees = HashMap::new(); - for keyset in keysets { - fees.insert(keyset.id, keyset.input_fee_ppk); + for keyset in metadata.keysets.values() { + let keys = self.load_keyset_keys(keyset.id).await?; + fees.insert( + keyset.id, + ( + keyset.input_fee_ppk, + keys.iter() + .map(|(amount, _)| amount.to_u64()) + .collect::>(), + ) + .into(), + ); } Ok(fees) } - /// Get keyset fees for mint by keyset id - pub async fn get_keyset_fees_by_id(&self, keyset_id: Id) -> Result { - self.get_keyset_fees() + /// Get keyset fees and amounts for a specific keyset ID + /// + /// Returns the input fee rate (per-proof-per-thousand) and available amounts + /// for a specific keyset. Uses cached data if available, fetches from mint if not. + pub async fn get_keyset_fees_and_amounts_by_id( + &self, + keyset_id: Id, + ) -> Result { + self.get_keyset_fees_and_amounts() .await? .get(&keyset_id) .cloned() diff --git a/crates/cdk/src/wallet/melt/melt_bip353.rs b/crates/cdk/src/wallet/melt/melt_bip353.rs new file mode 100644 index 000000000..adec11605 --- /dev/null +++ b/crates/cdk/src/wallet/melt/melt_bip353.rs @@ -0,0 +1,94 @@ +//! Melt BIP353 +//! +//! Implementation of melt functionality for BIP353 human-readable addresses + +use std::str::FromStr; + +use cdk_common::wallet::MeltQuote; +use tracing::instrument; + +#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] +use crate::bip353::{Bip353Address, PaymentType}; +use crate::nuts::MeltOptions; +use crate::{Amount, Error, Wallet}; + +impl Wallet { + /// Melt Quote for BIP353 human-readable address + /// + /// This method resolves a BIP353 address (e.g., "alice@example.com") to a Lightning offer + /// and then creates a melt quote for that offer. + /// + /// # Arguments + /// + /// * `bip353_address` - Human-readable address in the format "user@domain.com" + /// * `amount_msat` - Amount to pay in millisatoshis + /// + /// # Returns + /// + /// A `MeltQuote` that can be used to execute the payment + /// + /// # Errors + /// + /// This method will return an error if: + /// - The BIP353 address format is invalid + /// - DNS resolution fails or DNSSEC validation fails + /// - No Lightning offer is found in the payment instructions + /// - The mint fails to provide a quote for the offer + /// + /// # Example + /// + /// ```rust,no_run + /// use cdk::Amount; + /// # use cdk::Wallet; + /// # async fn example(wallet: Wallet) -> Result<(), cdk::Error> { + /// let quote = wallet + /// .melt_bip353_quote("alice@example.com", Amount::from(100_000)) // 100 sats in msat + /// .await?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] + #[instrument(skip(self, amount_msat), fields(address = %bip353_address))] + pub async fn melt_bip353_quote( + &self, + bip353_address: &str, + amount_msat: impl Into, + ) -> Result { + // Parse the BIP353 address + let address = Bip353Address::from_str(bip353_address).map_err(|e| { + tracing::error!("Failed to parse BIP353 address '{}': {}", bip353_address, e); + Error::Bip353Parse(e.to_string()) + })?; + + tracing::debug!("Resolving BIP353 address: {}", address); + + // Keep a copy for error reporting + let address_string = address.to_string(); + + // Resolve the address to get payment instructions + let payment_instructions = address.resolve(&self.client).await.map_err(|e| { + tracing::error!( + "Failed to resolve BIP353 address '{}': {}", + address_string, + e + ); + Error::Bip353Resolve(e.to_string()) + })?; + + // Extract the Lightning offer from the payment instructions + let offer = payment_instructions + .get(&PaymentType::LightningOffer) + .ok_or_else(|| { + tracing::error!("No Lightning offer found in BIP353 payment instructions"); + Error::Bip353NoLightningOffer + })?; + + tracing::debug!("Found Lightning offer in BIP353 instructions: {}", offer); + + // Create melt options with the provided amount + let options = MeltOptions::new_amountless(amount_msat); + + // Create a melt quote for the BOLT12 offer + self.melt_bolt12_quote(offer.clone(), Some(options)).await + } +} diff --git a/crates/cdk/src/wallet/melt.rs b/crates/cdk/src/wallet/melt/melt_bolt11.rs similarity index 62% rename from crates/cdk/src/wallet/melt.rs rename to crates/cdk/src/wallet/melt/melt_bolt11.rs index abccaae4e..79b7c527b 100644 --- a/crates/cdk/src/wallet/melt.rs +++ b/crates/cdk/src/wallet/melt/melt_bolt11.rs @@ -1,12 +1,11 @@ use std::collections::HashMap; use std::str::FromStr; -use cdk_common::amount::SplitTarget; use cdk_common::wallet::{Transaction, TransactionDirection}; +use cdk_common::PaymentMethod; use lightning_invoice::Bolt11Invoice; use tracing::instrument; -use super::MeltQuote; use crate::amount::to_unit; use crate::dhke::construct_proofs; use crate::nuts::{ @@ -15,12 +14,13 @@ use crate::nuts::{ }; use crate::types::{Melted, ProofInfo}; use crate::util::unix_time; -use crate::{ensure_cdk, Error, Wallet}; +use crate::wallet::MeltQuote; +use crate::{ensure_cdk, Amount, Error, Wallet}; impl Wallet { /// Melt Quote /// # Synopsis - /// ```rust + /// ```rust,no_run /// use std::sync::Arc; /// /// use cdk_sqlite::wallet::memory; @@ -30,12 +30,12 @@ impl Wallet { /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { - /// let seed = random::<[u8; 32]>(); + /// let seed = random::<[u8; 64]>(); /// let mint_url = "https://fake.thesimplekid.dev"; /// let unit = CurrencyUnit::Sat; /// /// let localstore = memory::empty().await?; - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap(); /// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); /// let quote = wallet.melt_quote(bolt11, None).await?; /// @@ -51,7 +51,7 @@ impl Wallet { let invoice = Bolt11Invoice::from_str(&request)?; let quote_request = MeltQuoteBolt11Request { - request: Bolt11Invoice::from_str(&request)?, + request: invoice.clone(), unit: self.unit.clone(), options, }; @@ -85,6 +85,7 @@ impl Wallet { state: quote_res.state, expiry: quote_res.expiry, payment_preimage: quote_res.payment_preimage, + payment_method: PaymentMethod::Bolt11, }; self.localstore.add_melt_quote(quote.clone()).await?; @@ -104,6 +105,13 @@ impl Wallet { Some(quote) => { let mut quote = quote; + if let Err(e) = self + .add_transaction_for_pending_melt("e, &response) + .await + { + tracing::error!("Failed to add transaction for pending melt: {}", e); + } + quote.state = response.state; self.localstore.add_melt_quote(quote).await?; } @@ -118,7 +126,19 @@ impl Wallet { /// Melt specific proofs #[instrument(skip(self, proofs))] pub async fn melt_proofs(&self, quote_id: &str, proofs: Proofs) -> Result { - let quote_info = self + self.melt_proofs_with_metadata(quote_id, proofs, HashMap::new()) + .await + } + + /// Melt specific proofs + #[instrument(skip(self, proofs))] + pub async fn melt_proofs_with_metadata( + &self, + quote_id: &str, + proofs: Proofs, + metadata: HashMap, + ) -> Result { + let mut quote_info = self .localstore .get_melt_quote(quote_id) .await? @@ -134,26 +154,42 @@ impl Wallet { return Err(Error::InsufficientFunds); } - let ys = proofs.ys()?; - self.localstore - .update_proofs_state(ys, State::Pending) - .await?; + // Since the proofs may be external (not in our database), add them first + let proofs_info = proofs + .clone() + .into_iter() + .map(|p| ProofInfo::new(p, self.mint_url.clone(), State::Pending, self.unit.clone())) + .collect::, _>>()?; + self.localstore.update_proofs(proofs_info, vec![]).await?; - let active_keyset_id = self.get_active_mint_keyset().await?.id; + let active_keyset_id = self.fetch_active_keyset().await?.id; - let count = self - .localstore - .get_keyset_counter(&active_keyset_id) - .await?; + let change_amount = proofs_total - quote_info.amount; - let count = count.map_or(0, |c| c + 1); + let premint_secrets = if change_amount <= Amount::ZERO { + PreMintSecrets::new(active_keyset_id) + } else { + // TODO: consolidate this calculation with from_seed_blank into a shared function + // Calculate how many secrets will be needed using the same logic as from_seed_blank + let num_secrets = + ((u64::from(change_amount) as f64).log2().ceil() as u64).max(1) as u32; - let premint_secrets = PreMintSecrets::from_xpriv_blank( - active_keyset_id, - count, - self.xpriv, - proofs_total - quote_info.amount, - )?; + tracing::debug!( + "Incrementing keyset {} counter by {}", + active_keyset_id, + num_secrets + ); + + // Atomically get the counter range we need + let new_counter = self + .localstore + .increment_keyset_counter(&active_keyset_id, num_secrets) + .await?; + + let count = new_counter - num_secrets; + + PreMintSecrets::from_seed_blank(active_keyset_id, count, &self.seed, change_amount)? + }; let request = MeltRequest::new( quote_id.to_string(), @@ -161,25 +197,27 @@ impl Wallet { Some(premint_secrets.blinded_messages()), ); - let melt_response = self.client.post_melt(request).await; - - let melt_response = match melt_response { - Ok(melt_response) => melt_response, - Err(err) => { - tracing::error!("Could not melt: {}", err); - tracing::info!("Checking status of input proofs."); - - self.reclaim_unspent(proofs).await?; - - return Err(err); + let melt_response = match quote_info.payment_method { + cdk_common::PaymentMethod::Bolt11 => { + self.try_proof_operation_or_reclaim( + request.inputs().clone(), + self.client.post_melt(request), + ) + .await? + } + cdk_common::PaymentMethod::Bolt12 => { + self.try_proof_operation_or_reclaim( + request.inputs().clone(), + self.client.post_melt_bolt12(request), + ) + .await? + } + cdk_common::PaymentMethod::Custom(_) => { + return Err(Error::UnsupportedPaymentMethod); } }; - let active_keys = self - .localstore - .get_keys(&active_keyset_id) - .await? - .ok_or(Error::NoActiveKeyset)?; + let active_keys = self.load_keyset_keys(active_keyset_id).await?; let change_proofs = match melt_response.change { Some(change) => { @@ -206,6 +244,8 @@ impl Wallet { None => None, }; + let payment_preimage = melt_response.payment_preimage.clone(); + let melted = Melted::from_proofs( melt_response.state, melt_response.payment_preimage, @@ -221,11 +261,6 @@ impl Wallet { change_proofs.total_amount()? ); - // Update counter for keyset - self.localstore - .increment_keyset_counter(&active_keyset_id, change_proofs.len() as u32) - .await?; - change_proofs .into_iter() .map(|proof| { @@ -241,7 +276,11 @@ impl Wallet { None => Vec::new(), }; - self.localstore.remove_melt_quote("e_info.id).await?; + quote_info.state = cdk_common::MeltQuoteState::Paid; + + let payment_request = quote_info.request.clone(); + + self.localstore.add_melt_quote(quote_info).await?; let deleted_ys = proofs.ys()?; self.localstore @@ -259,7 +298,10 @@ impl Wallet { ys: proofs.ys()?, timestamp: unix_time(), memo: None, - metadata: HashMap::new(), + metadata, + quote_id: Some(quote_id.to_string()), + payment_request: Some(payment_request), + payment_proof: payment_preimage, }) .await?; @@ -278,12 +320,12 @@ impl Wallet { /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { - /// let seed = random::<[u8; 32]>(); + /// let seed = random::<[u8; 64]>(); /// let mint_url = "https://fake.thesimplekid.dev"; /// let unit = CurrencyUnit::Sat; /// /// let localstore = memory::empty().await?; - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap(); /// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); /// let quote = wallet.melt_quote(bolt11, None).await?; /// let quote_id = quote.id; @@ -294,6 +336,44 @@ impl Wallet { /// } #[instrument(skip(self))] pub async fn melt(&self, quote_id: &str) -> Result { + self.melt_with_metadata(quote_id, HashMap::new()).await + } + + /// Melt with additional metadata to be saved locally with the transaction + /// # Synopsis + /// ```rust, no_run + /// use std::sync::Arc; + /// + /// use cdk_sqlite::wallet::memory; + /// use cdk::nuts::CurrencyUnit; + /// use cdk::wallet::Wallet; + /// use rand::random; + /// + /// #[tokio::main] + /// async fn main() -> anyhow::Result<()> { + /// let seed = random::<[u8; 64]>(); + /// let mint_url = "https://fake.thesimplekid.dev"; + /// let unit = CurrencyUnit::Sat; + /// + /// let localstore = memory::empty().await?; + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap(); + /// let bolt11 = "lnbc100n1pnvpufspp5djn8hrq49r8cghwye9kqw752qjncwyfnrprhprpqk43mwcy4yfsqdq5g9kxy7fqd9h8vmmfvdjscqzzsxqyz5vqsp5uhpjt36rj75pl7jq2sshaukzfkt7uulj456s4mh7uy7l6vx7lvxs9qxpqysgqedwz08acmqwtk8g4vkwm2w78suwt2qyzz6jkkwcgrjm3r3hs6fskyhvud4fan3keru7emjm8ygqpcrwtlmhfjfmer3afs5hhwamgr4cqtactdq".to_string(); + /// let quote = wallet.melt_quote(bolt11, None).await?; + /// let quote_id = quote.id; + /// + /// let mut metadata = std::collections::HashMap::new(); + /// metadata.insert("my key".to_string(), "my value".to_string()); + /// + /// let _ = wallet.melt_with_metadata("e_id, metadata).await?; + /// + /// Ok(()) + /// } + #[instrument(skip(self))] + pub async fn melt_with_metadata( + &self, + quote_id: &str, + metadata: HashMap, + ) -> Result { let quote_info = self .localstore .get_melt_quote(quote_id) @@ -310,13 +390,14 @@ impl Wallet { let available_proofs = self.get_unspent_proofs().await?; let active_keyset_ids = self - .get_active_mint_keysets() + .get_mint_keysets() .await? .into_iter() .map(|k| k.id) .collect(); - let keyset_fees = self.get_keyset_fees().await?; - let (mut input_proofs, mut exchange) = Wallet::select_exact_proofs( + let keyset_fees = self.get_keyset_fees_and_amounts().await?; + + let input_proofs = Wallet::select_proofs( inputs_needed_amount, available_proofs, &active_keyset_ids, @@ -324,24 +405,7 @@ impl Wallet { true, )?; - if let Some((proof, exact_amount)) = exchange.take() { - let new_proofs = self - .swap( - Some(exact_amount), - SplitTarget::None, - vec![proof.clone()], - None, - false, - ) - .await? - .ok_or_else(|| { - tracing::error!("Received empty proofs"); - Error::Internal - })?; - - input_proofs.extend_from_slice(&new_proofs); - } - - self.melt_proofs(quote_id, input_proofs).await + self.melt_proofs_with_metadata(quote_id, input_proofs, metadata) + .await } } diff --git a/crates/cdk/src/wallet/melt/melt_bolt12.rs b/crates/cdk/src/wallet/melt/melt_bolt12.rs new file mode 100644 index 000000000..9728e79dc --- /dev/null +++ b/crates/cdk/src/wallet/melt/melt_bolt12.rs @@ -0,0 +1,98 @@ +//! Melt BOLT12 +//! +//! Implementation of melt functionality for BOLT12 offers + +use std::str::FromStr; + +use cdk_common::amount::amount_for_offer; +use cdk_common::wallet::MeltQuote; +use cdk_common::PaymentMethod; +use lightning::offers::offer::Offer; +use tracing::instrument; + +use crate::amount::to_unit; +use crate::nuts::{CurrencyUnit, MeltOptions, MeltQuoteBolt11Response, MeltQuoteBolt12Request}; +use crate::{Error, Wallet}; + +impl Wallet { + /// Melt Quote for BOLT12 offer + #[instrument(skip(self, request))] + pub async fn melt_bolt12_quote( + &self, + request: String, + options: Option, + ) -> Result { + let quote_request = MeltQuoteBolt12Request { + request: request.clone(), + unit: self.unit.clone(), + options, + }; + + let quote_res = self.client.post_melt_bolt12_quote(quote_request).await?; + + if self.unit == CurrencyUnit::Sat || self.unit == CurrencyUnit::Msat { + let offer = Offer::from_str(&request).map_err(|_| Error::Bolt12parse)?; + // Get amount from offer or options + let amount_msat = options + .map(|opt| opt.amount_msat()) + .or_else(|| amount_for_offer(&offer, &CurrencyUnit::Msat).ok()) + .ok_or(Error::AmountUndefined)?; + let amount_quote_unit = to_unit(amount_msat, &CurrencyUnit::Msat, &self.unit).unwrap(); + + if quote_res.amount != amount_quote_unit { + tracing::warn!( + "Mint returned incorrect quote amount. Expected {}, got {}", + amount_quote_unit, + quote_res.amount + ); + return Err(Error::IncorrectQuoteAmount); + } + } + + let quote = MeltQuote { + id: quote_res.quote, + amount: quote_res.amount, + request, + unit: self.unit.clone(), + fee_reserve: quote_res.fee_reserve, + state: quote_res.state, + expiry: quote_res.expiry, + payment_preimage: quote_res.payment_preimage, + payment_method: PaymentMethod::Bolt12, + }; + + self.localstore.add_melt_quote(quote.clone()).await?; + + Ok(quote) + } + + /// BOLT12 melt quote status + #[instrument(skip(self, quote_id))] + pub async fn melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let response = self.client.get_melt_bolt12_quote_status(quote_id).await?; + + match self.localstore.get_melt_quote(quote_id).await? { + Some(quote) => { + let mut quote = quote; + + if let Err(e) = self + .add_transaction_for_pending_melt("e, &response) + .await + { + tracing::error!("Failed to add transaction for pending melt: {}", e); + } + + quote.state = response.state; + self.localstore.add_melt_quote(quote).await?; + } + None => { + tracing::info!("Quote melt {} unknown", quote_id); + } + } + + Ok(response) + } +} diff --git a/crates/cdk/src/wallet/melt/melt_lightning_address.rs b/crates/cdk/src/wallet/melt/melt_lightning_address.rs new file mode 100644 index 000000000..98a7c6023 --- /dev/null +++ b/crates/cdk/src/wallet/melt/melt_lightning_address.rs @@ -0,0 +1,90 @@ +//! Melt Lightning Address +//! +//! Implementation of melt functionality for Lightning addresses + +use std::str::FromStr; + +use cdk_common::wallet::MeltQuote; +use tracing::instrument; + +use crate::lightning_address::LightningAddress; +use crate::{Amount, Error, Wallet}; + +impl Wallet { + /// Melt Quote for Lightning address + /// + /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice + /// and then creates a melt quote for that invoice. + /// + /// # Arguments + /// + /// * `lightning_address` - Lightning address in the format "user@domain.com" + /// * `amount_msat` - Amount to pay in millisatoshis + /// + /// # Returns + /// + /// A `MeltQuote` that can be used to execute the payment + /// + /// # Errors + /// + /// This method will return an error if: + /// - The Lightning address format is invalid + /// - HTTP request to the Lightning address service fails + /// - The amount is outside the acceptable range + /// - The service returns an error + /// - The mint fails to provide a quote for the invoice + /// + /// # Example + /// + /// ```rust,no_run + /// use cdk::Amount; + /// # use cdk::Wallet; + /// # async fn example(wallet: Wallet) -> Result<(), cdk::Error> { + /// let quote = wallet + /// .melt_lightning_address_quote("alice@example.com", Amount::from(100_000)) // 100 sats in msat + /// .await?; + /// # Ok(()) + /// # } + /// ``` + #[instrument(skip(self, amount_msat), fields(lightning_address = %lightning_address))] + pub async fn melt_lightning_address_quote( + &self, + lightning_address: &str, + amount_msat: impl Into, + ) -> Result { + let amount = amount_msat.into(); + + // Parse the Lightning address + let ln_address = LightningAddress::from_str(lightning_address).map_err(|e| { + tracing::error!( + "Failed to parse Lightning address '{}': {}", + lightning_address, + e + ); + Error::LightningAddressParse(e.to_string()) + })?; + + tracing::debug!("Resolving Lightning address: {}", ln_address); + + // Request an invoice from the Lightning address service + let invoice = ln_address + .request_invoice(&self.client, amount) + .await + .map_err(|e| { + tracing::error!( + "Failed to get invoice from Lightning address service: {}", + e + ); + Error::LightningAddressRequest(e.to_string()) + })?; + + tracing::debug!( + "Received invoice from Lightning address service: {}", + invoice + ); + + // Create a melt quote for the invoice using the existing bolt11 functionality + // The invoice from LNURL already contains the amount, so we don't need amountless options + self.melt_quote(invoice.to_string(), None).await + } +} diff --git a/crates/cdk/src/wallet/melt/mod.rs b/crates/cdk/src/wallet/melt/mod.rs new file mode 100644 index 000000000..037d5210c --- /dev/null +++ b/crates/cdk/src/wallet/melt/mod.rs @@ -0,0 +1,150 @@ +use std::collections::HashMap; + +use cdk_common::util::unix_time; +use cdk_common::wallet::{MeltQuote, Transaction, TransactionDirection}; +use cdk_common::{Error, MeltQuoteBolt11Response, MeltQuoteState, ProofsMethods}; +use tracing::instrument; + +use crate::Wallet; + +#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] +mod melt_bip353; +mod melt_bolt11; +mod melt_bolt12; +#[cfg(feature = "wallet")] +mod melt_lightning_address; + +impl Wallet { + /// Check pending melt quotes + #[instrument(skip_all)] + pub async fn check_pending_melt_quotes(&self) -> Result<(), Error> { + let quotes = self.get_pending_melt_quotes().await?; + for quote in quotes { + self.melt_quote_status("e.id).await?; + } + Ok(()) + } + + /// Get all active melt quotes from the wallet + pub async fn get_active_melt_quotes(&self) -> Result, Error> { + let quotes = self.localstore.get_melt_quotes().await?; + Ok(quotes + .into_iter() + .filter(|q| { + q.state == MeltQuoteState::Pending + || (q.state == MeltQuoteState::Unpaid && q.expiry > unix_time()) + }) + .collect()) + } + + /// Get pending melt quotes + pub async fn get_pending_melt_quotes(&self) -> Result, Error> { + let quotes = self.localstore.get_melt_quotes().await?; + Ok(quotes + .into_iter() + .filter(|q| q.state == MeltQuoteState::Pending) + .collect()) + } + + pub(crate) async fn add_transaction_for_pending_melt( + &self, + quote: &MeltQuote, + response: &MeltQuoteBolt11Response, + ) -> Result<(), Error> { + if quote.state != response.state { + tracing::info!( + "Quote melt {} state changed from {} to {}", + quote.id, + quote.state, + response.state + ); + if response.state == MeltQuoteState::Paid { + let pending_proofs = self.get_pending_proofs().await?; + let proofs_total = pending_proofs.total_amount().unwrap_or_default(); + let change_total = response.change_amount().unwrap_or_default(); + + self.localstore + .add_transaction(Transaction { + mint_url: self.mint_url.clone(), + direction: TransactionDirection::Outgoing, + amount: response.amount, + fee: proofs_total + .checked_sub(response.amount) + .and_then(|amt| amt.checked_sub(change_total)) + .unwrap_or_default(), + unit: quote.unit.clone(), + ys: pending_proofs.ys()?, + timestamp: unix_time(), + memo: None, + metadata: HashMap::new(), + quote_id: Some(quote.id.clone()), + payment_request: Some(quote.request.clone()), + payment_proof: response.payment_preimage.clone(), + }) + .await?; + } + } + Ok(()) + } + + /// Get a melt quote for a human-readable address + /// + /// This method accepts a human-readable address that could be either a BIP353 address + /// or a Lightning address. It intelligently determines which to try based on mint support: + /// + /// 1. If the mint supports Bolt12, it tries BIP353 first + /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails + /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address + /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly + #[cfg(all(feature = "bip353", feature = "wallet", not(target_arch = "wasm32")))] + pub async fn melt_human_readable_quote( + &self, + address: &str, + amount_msat: impl Into, + ) -> Result { + use cdk_common::nuts::PaymentMethod; + + let amount = amount_msat.into(); + + // Get mint info from cache to check bolt12 support (no network call) + let mint_info = &self + .metadata_cache + .load(&self.localstore, &self.client, { + let ttl = self.metadata_cache_ttl.read(); + *ttl + }) + .await? + .mint_info; + + // Check if mint supports bolt12 by looking at nut05 methods + let supports_bolt12 = mint_info + .nuts + .nut05 + .methods + .iter() + .any(|m| m.method == PaymentMethod::Bolt12); + + if supports_bolt12 { + // Mint supports bolt12, try BIP353 first + match self.melt_bip353_quote(address, amount).await { + Ok(quote) => Ok(quote), + Err(Error::Bip353Resolve(_)) => { + // DNS resolution failed, fall back to Lightning address + tracing::debug!( + "BIP353 DNS resolution failed for {}, trying Lightning address", + address + ); + return self.melt_lightning_address_quote(address, amount).await; + } + Err(e) => { + // BIP353 resolved but failed for another reason (e.g., mint error) + // Don't fall back to Lightning address + Err(e) + } + } + } else { + // Mint doesn't support bolt12, use Lightning address directly + self.melt_lightning_address_quote(address, amount).await + } + } +} diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs index d15989bcf..1a4c5bb1a 100644 --- a/crates/cdk/src/wallet/mint_connector/http_client.rs +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -1,20 +1,23 @@ -#[cfg(feature = "auth")] -use std::sync::Arc; +//! HTTP Mint client with pluggable transport +use std::collections::HashSet; +use std::sync::{Arc, RwLock as StdRwLock}; use async_trait::async_trait; +use cdk_common::{nut19, MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; +use cdk_common::common::UnitMetadata; +use crate::nuts::CurrencyUnit; #[cfg(feature = "auth")] use cdk_common::{Method, ProtectedEndpoint, RoutePath}; -use reqwest::{Client, IntoUrl}; use serde::de::DeserializeOwned; use serde::Serialize; #[cfg(feature = "auth")] use tokio::sync::RwLock; use tracing::instrument; -#[cfg(not(target_arch = "wasm32"))] use url::Url; +use web_time::{Duration, Instant}; +use super::transport::Transport; use super::{Error, MintConnector}; -use crate::error::ErrorResponse; use crate::mint_url::MintUrl; #[cfg(feature = "auth")] use crate::nuts::nut22::MintAuthRequest; @@ -27,102 +30,58 @@ use crate::nuts::{ #[cfg(feature = "auth")] use crate::wallet::auth::{AuthMintConnector, AuthWallet}; +type Cache = (u64, HashSet<(nut19::Method, nut19::Path)>); + +/// Http Client #[derive(Debug, Clone)] -struct HttpClientCore { - inner: Client, +pub struct HttpClient +where + T: Transport + Send + Sync + 'static, +{ + transport: Arc, + mint_url: MintUrl, + cache_support: Arc>, + #[cfg(feature = "auth")] + auth_wallet: Arc>>, } -impl HttpClientCore { - fn new() -> Self { - #[cfg(not(target_arch = "wasm32"))] - if rustls::crypto::CryptoProvider::get_default().is_none() { - let _ = rustls::crypto::ring::default_provider().install_default(); - } - +impl HttpClient +where + T: Transport + Send + Sync + 'static, +{ + /// Create new [`HttpClient`] with a provided transport implementation. + #[cfg(feature = "auth")] + pub fn with_transport( + mint_url: MintUrl, + transport: T, + auth_wallet: Option, + ) -> Self { Self { - inner: Client::new(), + transport: transport.into(), + mint_url, + auth_wallet: Arc::new(RwLock::new(auth_wallet)), + cache_support: Default::default(), } } - fn client(&self) -> &Client { - &self.inner - } - - async fn http_get( - &self, - url: U, - auth: Option, - ) -> Result { - let mut request = self.client().get(url); - - if let Some(auth) = auth { - request = request.header(auth.header_key(), auth.to_string()); + /// Create new [`HttpClient`] with a provided transport implementation. + #[cfg(not(feature = "auth"))] + pub fn with_transport(mint_url: MintUrl, transport: T) -> Self { + Self { + transport: transport.into(), + mint_url, + cache_support: Default::default(), } - - let response = request - .send() - .await - .map_err(|e| Error::HttpError(e.to_string()))? - .text() - .await - .map_err(|e| Error::HttpError(e.to_string()))?; - - serde_json::from_str::(&response).map_err(|err| { - tracing::warn!("Http Response error: {}", err); - match ErrorResponse::from_json(&response) { - Ok(ok) => >::into(ok), - Err(err) => err.into(), - } - }) } - async fn http_post( - &self, - url: U, - auth_token: Option, - payload: &P, - ) -> Result { - let mut request = self.client().post(url).json(&payload); - - if let Some(auth) = auth_token { - request = request.header(auth.header_key(), auth.to_string()); - } - - let response = request - .send() - .await - .map_err(|e| Error::HttpError(e.to_string()))? - .text() - .await - .map_err(|e| Error::HttpError(e.to_string()))?; - - serde_json::from_str::(&response).map_err(|err| { - tracing::warn!("Http Response error: {}", err); - match ErrorResponse::from_json(&response) { - Ok(ok) => >::into(ok), - Err(err) => err.into(), - } - }) - } -} - -/// Http Client -#[derive(Debug, Clone)] -pub struct HttpClient { - core: HttpClientCore, - mint_url: MintUrl, - #[cfg(feature = "auth")] - auth_wallet: Arc>>, -} - -impl HttpClient { /// Create new [`HttpClient`] #[cfg(feature = "auth")] pub fn new(mint_url: MintUrl, auth_wallet: Option) -> Self { Self { - core: HttpClientCore::new(), + transport: T::default().into(), mint_url, auth_wallet: Arc::new(RwLock::new(auth_wallet)), + cache_support: Default::default(), } } @@ -130,7 +89,8 @@ impl HttpClient { /// Create new [`HttpClient`] pub fn new(mint_url: MintUrl) -> Self { Self { - core: HttpClientCore::new(), + transport: T::default().into(), + cache_support: Default::default(), mint_url, } } @@ -138,7 +98,7 @@ impl HttpClient { /// Get auth token for a protected endpoint #[cfg(feature = "auth")] #[instrument(skip(self))] - async fn get_auth_token( + pub async fn get_auth_token( &self, method: Method, path: RoutePath, @@ -153,7 +113,6 @@ impl HttpClient { } } - #[cfg(not(target_arch = "wasm32"))] /// Create new [`HttpClient`] with a proxy for specific TLDs. /// Specifying `None` for `host_matcher` will use the proxy for all /// requests. @@ -163,47 +122,131 @@ impl HttpClient { host_matcher: Option<&str>, accept_invalid_certs: bool, ) -> Result { - let regex = host_matcher - .map(regex::Regex::new) - .transpose() - .map_err(|e| Error::Custom(e.to_string()))?; - let client = reqwest::Client::builder() - .proxy(reqwest::Proxy::custom(move |url| { - if let Some(matcher) = regex.as_ref() { - if let Some(host) = url.host_str() { - if matcher.is_match(host) { - return Some(proxy.clone()); - } - } - } - None - })) - .danger_accept_invalid_certs(accept_invalid_certs) // Allow self-signed certs - .build() - .map_err(|e| Error::HttpError(e.to_string()))?; + let mut transport = T::default(); + transport.with_proxy(proxy, host_matcher, accept_invalid_certs)?; Ok(Self { - core: HttpClientCore { inner: client }, + transport: transport.into(), mint_url, #[cfg(feature = "auth")] auth_wallet: Arc::new(RwLock::new(None)), + cache_support: Default::default(), }) } + + /// Generic implementation of a retriable http request + /// + /// The retry only happens if the mint supports replay through the Caching of NUT-19. + #[inline(always)] + async fn retriable_http_request( + &self, + method: nut19::Method, + path: nut19::Path, + auth_token: Option, + payload: &P, + ) -> Result + where + P: Serialize + ?Sized + Send + Sync, + R: DeserializeOwned, + { + let started = Instant::now(); + + let retriable_window = self + .cache_support + .read() + .map(|cache_support| { + cache_support + .1 + .get(&(method, path)) + .map(|_| cache_support.0) + }) + .unwrap_or_default() + .map(Duration::from_secs) + .unwrap_or_default(); + + let transport = self.transport.clone(); + loop { + let url = self.mint_url.join_paths(&match path { + nut19::Path::MintBolt11 => vec!["v1", "mint", "bolt11"], + nut19::Path::MeltBolt11 => vec!["v1", "melt", "bolt11"], + nut19::Path::MintBolt12 => vec!["v1", "mint", "bolt12"], + + nut19::Path::MeltBolt12 => vec!["v1", "melt", "bolt12"], + nut19::Path::Swap => vec!["v1", "swap"], + })?; + + let result = match method { + nut19::Method::Get => transport.http_get(url, auth_token.clone()).await, + nut19::Method::Post => transport.http_post(url, auth_token.clone(), payload).await, + }; + + if result.is_ok() { + return result; + } + + match result.as_ref() { + Err(Error::HttpError(status_code, _)) => { + let status_code = status_code.to_owned().unwrap_or_default(); + if (400..=499).contains(&status_code) { + // 4xx errors won't be 'solved' by retrying + return result; + } + + // retry request, if possible + tracing::error!("Failed http_request {:?}", result.as_ref().err()); + + if retriable_window < started.elapsed() { + return result; + } + } + Err(_) => return result, + _ => unreachable!(), + }; + } + } } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] -impl MintConnector for HttpClient { +impl MintConnector for HttpClient +where + T: Transport + Send + Sync + 'static, +{ + #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn resolve_dns_txt(&self, domain: &str) -> Result, Error> { + self.transport.resolve_dns_txt(domain).await + } + + /// Fetch Lightning address pay request data + #[instrument(skip(self))] + async fn fetch_lnurl_pay_request( + &self, + url: &str, + ) -> Result { + let parsed_url = + url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?; + self.transport.http_get(parsed_url, None).await + } + + /// Fetch invoice from Lightning address callback + #[instrument(skip(self))] + async fn fetch_lnurl_invoice( + &self, + url: &str, + ) -> Result { + let parsed_url = + url::Url::parse(url).map_err(|e| Error::Custom(format!("Invalid URL: {}", e)))?; + self.transport.http_get(parsed_url, None).await + } + /// Get Active Mint Keys [NUT-01] #[instrument(skip(self), fields(mint_url = %self.mint_url))] async fn get_mint_keys(&self) -> Result, Error> { let url = self.mint_url.join_paths(&["v1", "keys"])?; + let transport = self.transport.clone(); - Ok(self - .core - .http_get::<_, KeysResponse>(url, None) - .await? - .keysets) + Ok(transport.http_get::(url, None).await?.keysets) } /// Get Keyset Keys [NUT-01] @@ -213,7 +256,8 @@ impl MintConnector for HttpClient { .mint_url .join_paths(&["v1", "keys", &keyset_id.to_string()])?; - let keys_response = self.core.http_get::<_, KeysResponse>(url, None).await?; + let transport = self.transport.clone(); + let keys_response = transport.http_get::(url, None).await?; Ok(keys_response.keysets.first().unwrap().clone()) } @@ -222,7 +266,8 @@ impl MintConnector for HttpClient { #[instrument(skip(self), fields(mint_url = %self.mint_url))] async fn get_mint_keysets(&self) -> Result { let url = self.mint_url.join_paths(&["v1", "keysets"])?; - self.core.http_get(url, None).await + let transport = self.transport.clone(); + transport.http_get(url, None).await } /// Mint Quote [NUT-04] @@ -243,7 +288,7 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + self.transport.http_post(url, auth_token, &request).await } /// Mint Quote status @@ -263,13 +308,12 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_get(url, auth_token).await + self.transport.http_get(url, auth_token).await } /// Mint Tokens [NUT-04] #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] async fn post_mint(&self, request: MintRequest) -> Result { - let url = self.mint_url.join_paths(&["v1", "mint", "bolt11"])?; #[cfg(feature = "auth")] let auth_token = self .get_auth_token(Method::Post, RoutePath::MintBolt11) @@ -277,7 +321,13 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + self.retriable_http_request( + nut19::Method::Post, + nut19::Path::MintBolt11, + auth_token, + &request, + ) + .await } /// Melt Quote [NUT-05] @@ -296,7 +346,7 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + self.transport.http_post(url, auth_token, &request).await } /// Melt Quote Status @@ -316,7 +366,7 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_get(url, auth_token).await + self.transport.http_get(url, auth_token).await } /// Melt [NUT-05] @@ -326,7 +376,6 @@ impl MintConnector for HttpClient { &self, request: MeltRequest, ) -> Result, Error> { - let url = self.mint_url.join_paths(&["v1", "melt", "bolt11"])?; #[cfg(feature = "auth")] let auth_token = self .get_auth_token(Method::Post, RoutePath::MeltBolt11) @@ -334,25 +383,54 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + + self.retriable_http_request( + nut19::Method::Post, + nut19::Path::MeltBolt11, + auth_token, + &request, + ) + .await } /// Swap Token [NUT-03] #[instrument(skip(self, swap_request), fields(mint_url = %self.mint_url))] async fn post_swap(&self, swap_request: SwapRequest) -> Result { - let url = self.mint_url.join_paths(&["v1", "swap"])?; #[cfg(feature = "auth")] let auth_token = self.get_auth_token(Method::Post, RoutePath::Swap).await?; #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &swap_request).await + + self.retriable_http_request( + nut19::Method::Post, + nut19::Path::Swap, + auth_token, + &swap_request, + ) + .await } /// Helper to get mint info async fn get_mint_info(&self) -> Result { let url = self.mint_url.join_paths(&["v1", "info"])?; - self.core.http_get(url, None).await + let transport = self.transport.clone(); + let info: MintInfo = transport.http_get(url, None).await?; + + if let Ok(mut cache_support) = self.cache_support.write() { + *cache_support = ( + info.nuts.nut19.ttl.unwrap_or(300), + info.nuts + .nut19 + .cached_endpoints + .clone() + .into_iter() + .map(|cached_endpoint| (cached_endpoint.method, cached_endpoint.path)) + .collect(), + ); + } + + Ok(info) } #[cfg(feature = "auth")] @@ -379,7 +457,7 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + self.transport.http_post(url, auth_token, &request).await } /// Restore request [NUT-13] @@ -393,25 +471,144 @@ impl MintConnector for HttpClient { #[cfg(not(feature = "auth"))] let auth_token = None; - self.core.http_post(url, auth_token, &request).await + self.transport.http_post(url, auth_token, &request).await + } + + /// Mint Quote Bolt12 [NUT-23] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn post_mint_bolt12_quote( + &self, + request: MintQuoteBolt12Request, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "bolt12"])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MintQuoteBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + + self.transport.http_post(url, auth_token, &request).await + } + + /// Mint Quote Bolt12 status + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_mint_quote_bolt12_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "bolt12", quote_id])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Get, RoutePath::MintQuoteBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.transport.http_get(url, auth_token).await + } + + /// Melt Quote Bolt12 [NUT-23] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_melt_bolt12_quote( + &self, + request: MeltQuoteBolt12Request, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "melt", "quote", "bolt12"])?; + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MeltQuoteBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.transport.http_post(url, auth_token, &request).await + } + + /// Melt Quote Bolt12 Status [NUT-23] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "melt", "quote", "bolt12", quote_id])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Get, RoutePath::MeltQuoteBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.transport.http_get(url, auth_token).await + } + + /// Melt Bolt12 [NUT-23] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_melt_bolt12( + &self, + request: MeltRequest, + ) -> Result, Error> { + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MeltBolt12) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.retriable_http_request( + nut19::Method::Post, + nut19::Path::MeltBolt12, + auth_token, + &request, + ) + .await + } + + /// Get Unit Metadata + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_unit_metadata(&self, unit: CurrencyUnit) -> Result { + let unit_str = unit.to_string(); + let url = self + .mint_url + .join_paths(&["v1", "unit", &unit_str])?; + + self.transport.http_get(url, None).await } } /// Http Client #[derive(Debug, Clone)] #[cfg(feature = "auth")] -pub struct AuthHttpClient { - core: HttpClientCore, +pub struct AuthHttpClient +where + T: Transport + Send + Sync + 'static, +{ + transport: Arc, mint_url: MintUrl, cat: Arc>, } #[cfg(feature = "auth")] -impl AuthHttpClient { +impl AuthHttpClient +where + T: Transport + Send + Sync + 'static, +{ /// Create new [`AuthHttpClient`] pub fn new(mint_url: MintUrl, cat: Option) -> Self { Self { - core: HttpClientCore::new(), + transport: T::default().into(), mint_url, cat: Arc::new(RwLock::new( cat.unwrap_or(AuthToken::ClearAuth("".to_string())), @@ -423,7 +620,10 @@ impl AuthHttpClient { #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] #[cfg(feature = "auth")] -impl AuthMintConnector for AuthHttpClient { +impl AuthMintConnector for AuthHttpClient +where + T: Transport + Send + Sync + 'static, +{ async fn get_auth_token(&self) -> Result { Ok(self.cat.read().await.clone()) } @@ -436,7 +636,7 @@ impl AuthMintConnector for AuthHttpClient { /// Get Mint Info [NUT-06] async fn get_mint_info(&self) -> Result { let url = self.mint_url.join_paths(&["v1", "info"])?; - let mint_info: MintInfo = self.core.http_get::<_, MintInfo>(url, None).await?; + let mint_info: MintInfo = self.transport.http_get::(url, None).await?; Ok(mint_info) } @@ -448,7 +648,7 @@ impl AuthMintConnector for AuthHttpClient { self.mint_url .join_paths(&["v1", "auth", "blind", "keys", &keyset_id.to_string()])?; - let mut keys_response = self.core.http_get::<_, KeysResponse>(url, None).await?; + let mut keys_response = self.transport.http_get::(url, None).await?; let keyset = keys_response .keysets @@ -466,14 +666,14 @@ impl AuthMintConnector for AuthHttpClient { .mint_url .join_paths(&["v1", "auth", "blind", "keysets"])?; - self.core.http_get(url, None).await + self.transport.http_get(url, None).await } /// Mint Tokens [NUT-22] #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] async fn post_mint_blind_auth(&self, request: MintAuthRequest) -> Result { let url = self.mint_url.join_paths(&["v1", "auth", "blind", "mint"])?; - self.core + self.transport .http_post(url, Some(self.cat.read().await.clone()), &request) .await } diff --git a/crates/cdk/src/wallet/mint_connector/mod.rs b/crates/cdk/src/wallet/mint_connector/mod.rs index 712ba8e9d..41514f4cf 100644 --- a/crates/cdk/src/wallet/mint_connector/mod.rs +++ b/crates/cdk/src/wallet/mint_connector/mod.rs @@ -3,27 +3,54 @@ use std::fmt::Debug; use async_trait::async_trait; +use cdk_common::{MeltQuoteBolt12Request, MintQuoteBolt12Request, MintQuoteBolt12Response}; use super::Error; +// Re-export Lightning address types for trait implementers +pub use crate::lightning_address::{LnurlPayInvoiceResponse, LnurlPayResponse}; use crate::nuts::{ CheckStateRequest, CheckStateResponse, Id, KeySet, KeysetResponse, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MeltRequest, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, MintRequest, MintResponse, RestoreRequest, RestoreResponse, SwapRequest, SwapResponse, }; +use cdk_common::common::UnitMetadata; +use crate::nuts::CurrencyUnit; #[cfg(feature = "auth")] use crate::wallet::AuthWallet; -mod http_client; +pub mod http_client; +pub mod transport; +/// Auth HTTP Client with async transport #[cfg(feature = "auth")] -pub use http_client::AuthHttpClient; -pub use http_client::HttpClient; +pub type AuthHttpClient = http_client::AuthHttpClient; +/// Default Http Client with async transport (non-Tor) +pub type HttpClient = http_client::HttpClient; +/// Tor Http Client with async transport (only when `tor` feature is enabled and not on wasm32) +#[cfg(all(feature = "tor", not(target_arch = "wasm32")))] +pub type TorHttpClient = http_client::HttpClient; /// Interface that connects a wallet to a mint. Typically represents an [HttpClient]. #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] pub trait MintConnector: Debug { + #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] + /// Resolve the DNS record getting the TXT value + async fn resolve_dns_txt(&self, _domain: &str) -> Result, Error>; + + /// Fetch Lightning address pay request data + async fn fetch_lnurl_pay_request( + &self, + url: &str, + ) -> Result; + + /// Fetch invoice from Lightning address callback + async fn fetch_lnurl_invoice( + &self, + url: &str, + ) -> Result; + /// Get Active Mint Keys [NUT-01] async fn get_mint_keys(&self) -> Result, Error>; /// Get Keyset Keys [NUT-01] @@ -77,4 +104,32 @@ pub trait MintConnector: Debug { /// Set auth wallet on client #[cfg(feature = "auth")] async fn set_auth_wallet(&self, wallet: Option); + /// Mint Quote [NUT-04] + async fn post_mint_bolt12_quote( + &self, + request: MintQuoteBolt12Request, + ) -> Result, Error>; + /// Mint Quote status + async fn get_mint_quote_bolt12_status( + &self, + quote_id: &str, + ) -> Result, Error>; + /// Melt Quote [NUT-23] + async fn post_melt_bolt12_quote( + &self, + request: MeltQuoteBolt12Request, + ) -> Result, Error>; + /// Melt Quote Status [NUT-23] + async fn get_melt_bolt12_quote_status( + &self, + quote_id: &str, + ) -> Result, Error>; + /// Melt [NUT-23] + async fn post_melt_bolt12( + &self, + request: MeltRequest, + ) -> Result, Error>; + + /// Get Unit Metadata + async fn get_unit_metadata(&self, unit: CurrencyUnit) -> Result; } diff --git a/crates/cdk/src/wallet/mint_connector/transport.rs b/crates/cdk/src/wallet/mint_connector/transport.rs new file mode 100644 index 000000000..8238c9cec --- /dev/null +++ b/crates/cdk/src/wallet/mint_connector/transport.rs @@ -0,0 +1,221 @@ +//! HTTP Transport trait with a default implementation +use std::fmt::Debug; + +use cdk_common::AuthToken; +#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] +use hickory_resolver::config::ResolverConfig; +#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] +use hickory_resolver::name_server::TokioConnectionProvider; +#[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] +use hickory_resolver::Resolver; +use reqwest::Client; +use serde::de::DeserializeOwned; +use serde::Serialize; +use url::Url; + +use super::Error; +use crate::error::ErrorResponse; + +/// Expected HTTP Transport +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +pub trait Transport: Default + Send + Sync + Debug + Clone { + #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] + /// DNS resolver to get a TXT record from a domain name + async fn resolve_dns_txt(&self, _domain: &str) -> Result, Error>; + + /// Make the transport to use a given proxy + fn with_proxy( + &mut self, + proxy: url::Url, + host_matcher: Option<&str>, + accept_invalid_certs: bool, + ) -> Result<(), super::Error>; + + /// HTTP Get request + async fn http_get( + &self, + url: url::Url, + auth: Option, + ) -> Result + where + R: serde::de::DeserializeOwned; + + /// HTTP Post request + async fn http_post( + &self, + url: url::Url, + auth_token: Option, + payload: &P, + ) -> Result + where + P: serde::Serialize + ?Sized + Send + Sync, + R: serde::de::DeserializeOwned; +} + +/// Async transport for Http +#[derive(Debug, Clone)] +pub struct Async { + inner: Client, +} + +impl Default for Async { + fn default() -> Self { + #[cfg(not(target_arch = "wasm32"))] + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + Self { + inner: Client::new(), + } + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl Transport for Async { + #[cfg(target_arch = "wasm32")] + fn with_proxy( + &mut self, + _proxy: Url, + _host_matcher: Option<&str>, + _accept_invalid_certs: bool, + ) -> Result<(), Error> { + panic!("Not supported in wasm"); + } + + #[cfg(not(target_arch = "wasm32"))] + fn with_proxy( + &mut self, + proxy: Url, + host_matcher: Option<&str>, + accept_invalid_certs: bool, + ) -> Result<(), Error> { + let builder = reqwest::Client::builder().danger_accept_invalid_certs(accept_invalid_certs); + + let builder = match host_matcher { + Some(pattern) => { + // When a matcher is provided, only apply the proxy to matched hosts + let regex = regex::Regex::new(pattern).map_err(|e| Error::Custom(e.to_string()))?; + builder.proxy(reqwest::Proxy::custom(move |url| { + url.host_str() + .filter(|host| regex.is_match(host)) + .map(|_| proxy.clone()) + })) + } + // Apply proxy to all requests when no matcher is provided + None => { + builder.proxy(reqwest::Proxy::all(proxy).map_err(|e| Error::Custom(e.to_string()))?) + } + }; + + self.inner = builder + .build() + .map_err(|e| Error::HttpError(e.status().map(|s| s.as_u16()), e.to_string()))?; + Ok(()) + } + + /// DNS resolver to get a TXT record from a domain name + #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] + async fn resolve_dns_txt(&self, domain: &str) -> Result, Error> { + let resolver = Resolver::builder_with_config( + ResolverConfig::default(), + TokioConnectionProvider::default(), + ) + .build(); + + Ok(resolver + .txt_lookup(domain) + .await + .map_err(|e| Error::Custom(e.to_string()))? + .into_iter() + .map(|txt| { + txt.txt_data() + .iter() + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) + .collect::>() + .join("") + }) + .collect::>()) + } + + async fn http_get(&self, url: Url, auth: Option) -> Result + where + R: DeserializeOwned, + { + let mut request = self.inner.get(url); + + if let Some(auth) = auth { + request = request.header(auth.header_key(), auth.to_string()); + } + + let response = request + .send() + .await + .map_err(|e| { + Error::HttpError( + e.status().map(|status_code| status_code.as_u16()), + e.to_string(), + ) + })? + .text() + .await + .map_err(|e| { + Error::HttpError( + e.status().map(|status_code| status_code.as_u16()), + e.to_string(), + ) + })?; + + serde_json::from_str::(&response).map_err(|err| { + tracing::warn!("Http Response error: {}", err); + match ErrorResponse::from_json(&response) { + Ok(ok) => >::into(ok), + Err(err) => err.into(), + } + }) + } + + async fn http_post( + &self, + url: Url, + auth_token: Option, + payload: &P, + ) -> Result + where + P: Serialize + ?Sized + Send + Sync, + R: DeserializeOwned, + { + let mut request = self.inner.post(url).json(&payload); + + if let Some(auth) = auth_token { + request = request.header(auth.header_key(), auth.to_string()); + } + + let response = request.send().await.map_err(|e| { + Error::HttpError( + e.status().map(|status_code| status_code.as_u16()), + e.to_string(), + ) + })?; + + let response = response.text().await.map_err(|e| { + Error::HttpError( + e.status().map(|status_code| status_code.as_u16()), + e.to_string(), + ) + })?; + + serde_json::from_str::(&response).map_err(|err| { + tracing::warn!("Http Response error: {}", err); + match ErrorResponse::from_json(&response) { + Ok(ok) => >::into(ok), + Err(err) => err.into(), + } + }) + } +} + +#[cfg(all(feature = "tor", not(target_arch = "wasm32")))] +pub mod tor_transport; diff --git a/crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs b/crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs new file mode 100644 index 000000000..4fb93710f --- /dev/null +++ b/crates/cdk/src/wallet/mint_connector/transport/tor_transport.rs @@ -0,0 +1,330 @@ +///! Tor transport implementation (non-wasm32 only) +use std::sync::Arc; + +use arti_client::{TorClient, TorClientConfig}; +use arti_hyper::ArtiHttpConnector; +use async_trait::async_trait; +use cdk_common::AuthToken; +use http::header::{self, HeaderName, HeaderValue}; +use hyper::http::{Method, Request, Uri}; +use hyper::{Body, Client}; +use serde::de::DeserializeOwned; +use tls_api::{TlsConnector as _, TlsConnectorBuilder as _}; +use tokio::sync::OnceCell; +use url::Url; + +use super::super::Error; +use crate::wallet::getrandom; +use crate::wallet::mint_connector::transport::{ErrorResponse, Transport}; + +/// Fixed-size pool size +pub const DEFAULT_TOR_POOL_SIZE: usize = 5; + +/// Tor transport that maintains a pool of isolated TorClient handles +#[derive(Clone)] +pub struct TorAsync { + salt: [u8; 4], + size: usize, + pool: Arc>>>, +} + +impl std::fmt::Debug for TorAsync { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let pool_len = self.pool.get().map(|p| p.len()); + f.debug_struct("TorAsync") + .field("configured_pool_size", &self.size) + .field("initialized_pool_size", &pool_len) + .finish() + } +} + +// salt generator (sync, tiny, uses OS RNG) +#[inline] +fn gen_salt() -> [u8; 4] { + let mut s = [0u8; 4]; + getrandom(&mut s).expect("failed to obtain random bytes for TorAsync salt"); + s +} + +impl Default for TorAsync { + fn default() -> Self { + // Do NOT bootstrap here; keep Default cheap and non-blocking. + Self { + size: DEFAULT_TOR_POOL_SIZE, + pool: Arc::new(OnceCell::new()), + salt: gen_salt(), + } + } +} + +impl TorAsync { + /// Create a TorAsync with default pool size (lazy bootstrapping) + pub fn new() -> Self { + Self::default() + } + + /// Create a TorAsync with the given pool size (lazy bootstrapping) + pub fn with_pool_size(size: usize) -> Self { + let size = size.max(1); + Self { + size, + pool: Arc::new(OnceCell::new()), + salt: gen_salt(), + } + } + + /// Ensure the Tor client pool is initialized; build on first use. + async fn ensure_pool(&self) -> Result>, Error> { + let size = self.size; + let pool_ref = self + .pool + .get_or_try_init(|| async move { + let base = TorClient::create_bootstrapped(TorClientConfig::default()) + .await + .map_err(|e| Error::Custom(e.to_string()))?; + let mut clients = Vec::with_capacity(size); + for _ in 0..size { + clients.push(base.isolated_client()); + } + Ok::>, Error>(clients) + }) + .await?; + Ok(pool_ref.clone()) + } + + /// Choose client index deterministically based on authority (scheme, host, port), + /// HTTP method, path+query, and optionally a body fingerprint. + #[inline] + fn index_for_request( + &self, + method: &http::Method, + url: &Url, + body: Option<&[u8]>, + pool_len: usize, + ) -> usize { + // Tiny, dependency-free, stable hash (FNV-1a 64-bit) + const FNV_OFFSET: u64 = 0xcbf29ce484222325; + const FNV_PRIME: u64 = 0x0000_0100_0000_01B3; + fn fnv1a(mut h: u64, bytes: &[u8]) -> u64 { + for &b in bytes { + h ^= b as u64; + h = h.wrapping_mul(FNV_PRIME); + } + h + } + + let mut h = FNV_OFFSET; + + // Mix in salt first so it affects the entire hash space + h = fnv1a(h, &self.salt); + // Include scheme and authority + h = fnv1a(h, url.scheme().as_bytes()); + h = fnv1a(h, b"://"); + if let Some(host) = url.host_str() { + h = fnv1a(h, host.as_bytes()); + } + if let Some(port) = url.port() { + h = fnv1a(h, b":"); + let p = port.to_string(); + h = fnv1a(h, p.as_bytes()); + } + // Include HTTP method + h = fnv1a(h, method.as_str().as_bytes()); + h = fnv1a(h, b" "); + // Include path and query + h = fnv1a(h, url.path().as_bytes()); + if let Some(q) = url.query() { + h = fnv1a(h, b"?"); + h = fnv1a(h, q.as_bytes()); + } + // Optionally include body (full). Could be trimmed in the future if needed. + if let Some(b) = body { + h = fnv1a(h, b); + } + (h as usize) % pool_len.max(1) + } + + async fn request( + &self, + method: http::Method, + url: Url, + auth: Option, + mut body: Option>, + ) -> Result + where + R: DeserializeOwned, + { + let tls = tls_api_native_tls::TlsConnector::builder() + .map_err(|e| Error::Custom(format!("{e:?}")))? + .build() + .map_err(|e| Error::Custom(format!("{e:?}")))?; + + // Lazily initialize the pool and deterministically select a client + let pool = self.ensure_pool().await?; + let idx = self.index_for_request(&method, &url, body.as_deref(), pool.len()); + let client_for_request = pool[idx].clone(); + + let connector = ArtiHttpConnector::new(client_for_request, tls); + let client: Client<_> = Client::builder().build(connector); + + let uri: Uri = url + .as_str() + .parse::() + .map_err(|e| Error::Custom(e.to_string()))?; + + let mut builder = Request::builder().method(method).uri(uri); + builder = builder.header(header::ACCEPT, "application/json"); + + let mut req = if let Some(b) = body.take() { + builder + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from(b)) + .map_err(|e| Error::Custom(e.to_string()))? + } else { + builder + .body(Body::empty()) + .map_err(|e| Error::Custom(e.to_string()))? + }; + + if let Some(auth) = auth { + let key = auth.header_key(); + let val = auth.to_string(); + req.headers_mut().insert( + HeaderName::from_bytes(key.as_bytes()).map_err(|e| Error::Custom(e.to_string()))?, + HeaderValue::from_str(&val).map_err(|e| Error::Custom(e.to_string()))?, + ); + } + + let resp = client + .request(req) + .await + .map_err(|e| Error::HttpError(None, e.to_string()))?; + + let status = resp.status().as_u16(); + let bytes = hyper::body::to_bytes(resp.into_body()) + .await + .map_err(|e| Error::HttpError(None, e.to_string()))?; + + if !(200..300).contains(&status) { + let text = String::from_utf8_lossy(&bytes).to_string(); + return Err(Error::HttpError(Some(status), text)); + } + + serde_json::from_slice::(&bytes).map_err(|err| { + let text = String::from_utf8_lossy(&bytes).to_string(); + tracing::warn!("Http Response error: {}", err); + match ErrorResponse::from_json(&text) { + Ok(ok) => >::into(ok), + Err(err) => err.into(), + } + }) + } +} + +#[async_trait] +impl Transport for TorAsync { + fn with_proxy( + &mut self, + _proxy: Url, + _host_matcher: Option<&str>, + _accept_invalid_certs: bool, + ) -> Result<(), Error> { + panic!("not supported with TorAsync transport"); + } + + async fn http_get( + &self, + url: url::Url, + auth: Option, + ) -> Result + where + R: serde::de::DeserializeOwned, + { + self.request::(Method::GET, url, auth, None).await + } + + async fn http_post( + &self, + url: url::Url, + auth_token: Option, + payload: &P, + ) -> Result + where + P: serde::Serialize + ?Sized + Send + Sync, + R: serde::de::DeserializeOwned, + { + let body = serde_json::to_vec(payload).map_err(|e| Error::Custom(e.to_string()))?; + self.request::(Method::POST, url, auth_token, Some(body)) + .await + } + + #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] + async fn resolve_dns_txt(&self, domain: &str) -> Result, Error> { + #[derive(serde::Deserialize)] + struct Answer { + #[serde(default)] + data: String, + #[allow(dead_code)] + #[serde(default)] + name: String, + #[allow(dead_code)] + #[serde(default)] + r#type: u32, + } + + #[allow(non_snake_case)] + #[derive(serde::Deserialize)] + struct DnsResp { + #[serde(default)] + Answer: Option>, + #[allow(dead_code)] + #[serde(default)] + Status: Option, + } + + fn dequote_txt(s: &str) -> String { + let mut result = String::new(); + let mut in_quote = false; + let mut buf = String::new(); + for ch in s.chars() { + if ch == '"' { + if in_quote { + result.push_str(&buf); + buf.clear(); + in_quote = false; + } else { + in_quote = true; + } + } else if in_quote { + buf.push(ch); + } + } + if !result.is_empty() { + result + } else { + s.trim_matches('"').to_string() + } + } + + let mut url = + Url::parse("https://dns.google/resolve").map_err(|e| Error::Custom(e.to_string()))?; + { + let mut qp = url.query_pairs_mut(); + qp.append_pair("name", domain); + qp.append_pair("type", "TXT"); + } + + let resp: DnsResp = self + .request::(Method::GET, url, None, None::>) + .await?; + + let answers = resp.Answer.unwrap_or_default(); + let txts = answers + .into_iter() + .filter(|a| !a.data.is_empty()) + .map(|a| dequote_txt(&a.data)) + .collect::>(); + + Ok(txts) + } +} diff --git a/crates/cdk/src/wallet/mint_metadata_cache.rs b/crates/cdk/src/wallet/mint_metadata_cache.rs new file mode 100644 index 000000000..f27a2b5c6 --- /dev/null +++ b/crates/cdk/src/wallet/mint_metadata_cache.rs @@ -0,0 +1,647 @@ +//! Per-mint cryptographic key and metadata cache +//! +//! Provides on-demand fetching and caching of mint metadata (info, keysets, and keys) +//! with atomic in-memory cache updates and database persistence. +//! +//! # Architecture +//! +//! - **Pull-based loading**: Keys fetched on-demand from mint HTTP API +//! - **Atomic cache**: Single `MintMetadata` snapshot updated via `ArcSwap` +//! - **Synchronous persistence**: Database writes happen after cache update +//! - **Multi-database support**: Tracks sync status per storage instance via pointer identity +//! +//! # Usage +//! +//! ```ignore +//! // Create manager (cheap, no I/O) +//! let manager = Arc::new(MintMetadataCache::new(mint_url)); +//! +//! // Load metadata (returns cached if available, fetches if not) +//! let metadata = manager.load(&storage, &client).await?; +//! let keys = metadata.keys.get(&keyset_id).ok_or(Error::UnknownKeySet)?; +//! +//! // Force refresh from mint +//! let fresh = manager.load_from_mint(&storage, &client).await?; +//! ``` + +use std::collections::HashMap; +use std::fmt::Debug; +use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use cdk_common::database::{self, WalletDatabase}; +use cdk_common::mint_url::MintUrl; +use cdk_common::nuts::{KeySetInfo, Keys}; +use cdk_common::parking_lot::RwLock; +use cdk_common::{KeySet, MintInfo}; +use tokio::sync::Mutex; +use web_time::Instant; + +use crate::nuts::Id; +use crate::wallet::MintConnector; +#[cfg(feature = "auth")] +use crate::wallet::{AuthMintConnector, AuthWallet}; +use crate::{Error, Wallet}; + +/// Metadata freshness and versioning information +/// +/// Tracks when data was last fetched and which version is currently cached. +/// Used to determine if cache is ready and if database sync is needed. +#[derive(Clone, Debug)] +pub struct FreshnessStatus { + /// Whether this data has been successfully fetched at least once + pub is_populated: bool, + + /// A future time when the cache would be considered as staled. + pub updated_at: Instant, + + /// Monotonically increasing version number (for database sync tracking) + version: usize, +} + +impl Default for FreshnessStatus { + fn default() -> Self { + Self { + is_populated: false, + updated_at: Instant::now(), + version: 0, + } + } +} + +/// Complete metadata snapshot for a single mint +/// +/// Contains all cryptographic keys, keyset metadata, and mint information +/// fetched from a mint server. This struct is atomically swapped as a whole +/// to ensure readers always see a consistent view. +/// +/// Cloning is cheap due to `Arc` wrapping of large data structures. +#[derive(Clone, Debug, Default)] +pub struct MintMetadata { + /// Mint server information (name, description, supported features, etc.) + pub mint_info: MintInfo, + + /// All keysets indexed by their ID (includes both active and inactive) + pub keysets: HashMap>, + + /// Cryptographic keys for each keyset, indexed by keyset ID + pub keys: HashMap>, + + /// Subset of keysets that are currently active (cached for convenience) + pub active_keysets: Vec>, + + /// Freshness tracking for regular (non-auth) mint data + status: FreshnessStatus, + + /// Freshness tracking for blind auth keysets (when `auth` feature enabled) + #[cfg(feature = "auth")] + auth_status: FreshnessStatus, +} + +/// On-demand mint metadata cache with database persistence +/// +/// Manages a single mint's cryptographic keys and metadata. Fetches data from +/// the mint's HTTP API on-demand and caches it in memory. Database writes +/// occur synchronously to ensure persistence. +/// +/// # Thread Safety +/// +/// All methods are safe to call concurrently. The cache uses `ArcSwap` for +/// lock-free reads and atomic updates. A `Mutex` ensures only one fetch +/// operation runs at a time, with other callers waiting and re-reading cache. +/// +/// # Cloning +/// +/// Cheap to clone - all data is behind `Arc`. Clones share the same cache. +#[derive(Clone)] +pub struct MintMetadataCache { + /// The mint server URL this cache manages + mint_url: MintUrl, + + /// Atomically-updated metadata snapshot (lock-free reads) + metadata: Arc>, + + /// Tracks which database instances have been synced to which cache version. + /// Key: pointer identity of storage Arc, Value: last synced cache version + db_sync_versions: Arc>>, + + /// Mutex to ensure only one fetch operation runs at a time + /// Other callers wait for the lock, then re-read the updated cache + fetch_lock: Arc>, +} + +impl std::fmt::Debug for MintMetadataCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MintMetadataCache") + .field("mint_url", &self.mint_url) + .field("is_populated", &self.metadata.load().status.is_populated) + .field("keyset_count", &self.metadata.load().keysets.len()) + .finish() + } +} + +impl Wallet { + /// Sets the metadata cache TTL + pub fn set_metadata_cache_ttl(&self, ttl: Option) { + let mut guarded_ttl = self.metadata_cache_ttl.write(); + *guarded_ttl = ttl; + } + + /// Get information about metadata cache info + pub fn get_metadata_cache_info(&self) -> FreshnessStatus { + self.metadata_cache.metadata.load().status.clone() + } +} + +#[cfg(feature = "auth")] +impl AuthWallet { + /// Get information about metadata cache info + pub fn get_metadata_cache_info(&self) -> FreshnessStatus { + self.metadata_cache.metadata.load().auth_status.clone() + } +} + +impl MintMetadataCache { + /// Compute a unique identifier for an Arc pointer + /// + /// Used to track which storage instances have been synced. We use pointer + /// identity rather than a counter because wallets may use multiple storage + /// backends simultaneously (e.g., different databases for different mints). + fn arc_pointer_id(arc: &Arc) -> usize + where + T: ?Sized, + { + Arc::as_ptr(arc) as *const () as usize + } + + /// Create a new metadata cache for the given mint + /// + /// This is a cheap operation that only allocates memory. No network or + /// database I/O occurs until `load()` or `load_from_mint()` is called. + /// + /// # Example + /// + /// ```ignore + /// let cache = MintMetadataCache::new(mint_url, None); + /// // No data loaded yet - call load() to fetch + /// ``` + pub fn new(mint_url: MintUrl) -> Self { + Self { + mint_url, + metadata: Arc::new(ArcSwap::default()), + db_sync_versions: Arc::new(Default::default()), + fetch_lock: Arc::new(Mutex::new(())), + } + } + + /// Load metadata from mint server and update cache + /// + /// Always performs an HTTP fetch from the mint server to get fresh data. + /// Updates the in-memory cache and persists to the database. + /// + /// Uses a mutex to ensure only one fetch runs at a time. If multiple + /// callers request a fetch simultaneously, only one performs the HTTP + /// request while others wait for the lock, then return the updated cache. + /// + /// Use this when you need guaranteed fresh data from the mint. + /// + /// # Arguments + /// + /// * `storage` - Database to persist metadata to (async background write) + /// * `client` - HTTP client for fetching from mint server + /// + /// # Returns + /// + /// Fresh metadata from the mint server + /// + /// # Example + /// + /// ```ignore + /// // Force refresh from mint (ignores cache) + /// let fresh = cache.load_from_mint(&storage, &client).await?; + /// ``` + #[inline(always)] + pub async fn load_from_mint( + &self, + storage: &Arc + Send + Sync>, + client: &Arc, + ) -> Result, Error> { + // Acquire lock to ensure only one fetch at a time + let current_version = self.metadata.load().status.version; + let _guard = self.fetch_lock.lock().await; + + // Check if another caller already updated the cache while we waited + let current_metadata = self.metadata.load().clone(); + if current_metadata.status.is_populated && current_metadata.status.version > current_version + { + // Cache was just updated by another caller - return it + tracing::debug!( + "Cache was updated while waiting for fetch lock, returning cached data" + ); + return Ok(current_metadata); + } + + // Load keys from database before fetching from HTTP + // This prevents re-fetching keys we already have and avoids duplicate insertions + if let Some(keysets) = storage.get_mint_keysets(self.mint_url.clone()).await? { + let mut updated_metadata = (*self.metadata.load().clone()).clone(); + for keyset_info in keysets { + if let Some(keys) = storage.get_keys(&keyset_info.id).await? { + tracing::trace!("Loaded keys for keyset {} from database", keyset_info.id); + updated_metadata.keys.insert(keyset_info.id, Arc::new(keys)); + } + } + // Update cache with database keys before HTTP fetch + self.metadata.store(Arc::new(updated_metadata)); + } + + // Perform the fetch + #[cfg(feature = "auth")] + let metadata = self.fetch_from_http(Some(client), None).await?; + + #[cfg(not(feature = "auth"))] + let metadata = self.fetch_from_http(Some(client)).await?; + + // Persist to database + self.database_sync(storage.clone(), metadata.clone()).await; + + Ok(metadata) + } + + /// Load metadata from cache or fetch if not available + /// + /// Returns cached metadata if available and it is still valid, otherwise fetches from the mint. + /// If cache is stale relative to the database, spawns a background sync task. + /// + /// This is the primary method for normal operations - it balances freshness + /// with performance by returning cached data when available. + /// + /// # Arguments + /// + /// * `storage` - Database to persist metadata to (if fetched or stale) + /// * `client` - HTTP client for fetching from mint (only if cache empty) + /// * `ttl` - Optional TTL, if not provided it is assumed that any cached data is good enough + /// + /// # Returns + /// + /// Metadata from cache if available, otherwise fresh from mint + /// + /// # Example + /// + /// ```ignore + /// // Use cached data if available, fetch if not + /// let metadata = cache.load(&storage, &client).await?; + /// ``` + #[inline(always)] + pub async fn load( + &self, + storage: &Arc + Send + Sync>, + client: &Arc, + ttl: Option, + ) -> Result, Error> { + let cached_metadata = self.metadata.load().clone(); + let storage_id = Self::arc_pointer_id(storage); + + // Check what version of cache this database has seen + let db_synced_version = self + .db_sync_versions + .read() + .get(&storage_id) + .cloned() + .unwrap_or_default(); + + if cached_metadata.status.is_populated + && ttl + .map(|ttl| cached_metadata.status.updated_at + ttl > Instant::now()) + .unwrap_or(true) + { + // Cache is ready - check if database needs updating + if db_synced_version != cached_metadata.status.version { + // Database is stale - sync before returning + self.database_sync(storage.clone(), cached_metadata.clone()) + .await; + } + return Ok(cached_metadata); + } + + // Cache not populated - fetch from mint + self.load_from_mint(storage, client).await + } + + /// Load auth keysets and keys (auth feature only) + /// + /// Fetches blind authentication keysets from the mint. Always performs + /// an HTTP fetch to get current auth keysets. + /// + /// # Arguments + /// + /// * `storage` - Database to persist metadata to + /// * `auth_client` - Auth-capable HTTP client for fetching blind auth keysets + /// + /// # Returns + /// + /// Metadata containing auth keysets and keys + #[cfg(feature = "auth")] + pub async fn load_auth( + &self, + storage: &Arc + Send + Sync>, + auth_client: &Arc, + ) -> Result, Error> { + let cached_metadata = self.metadata.load().clone(); + let storage_id = Self::arc_pointer_id(storage); + + let db_synced_version = self + .db_sync_versions + .read() + .get(&storage_id) + .cloned() + .unwrap_or_default(); + + // Check if auth data is populated in cache + if cached_metadata.auth_status.is_populated + && cached_metadata.auth_status.updated_at > Instant::now() + { + if db_synced_version != cached_metadata.status.version { + // Database needs updating - sync before returning + self.database_sync(storage.clone(), cached_metadata.clone()) + .await; + } + return Ok(cached_metadata); + } + + // Acquire fetch lock to ensure only one auth fetch at a time + let _guard = self.fetch_lock.lock().await; + + // Re-check if auth data was updated while waiting for lock + let current_metadata = self.metadata.load().clone(); + if current_metadata.auth_status.is_populated + && current_metadata.auth_status.updated_at > Instant::now() + { + tracing::debug!( + "Auth cache was updated while waiting for fetch lock, returning cached data" + ); + return Ok(current_metadata); + } + + // Load keys from database before fetching from HTTP + // This prevents re-fetching keys we already have and avoids duplicate insertions + if let Some(keysets) = storage.get_mint_keysets(self.mint_url.clone()).await? { + let mut updated_metadata = (*self.metadata.load().clone()).clone(); + for keyset_info in keysets { + if let Some(keys) = storage.get_keys(&keyset_info.id).await? { + tracing::trace!( + "Loaded keys for keyset {} from database (auth)", + keyset_info.id + ); + updated_metadata.keys.insert(keyset_info.id, Arc::new(keys)); + } + } + // Update cache with database keys before HTTP fetch + self.metadata.store(Arc::new(updated_metadata)); + } + + // Auth data not in cache - fetch from mint + let metadata = self.fetch_from_http(None, Some(auth_client)).await?; + + // Persist to database + self.database_sync(storage.clone(), metadata.clone()).await; + + Ok(metadata) + } + + /// Sync metadata to database + /// + /// This will: + /// 1. Check if this sync is still needed (version may be superseded) + /// 2. Save mint info, keysets, and keys to the database + /// 3. Update the sync tracking to record this storage has been updated + async fn database_sync( + &self, + storage: Arc + Send + Sync>, + metadata: Arc, + ) { + let mint_url = self.mint_url.clone(); + let db_sync_versions = self.db_sync_versions.clone(); + + Self::persist_to_database(mint_url, storage, metadata, db_sync_versions).await + } + + /// Persist metadata to database + /// + /// Saves mint info, keysets, and keys to the database. Checks version + /// before writing to avoid redundant work if a newer version has already + /// been persisted. + /// + /// # Arguments + /// + /// * `mint_url` - Mint URL for database keys + /// * `storage` - Database to write to + /// * `metadata` - Metadata to persist + /// * `db_sync_versions` - Shared version tracker + async fn persist_to_database( + mint_url: MintUrl, + storage: Arc + Send + Sync>, + metadata: Arc, + db_sync_versions: Arc>>, + ) { + let storage_id = Self::arc_pointer_id(&storage); + + // Check if this write is still needed + { + let mut versions = db_sync_versions.write(); + + let current_synced_version = versions.get(&storage_id).cloned().unwrap_or_default(); + + if metadata.status.version <= current_synced_version { + // A newer version has already been persisted - skip this write + return; + } + + // Mark this version as being synced + versions.insert(storage_id, metadata.status.version); + } + + // Save mint info + storage + .add_mint(mint_url.clone(), Some(metadata.mint_info.clone())) + .await + .inspect_err(|e| tracing::warn!("Failed to save mint info for {}: {}", mint_url, e)) + .ok(); + + // Save all keysets + let keysets: Vec<_> = metadata.keysets.values().map(|ks| (**ks).clone()).collect(); + + if !keysets.is_empty() { + storage + .add_mint_keysets(mint_url.clone(), keysets) + .await + .inspect_err(|e| tracing::warn!("Failed to save keysets for {}: {}", mint_url, e)) + .ok(); + } + + // Save keys for each keyset + for (keyset_id, keys) in &metadata.keys { + if let Some(keyset_info) = metadata.keysets.get(keyset_id) { + // Check if keys already exist in database to avoid duplicate insertion + if storage.get_keys(keyset_id).await.ok().flatten().is_some() { + tracing::trace!( + "Keys for keyset {} already in database, skipping insert", + keyset_id + ); + continue; + } + + let keyset = KeySet { + id: *keyset_id, + unit: keyset_info.unit.clone(), + final_expiry: keyset_info.final_expiry, + keys: (**keys).clone(), + }; + + storage + .add_keys(keyset) + .await + .inspect_err(|e| { + tracing::warn!( + "Failed to save keys for keyset {} at {}: {}", + keyset_id, + mint_url, + e + ) + }) + .ok(); + } + } + } + + /// Fetch fresh metadata from mint HTTP API and update cache + /// + /// Performs the following steps: + /// 1. Fetches mint info from server + /// 2. Fetches list of all keysets + /// 3. Fetches cryptographic keys for each keyset + /// 4. Verifies keyset IDs match their keys + /// 5. Atomically updates in-memory cache + /// + /// # Arguments + /// + /// * `client` - Optional regular mint client (for non-auth operations) + /// * `auth_client` - Optional auth client (for blind auth keysets) + /// + /// # Returns + /// + /// Newly fetched and cached metadata + async fn fetch_from_http( + &self, + client: Option<&Arc>, + #[cfg(feature = "auth")] auth_client: Option<&Arc>, + ) -> Result, Error> { + tracing::debug!("Fetching mint metadata from HTTP for {}", self.mint_url); + + // Start with current cache to preserve data from other sources + let mut new_metadata = (*self.metadata.load().clone()).clone(); + let mut keysets_to_fetch = Vec::new(); + + // Fetch regular mint data + if let Some(client) = client.as_ref() { + // Get mint information + new_metadata.mint_info = client.get_mint_info().await.inspect_err(|err| { + tracing::error!("Failed to fetch mint info for {}: {}", self.mint_url, err); + })?; + + // Get list of keysets + keysets_to_fetch.extend( + client + .get_mint_keysets() + .await + .inspect_err(|err| { + tracing::error!("Failed to fetch keysets for {}: {}", self.mint_url, err); + })? + .keysets, + ); + } + + // Fetch auth keysets if auth client provided + #[cfg(feature = "auth")] + if let Some(auth_client) = auth_client.as_ref() { + keysets_to_fetch.extend(auth_client.get_mint_blind_auth_keysets().await?.keysets); + } + + tracing::debug!( + "Fetched {} keysets for {}", + keysets_to_fetch.len(), + self.mint_url + ); + + // Fetch keys for each keyset + for keyset_info in keysets_to_fetch { + let keyset_arc = Arc::new(keyset_info.clone()); + new_metadata + .keysets + .insert(keyset_info.id, keyset_arc.clone()); + + // Track active keysets separately for quick access + if keyset_info.active { + new_metadata.active_keysets.push(keyset_arc); + } + + // Only fetch keys if we don't already have them cached + if let std::collections::hash_map::Entry::Vacant(e) = + new_metadata.keys.entry(keyset_info.id) + { + let keyset = if let Some(client) = client.as_ref() { + client.get_mint_keyset(keyset_info.id).await? + } else { + #[cfg(feature = "auth")] + if let Some(auth_client) = auth_client.as_ref() { + auth_client + .get_mint_blind_auth_keyset(keyset_info.id) + .await? + } else { + return Err(Error::Internal); + } + + #[cfg(not(feature = "auth"))] + return Err(Error::Internal); + }; + + // Verify the keyset ID matches the keys + keyset.verify_id()?; + + e.insert(Arc::new(keyset.keys)); + } + } + + // Update freshness status based on what was fetched + if client.is_some() { + new_metadata.status.is_populated = true; + new_metadata.status.updated_at = Instant::now(); + new_metadata.status.version += 1; + } + + #[cfg(feature = "auth")] + if auth_client.is_some() { + new_metadata.auth_status.is_populated = true; + new_metadata.auth_status.updated_at = Instant::now(); + new_metadata.auth_status.version += 1; + } + + tracing::info!( + "Updated cache for {} with {} keysets (version {})", + self.mint_url, + new_metadata.keysets.len(), + new_metadata.status.version + ); + + // Atomically update cache + let metadata_arc = Arc::new(new_metadata); + self.metadata.store(metadata_arc.clone()); + Ok(metadata_arc) + } + + /// Get the mint URL this cache manages + pub fn mint_url(&self) -> &MintUrl { + &self.mint_url + } +} diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index a0f499ee1..8313a3398 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -1,17 +1,22 @@ #![doc = include_str!("./README.md")] use std::collections::HashMap; +use std::fmt::Debug; use std::str::FromStr; +use std::sync::atomic::AtomicBool; use std::sync::Arc; +use std::time::Duration; -use bitcoin::bip32::Xpriv; +use cdk_common::amount::FeeAndAmounts; use cdk_common::database::{self, WalletDatabase}; -use cdk_common::subscription::Params; +use cdk_common::parking_lot::RwLock; +use cdk_common::subscription::WalletParams; use getrandom::getrandom; use subscription::{ActiveSubscription, SubscriptionManager}; #[cfg(feature = "auth")] -use tokio::sync::RwLock; +use tokio::sync::RwLock as TokioRwLock; use tracing::instrument; +use zeroize::Zeroize; use crate::amount::SplitTarget; use crate::dhke::construct_proofs; @@ -26,22 +31,31 @@ use crate::nuts::{ }; use crate::types::ProofInfo; use crate::util::unix_time; +use crate::wallet::mint_metadata_cache::MintMetadataCache; use crate::Amount; +use cdk_common::common::UnitMetadata; #[cfg(feature = "auth")] use crate::OidcClient; #[cfg(feature = "auth")] mod auth; +#[cfg(all(feature = "tor", not(target_arch = "wasm32")))] +pub use mint_connector::TorHttpClient; mod balance; mod builder; +mod issue; mod keysets; mod melt; -mod mint; mod mint_connector; +mod mint_metadata_cache; pub mod multi_mint_wallet; +pub mod payment_request; mod proofs; mod receive; +mod reclaim; mod send; +#[cfg(not(target_arch = "wasm32"))] +mod streams; pub mod subscription; mod swap; mod transactions; @@ -52,9 +66,13 @@ pub use auth::{AuthMintConnector, AuthWallet}; pub use builder::WalletBuilder; pub use cdk_common::wallet as types; #[cfg(feature = "auth")] +pub use mint_connector::http_client::AuthHttpClient as BaseAuthHttpClient; +pub use mint_connector::http_client::HttpClient as BaseHttpClient; +pub use mint_connector::transport::Transport as HttpTransport; +#[cfg(feature = "auth")] pub use mint_connector::AuthHttpClient; -pub use mint_connector::{HttpClient, MintConnector}; -pub use multi_mint_wallet::MultiMintWallet; +pub use mint_connector::{HttpClient, LnurlPayInvoiceResponse, LnurlPayResponse, MintConnector}; +pub use multi_mint_wallet::{MultiMintReceiveOptions, MultiMintSendOptions, MultiMintWallet}; pub use receive::ReceiveOptions; pub use send::{PreparedSend, SendMemo, SendOptions}; pub use types::{MeltQuote, MintQuote, SendKind}; @@ -74,13 +92,17 @@ pub struct Wallet { pub unit: CurrencyUnit, /// Storage backend pub localstore: Arc + Send + Sync>, + /// Mint metadata cache for this mint (lock-free cached access to keys, keysets, and mint info) + pub metadata_cache: Arc, /// The targeted amount of proofs to have at each size pub target_proof_count: usize, + metadata_cache_ttl: Arc>>, #[cfg(feature = "auth")] - auth_wallet: Arc>>, - xpriv: Xpriv, + auth_wallet: Arc>>, + seed: [u8; 64], client: Arc, subscription: SubscriptionManager, + in_error_swap_reverted_proofs: Arc, } const ALPHANUMERIC: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; @@ -94,37 +116,46 @@ pub enum WalletSubscription { Bolt11MintQuoteState(Vec), /// Melt quote subscription Bolt11MeltQuoteState(Vec), + /// Mint bolt12 quote subscription + Bolt12MintQuoteState(Vec), } -impl From for Params { +impl From for WalletParams { fn from(val: WalletSubscription) -> Self { let mut buffer = vec![0u8; 10]; getrandom(&mut buffer).expect("Failed to generate random bytes"); - let id = buffer - .iter() - .map(|&byte| { - let index = byte as usize % ALPHANUMERIC.len(); // 62 alphanumeric characters (A-Z, a-z, 0-9) - ALPHANUMERIC[index] as char - }) - .collect::(); + let id = Arc::new( + buffer + .iter() + .map(|&byte| { + let index = byte as usize % ALPHANUMERIC.len(); // 62 alphanumeric characters (A-Z, a-z, 0-9) + ALPHANUMERIC[index] as char + }) + .collect::(), + ); match val { - WalletSubscription::ProofState(filters) => Params { + WalletSubscription::ProofState(filters) => WalletParams { filters, kind: Kind::ProofState, - id: id.into(), + id, }, - WalletSubscription::Bolt11MintQuoteState(filters) => Params { + WalletSubscription::Bolt11MintQuoteState(filters) => WalletParams { filters, kind: Kind::Bolt11MintQuote, - id: id.into(), + id, }, - WalletSubscription::Bolt11MeltQuoteState(filters) => Params { + WalletSubscription::Bolt11MeltQuoteState(filters) => WalletParams { filters, kind: Kind::Bolt11MeltQuote, - id: id.into(), + id, + }, + WalletSubscription::Bolt12MintQuoteState(filters) => WalletParams { + filters, + kind: Kind::Bolt12MintQuote, + id, }, } } @@ -143,7 +174,7 @@ impl Wallet { /// use rand::random; /// /// async fn test() -> anyhow::Result<()> { - /// let seed = random::<[u8; 32]>(); + /// let seed = random::<[u8; 64]>(); /// let mint_url = "https://fake.thesimplekid.dev"; /// let unit = CurrencyUnit::Sat; /// @@ -152,7 +183,7 @@ impl Wallet { /// .mint_url(mint_url.parse().unwrap()) /// .unit(unit) /// .localstore(Arc::new(localstore)) - /// .seed(&seed) + /// .seed(seed) /// .build(); /// Ok(()) /// } @@ -161,7 +192,7 @@ impl Wallet { mint_url: &str, unit: CurrencyUnit, localstore: Arc + Send + Sync>, - seed: &[u8], + seed: [u8; 64], target_proof_count: Option, ) -> Result { let mint_url = MintUrl::from_str(mint_url)?; @@ -176,10 +207,10 @@ impl Wallet { } /// Subscribe to events - pub async fn subscribe>(&self, query: T) -> ActiveSubscription { + pub async fn subscribe>(&self, query: T) -> ActiveSubscription { self.subscription - .subscribe(self.mint_url.clone(), query.into(), Arc::new(self.clone())) - .await + .subscribe(self.mint_url.clone(), query.into()) + .expect("FIXME") } /// Fee required for proof set @@ -195,12 +226,18 @@ impl Wallet { proofs_per_keyset: HashMap, ) -> Result { let mut fee_per_keyset = HashMap::new(); + let metadata = self + .metadata_cache + .load(&self.localstore, &self.client, { + let ttl = self.metadata_cache_ttl.read(); + *ttl + }) + .await?; for keyset_id in proofs_per_keyset.keys() { - let mint_keyset_info = self - .localstore - .get_keyset_by_id(keyset_id) - .await? + let mint_keyset_info = metadata + .keysets + .get(keyset_id) .ok_or(Error::UnknownKeySet)?; fee_per_keyset.insert(*keyset_id, mint_keyset_info.input_fee_ppk); } @@ -214,9 +251,14 @@ impl Wallet { #[instrument(skip_all)] pub async fn get_keyset_count_fee(&self, keyset_id: &Id, count: u64) -> Result { let input_fee_ppk = self - .localstore - .get_keyset_by_id(keyset_id) + .metadata_cache + .load(&self.localstore, &self.client, { + let ttl = self.metadata_cache_ttl.read(); + *ttl + }) .await? + .keysets + .get(keyset_id) .ok_or(Error::UnknownKeySet)? .input_fee_ppk; @@ -242,101 +284,121 @@ impl Wallet { /// Query mint for current mint information #[instrument(skip(self))] - pub async fn get_mint_info(&self) -> Result, Error> { - match self.client.get_mint_info().await { - Ok(mint_info) => { - // If mint provides time make sure it is accurate - if let Some(mint_unix_time) = mint_info.time { - let current_unix_time = unix_time(); - if current_unix_time.abs_diff(mint_unix_time) > 30 { - tracing::warn!( - "Mint time does match wallet time. Mint: {}, Wallet: {}", - mint_unix_time, - current_unix_time - ); - return Err(Error::MintTimeExceedsTolerance); - } - } + pub async fn fetch_mint_info(&self) -> Result, Error> { + let mint_info = self + .metadata_cache + .load_from_mint(&self.localstore, &self.client) + .await? + .mint_info + .clone(); + + // If mint provides time make sure it is accurate + if let Some(mint_unix_time) = mint_info.time { + let current_unix_time = unix_time(); + if current_unix_time.abs_diff(mint_unix_time) > 30 { + tracing::warn!( + "Mint time does match wallet time. Mint: {}, Wallet: {}", + mint_unix_time, + current_unix_time + ); + return Err(Error::MintTimeExceedsTolerance); + } + } - // Create or update auth wallet - #[cfg(feature = "auth")] - { - let mut auth_wallet = self.auth_wallet.write().await; - match &*auth_wallet { - Some(auth_wallet) => { - let mut protected_endpoints = - auth_wallet.protected_endpoints.write().await; - *protected_endpoints = mint_info.protected_endpoints(); - - if let Some(oidc_client) = - mint_info.openid_discovery().map(OidcClient::new) - { - auth_wallet.set_oidc_client(Some(oidc_client)).await; - } - } - None => { - tracing::info!("Mint has auth enabled creating auth wallet"); - - let oidc_client = mint_info.openid_discovery().map(OidcClient::new); - let new_auth_wallet = AuthWallet::new( - self.mint_url.clone(), - None, - self.localstore.clone(), - mint_info.protected_endpoints(), - oidc_client, - ); - *auth_wallet = Some(new_auth_wallet.clone()); - - self.client.set_auth_wallet(Some(new_auth_wallet)).await; - } + // Create or update auth wallet + #[cfg(feature = "auth")] + { + let mut auth_wallet = self.auth_wallet.write().await; + match &*auth_wallet { + Some(auth_wallet) => { + let mut protected_endpoints = auth_wallet.protected_endpoints.write().await; + *protected_endpoints = mint_info.protected_endpoints(); + + if let Some(oidc_client) = mint_info + .openid_discovery() + .map(|url| OidcClient::new(url, None)) + { + auth_wallet.set_oidc_client(Some(oidc_client)).await; } } + None => { + tracing::info!("Mint has auth enabled creating auth wallet"); + + let oidc_client = mint_info + .openid_discovery() + .map(|url| OidcClient::new(url, None)); + let new_auth_wallet = AuthWallet::new( + self.mint_url.clone(), + None, + self.localstore.clone(), + self.metadata_cache.clone(), + mint_info.protected_endpoints(), + oidc_client, + ); + *auth_wallet = Some(new_auth_wallet.clone()); + + self.client.set_auth_wallet(Some(new_auth_wallet)).await; + } + } + } - self.localstore - .add_mint(self.mint_url.clone(), Some(mint_info.clone())) - .await?; + tracing::trace!("Mint info updated for {}", self.mint_url); - tracing::trace!("Mint info updated for {}", self.mint_url); + Ok(Some(mint_info)) + } - Ok(Some(mint_info)) - } - Err(err) => { - tracing::warn!("Could not get mint info {}", err); - Ok(None) - } - } + /// Load mint info from cache + /// + /// This is a helper function that loads the mint info from the metadata cache + /// using the configured TTL. Unlike `fetch_mint_info()`, this does not make + /// a network call if the cache is fresh. + #[instrument(skip(self))] + pub async fn load_mint_info(&self) -> Result { + let mint_info = self + .metadata_cache + .load(&self.localstore, &self.client, { + let ttl = self.metadata_cache_ttl.read(); + *ttl + }) + .await? + .mint_info + .clone(); + + Ok(mint_info) } /// Get amounts needed to refill proof state #[instrument(skip(self))] - pub async fn amounts_needed_for_state_target(&self) -> Result, Error> { + pub async fn amounts_needed_for_state_target( + &self, + fee_and_amounts: &FeeAndAmounts, + ) -> Result, Error> { let unspent_proofs = self.get_unspent_proofs().await?; - let amounts_count: HashMap = + let amounts_count: HashMap = unspent_proofs .iter() .fold(HashMap::new(), |mut acc, proof| { let amount = proof.amount; - let counter = acc.entry(u64::from(amount) as usize).or_insert(0); + let counter = acc.entry(u64::from(amount)).or_insert(0); *counter += 1; acc }); - let all_possible_amounts: Vec = (0..32).map(|i| 2usize.pow(i as u32)).collect(); - - let needed_amounts = all_possible_amounts - .iter() - .fold(Vec::new(), |mut acc, amount| { - let count_needed: usize = self - .target_proof_count - .saturating_sub(*amounts_count.get(amount).unwrap_or(&0)); + let needed_amounts = + fee_and_amounts + .amounts() + .iter() + .fold(Vec::new(), |mut acc, amount| { + let count_needed = (self.target_proof_count as u64) + .saturating_sub(*amounts_count.get(amount).unwrap_or(&0)); - for _i in 0..count_needed { - acc.push(Amount::from(*amount as u64)); - } + for _i in 0..count_needed { + acc.push(Amount::from(*amount)); + } - acc - }); + acc + }); Ok(needed_amounts) } @@ -345,8 +407,11 @@ impl Wallet { async fn determine_split_target_values( &self, change_amount: Amount, + fee_and_amounts: &FeeAndAmounts, ) -> Result { - let mut amounts_needed_refill = self.amounts_needed_for_state_target().await?; + let mut amounts_needed_refill = self + .amounts_needed_for_state_target(fee_and_amounts) + .await?; amounts_needed_refill.sort(); @@ -372,22 +437,22 @@ impl Wallet { .await? .is_none() { - self.get_mint_info().await?; + self.fetch_mint_info().await?; } - let keysets = self.get_mint_keysets().await?; + let keysets = self.load_mint_keysets().await?; let mut restored_value = Amount::ZERO; for keyset in keysets { - let keys = self.get_keyset_keys(keyset.id).await?; + let keys = self.load_keyset_keys(keyset.id).await?; let mut empty_batch = 0; let mut start_counter = 0; while empty_batch.lt(&3) { let premint_secrets = PreMintSecrets::restore_batch( keyset.id, - self.xpriv, + &self.seed, start_counter, start_counter + 100, )?; @@ -629,7 +694,7 @@ impl Wallet { let mint_pubkey = match keys_cache.get(&proof.keyset_id) { Some(keys) => keys.amount_key(proof.amount), None => { - let keys = self.get_keyset_keys(proof.keyset_id).await?; + let keys = self.load_keyset_keys(proof.keyset_id).await?; let key = keys.amount_key(proof.amount); keys_cache.insert(proof.keyset_id, keys); @@ -646,4 +711,32 @@ 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) { + 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; + } + + /// Get unit metadata from the mint + /// + /// Fetches the unit metadata for this wallet's currency unit from the mint's HTTP endpoint. + #[instrument(skip(self))] + pub async fn get_unit_metadata(&self) -> Result { + self.client.get_unit_metadata(self.unit.clone()).await + } +} + +impl Drop for Wallet { + fn drop(&mut self) { + self.seed.zeroize(); + } } diff --git a/crates/cdk/src/wallet/multi_mint_wallet.rs b/crates/cdk/src/wallet/multi_mint_wallet.rs index bea3ab3bd..05c8d261f 100644 --- a/crates/cdk/src/wallet/multi_mint_wallet.rs +++ b/crates/cdk/src/wallet/multi_mint_wallet.rs @@ -3,94 +3,510 @@ //! Wrapper around core [`Wallet`] that enables the use of multiple mint unit //! pairs -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; +use std::ops::Deref; use std::str::FromStr; use std::sync::Arc; use anyhow::Result; -use cdk_common::database; use cdk_common::database::WalletDatabase; -use cdk_common::wallet::{Transaction, TransactionDirection, WalletKey}; +use cdk_common::task::spawn; +use cdk_common::wallet::{MeltQuote, Transaction, TransactionDirection, TransactionId}; +use cdk_common::{database, KeySetInfo}; use tokio::sync::RwLock; use tracing::instrument; +use zeroize::Zeroize; +use super::builder::WalletBuilder; use super::receive::ReceiveOptions; -use super::send::{PreparedSend, SendMemo, SendOptions}; +use super::send::{PreparedSend, SendOptions}; use super::Error; use crate::amount::SplitTarget; use crate::mint_url::MintUrl; +use crate::nuts::nut00::ProofsMethods; +use crate::nuts::nut23::QuoteState; use crate::nuts::{CurrencyUnit, MeltOptions, Proof, Proofs, SpendingConditions, Token}; use crate::types::Melted; +#[cfg(all(feature = "tor", not(target_arch = "wasm32")))] +use crate::wallet::mint_connector::transport::tor_transport::TorAsync; use crate::wallet::types::MintQuote; -use crate::{ensure_cdk, Amount, Wallet}; +use crate::{Amount, Wallet}; +use cdk_common::common::UnitMetadata; -/// Multi Mint Wallet +// Transfer timeout constants +/// Total timeout for waiting for Lightning payment confirmation during transfers +/// This needs to be long enough to handle slow networks and Lightning routing +const TRANSFER_PAYMENT_TIMEOUT_SECS: u64 = 120; // 2 minutes + +/// Transfer mode for mint-to-mint transfers +#[derive(Debug, Clone)] +pub enum TransferMode { + /// Transfer exact amount to target (target receives specified amount) + ExactReceive(Amount), + /// Transfer all available balance (source will be emptied) + FullBalance, +} + +/// Result of a transfer operation with detailed breakdown +#[derive(Debug, Clone)] +pub struct TransferResult { + /// Amount deducted from source mint + pub amount_sent: Amount, + /// Amount received at target mint + pub amount_received: Amount, + /// Total fees paid for the transfer + pub fees_paid: Amount, + /// Remaining balance in source mint after transfer + pub source_balance_after: Amount, + /// New balance in target mint after transfer + pub target_balance_after: Amount, +} + +/// Data extracted from a token including mint URL, proofs, and memo #[derive(Debug, Clone)] +pub struct TokenData { + /// The mint URL from the token + pub mint_url: MintUrl, + /// The proofs contained in the token + pub proofs: Proofs, + /// The memo from the token, if present + pub memo: Option, +} + +/// Configuration for individual wallets within MultiMintWallet +#[derive(Clone, Default, Debug)] +pub struct WalletConfig { + /// Custom mint connector implementation + pub mint_connector: Option>, + /// Custom auth connector implementation + #[cfg(feature = "auth")] + pub auth_connector: Option>, + /// Target number of proofs to maintain at each denomination + pub target_proof_count: Option, +} + +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, + ) -> Self { + self.mint_connector = Some(connector); + self + } + + /// Set custom auth connector + #[cfg(feature = "auth")] + pub fn with_auth_connector( + mut self, + connector: Arc, + ) -> 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. +/// This simplifies the interface by removing the need to specify both mint and unit. +/// +/// # Examples +/// +/// ## Creating and using a multi-mint wallet +/// ```ignore +/// # use cdk::wallet::MultiMintWallet; +/// # use cdk::mint_url::MintUrl; +/// # use cdk::Amount; +/// # use cdk::nuts::CurrencyUnit; +/// # use std::sync::Arc; +/// # async fn example() -> Result<(), Box> { +/// // Create a multi-mint wallet with a database +/// // For real usage, you would use cdk_sqlite::wallet::memory::empty().await? or similar +/// let seed = [0u8; 64]; // Use a secure random seed in production +/// let database = cdk_sqlite::wallet::memory::empty().await?; +/// +/// let wallet = MultiMintWallet::new( +/// Arc::new(database), +/// seed, +/// CurrencyUnit::Sat, +/// ).await?; +/// +/// // 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()).await?; +/// wallet.add_mint(mint_url2).await?; +/// +/// // Check total balance across all mints +/// let balance = wallet.total_balance().await?; +/// println!("Total balance: {} sats", balance); +/// +/// // Send tokens from a specific mint +/// let prepared = wallet.prepare_send( +/// mint_url1, +/// Amount::from(100), +/// Default::default() +/// ).await?; +/// let token = prepared.confirm(None).await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] pub struct MultiMintWallet { /// Storage backend - pub localstore: Arc + Send + Sync>, - seed: Arc<[u8]>, - /// Wallets - pub wallets: Arc>>, + localstore: Arc + Send + Sync>, + seed: [u8; 64], + /// The currency unit this wallet supports + unit: CurrencyUnit, + /// Wallets indexed by mint URL + wallets: Arc>>, + /// Proxy configuration for HTTP clients (optional) + proxy_config: Option, + /// Shared Tor transport to be cloned into each TorHttpClient (if enabled) + #[cfg(all(feature = "tor", not(target_arch = "wasm32")))] + shared_tor_transport: Option, } impl MultiMintWallet { - /// Create a new [MultiMintWallet] with initial wallets - pub fn new( + /// Create a new [MultiMintWallet] for a specific currency unit + pub async fn new( localstore: Arc + Send + Sync>, - seed: Arc<[u8]>, - wallets: Vec, - ) -> Self { - Self { + seed: [u8; 64], + unit: CurrencyUnit, + ) -> Result { + let wallet = Self { localstore, seed, - wallets: Arc::new(RwLock::new( - wallets - .into_iter() - .map(|w| (WalletKey::new(w.mint_url.clone(), w.unit.clone()), w)) - .collect(), - )), - } + unit, + wallets: Arc::new(RwLock::new(BTreeMap::new())), + proxy_config: None, + #[cfg(all(feature = "tor", not(target_arch = "wasm32")))] + shared_tor_transport: None, + }; + + // Automatically load wallets from database for this currency unit + wallet.load_wallets().await?; + + Ok(wallet) } - /// Adds a [Wallet] to this [MultiMintWallet] - #[instrument(skip(self, wallet))] - pub async fn add_wallet(&self, wallet: Wallet) { - let wallet_key = WalletKey::new(wallet.mint_url.clone(), wallet.unit.clone()); + /// Create a new [MultiMintWallet] with proxy configuration + /// + /// All wallets in this MultiMintWallet will use the specified proxy. + /// This allows you to route all mint connections through a proxy server. + pub async fn new_with_proxy( + localstore: Arc + Send + Sync>, + seed: [u8; 64], + unit: CurrencyUnit, + proxy_url: url::Url, + ) -> Result { + let wallet = Self { + localstore, + seed, + unit, + wallets: Arc::new(RwLock::new(BTreeMap::new())), + proxy_config: Some(proxy_url), + #[cfg(all(feature = "tor", not(target_arch = "wasm32")))] + shared_tor_transport: None, + }; - let mut wallets = self.wallets.write().await; + // Automatically load wallets from database for this currency unit + wallet.load_wallets().await?; - wallets.insert(wallet_key, wallet); + Ok(wallet) } - /// Creates a new [Wallet] and adds it to this [MultiMintWallet] - pub async fn create_and_add_wallet( - &self, - mint_url: &str, + /// Create a new [MultiMintWallet] with Tor transport for all wallets + /// + /// When the `tor` feature is enabled (and not on wasm32), this constructor + /// creates a single Tor transport (TorAsync) that is cloned into each + /// TorHttpClient used by per-mint Wallets. This ensures only one Tor instance + /// is bootstrapped and shared across wallets. + #[cfg(all(feature = "tor", not(target_arch = "wasm32")))] + pub async fn new_with_tor( + localstore: Arc + Send + Sync>, + seed: [u8; 64], unit: CurrencyUnit, - target_proof_count: Option, - ) -> Result { - let wallet = Wallet::new( - mint_url, + ) -> Result { + let wallet = Self { + localstore, + seed, unit, - self.localstore.clone(), - self.seed.as_ref(), - target_proof_count, - )?; - - wallet.get_mint_info().await?; + wallets: Arc::new(RwLock::new(BTreeMap::new())), + proxy_config: None, + shared_tor_transport: Some(TorAsync::new()), + }; - self.add_wallet(wallet.clone()).await; + // Automatically load wallets from database for this currency unit + wallet.load_wallets().await?; Ok(wallet) } - /// Remove Wallet from 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(&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, + 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, + ) -> 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_wallet(&self, wallet_key: &WalletKey) { + 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 { + // 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( + mint_url.clone(), + proxy_url.clone(), + None, + true, + ) + .unwrap_or_else(|_| { + #[cfg(feature = "auth")] + { + crate::wallet::HttpClient::new(mint_url.clone(), None) + } + #[cfg(not(feature = "auth"))] + { + crate::wallet::HttpClient::new(mint_url.clone()) + } + }); + WalletBuilder::new() + .mint_url(mint_url.clone()) + .unit(self.unit.clone()) + .localstore(self.localstore.clone()) + .seed(self.seed) + .target_proof_count(target_proof_count) + .client(client) + .build()? + } else { + #[cfg(all(feature = "tor", not(target_arch = "wasm32")))] + if let Some(tor) = &self.shared_tor_transport { + // Create wallet with Tor transport client, cloning the shared transport + let client = { + let transport = tor.clone(); + #[cfg(feature = "auth")] + { + crate::wallet::TorHttpClient::with_transport( + mint_url.clone(), + transport, + None, + ) + } + #[cfg(not(feature = "auth"))] + { + crate::wallet::TorHttpClient::with_transport(mint_url.clone(), transport) + } + }; + + WalletBuilder::new() + .mint_url(mint_url.clone()) + .unit(self.unit.clone()) + .localstore(self.localstore.clone()) + .seed(self.seed) + .target_proof_count(target_proof_count) + .client(client) + .build()? + } else { + // Create wallet with default client + Wallet::new( + &mint_url.to_string(), + self.unit.clone(), + self.localstore.clone(), + self.seed, + Some(target_proof_count), + )? + } + + #[cfg(not(all(feature = "tor", not(target_arch = "wasm32"))))] + { + // Create wallet with default client + Wallet::new( + &mint_url.to_string(), + self.unit.clone(), + self.localstore.clone(), + self.seed, + Some(target_proof_count), + )? + } + }; + + Ok(wallet) + } + + /// Load all wallets from database that have proofs for this currency unit + #[instrument(skip(self))] + async fn load_wallets(&self) -> Result<(), Error> { + let mints = self.localstore.get_mints().await.map_err(Error::Database)?; + + // Get all proofs for this currency unit to determine which mints are relevant + let all_proofs = self + .localstore + .get_proofs(None, Some(self.unit.clone()), None, None) + .await + .map_err(Error::Database)?; + + for (mint_url, _mint_info) in mints { + // Check if this mint has any proofs for the specified currency unit + // or if we have no proofs at all (initial setup) + let mint_has_proofs_for_unit = + all_proofs.is_empty() || all_proofs.iter().any(|proof| proof.mint_url == mint_url); + + 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()).await? + } + } + } - wallets.remove(wallet_key); + Ok(()) } /// Get Wallets from MultiMintWallet @@ -101,29 +517,89 @@ impl MultiMintWallet { /// Get Wallet from MultiMintWallet #[instrument(skip(self))] - pub async fn get_wallet(&self, wallet_key: &WalletKey) -> Option { - self.wallets.read().await.get(wallet_key).cloned() + pub async fn get_wallet(&self, mint_url: &MintUrl) -> Option { + self.wallets.read().await.get(mint_url).cloned() } - /// Check if mint unit pair is in wallet + /// Check if mint is in wallet #[instrument(skip(self))] - pub async fn has(&self, wallet_key: &WalletKey) -> bool { - self.wallets.read().await.contains_key(wallet_key) + pub async fn has_mint(&self, mint_url: &MintUrl) -> bool { + self.wallets.read().await.contains_key(mint_url) + } + + /// Get the currency unit for this wallet + pub fn unit(&self) -> &CurrencyUnit { + &self.unit } - /// Get wallet balances + /// Get keysets for a mint url + pub async fn get_mint_keysets(&self, mint_url: &MintUrl) -> Result, Error> { + let wallets = self.wallets.read().await; + let target_wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + target_wallet.get_mint_keysets().await + } + + /// Get token data (mint URL and proofs) from a token + /// + /// This method extracts the mint URL and proofs from a token. It will automatically + /// fetch the keysets from the mint if needed to properly decode the proofs. + /// + /// The mint must already be added to the wallet. If the mint is not in the wallet, + /// use `add_mint` first or set `allow_untrusted` in receive options. + /// + /// # Arguments + /// + /// * `token` - The token to extract data from + /// + /// # Returns + /// + /// A `TokenData` struct containing the mint URL and proofs + /// + /// # Example + /// + /// ```no_run + /// # use cdk::wallet::MultiMintWallet; + /// # use cdk::nuts::Token; + /// # use std::str::FromStr; + /// # async fn example(wallet: &MultiMintWallet) -> Result<(), Box> { + /// let token = Token::from_str("cashuA...")?; + /// let token_data = wallet.get_token_data(&token).await?; + /// println!("Mint: {}", token_data.mint_url); + /// println!("Proofs: {} total", token_data.proofs.len()); + /// # Ok(()) + /// # } + /// ``` + #[instrument(skip(self, token))] + pub async fn get_token_data(&self, token: &Token) -> Result { + let mint_url = token.mint_url()?; + + // Get the keysets for this mint + let keysets = self.get_mint_keysets(&mint_url).await?; + + // Extract proofs using the keysets + let proofs = token.proofs(&keysets)?; + + // Get the memo + let memo = token.memo().clone(); + + Ok(TokenData { + mint_url, + proofs, + memo, + }) + } + + /// Get wallet balances for all mints #[instrument(skip(self))] - pub async fn get_balances( - &self, - unit: &CurrencyUnit, - ) -> Result, Error> { + pub async fn get_balances(&self) -> Result, Error> { let mut balances = BTreeMap::new(); - for (WalletKey { mint_url, unit: u }, wallet) in self.wallets.read().await.iter() { - if unit == u { - let wallet_balance = wallet.total_balance().await?; - balances.insert(mint_url.clone(), wallet_balance); - } + for (mint_url, wallet) in self.wallets.read().await.iter() { + let wallet_balance = wallet.total_balance().await?; + balances.insert(mint_url.clone(), (wallet_balance, wallet.unit.clone())); } Ok(balances) @@ -131,14 +607,12 @@ impl MultiMintWallet { /// List proofs. #[instrument(skip(self))] - pub async fn list_proofs( - &self, - ) -> Result, CurrencyUnit)>, Error> { + pub async fn list_proofs(&self) -> Result>, Error> { let mut mint_proofs = BTreeMap::new(); - for (WalletKey { mint_url, unit: u }, wallet) in self.wallets.read().await.iter() { + for (mint_url, wallet) in self.wallets.read().await.iter() { let wallet_proofs = wallet.get_unspent_proofs().await?; - mint_proofs.insert(mint_url.clone(), (wallet_proofs, u.clone())); + mint_proofs.insert(mint_url.clone(), wallet_proofs); } Ok(mint_proofs) } @@ -161,129 +635,633 @@ impl MultiMintWallet { Ok(transactions) } - /// Prepare to send + /// Get proofs for a transaction by transaction ID + /// + /// This retrieves all proofs associated with a transaction. If `mint_url` is provided, + /// it will only check that specific mint's wallet. Otherwise, it searches across all + /// wallets to find which mint the transaction belongs to. + /// + /// # Arguments + /// + /// * `id` - The transaction ID + /// * `mint_url` - Optional mint URL to check directly, avoiding iteration over all wallets + #[instrument(skip(self))] + pub async fn get_proofs_for_transaction( + &self, + id: TransactionId, + mint_url: Option, + ) -> Result { + let wallets = self.wallets.read().await; + + // If mint_url is provided, try that wallet directly + if let Some(mint_url) = mint_url { + if let Some(wallet) = wallets.get(&mint_url) { + // Verify the transaction exists in this wallet + if wallet.get_transaction(id).await?.is_some() { + return wallet.get_proofs_for_transaction(id).await; + } + } + // Transaction not found in specified mint + return Err(Error::TransactionNotFound); + } + + // No mint_url provided, search across all wallets + for (mint_url, wallet) in wallets.iter() { + if let Some(transaction) = wallet.get_transaction(id).await? { + // Verify the transaction belongs to this wallet's mint + if &transaction.mint_url == mint_url { + return wallet.get_proofs_for_transaction(id).await; + } + } + } + + // Transaction not found in any wallet + Err(Error::TransactionNotFound) + } + + /// Get total balance across all wallets (since all wallets use the same currency unit) + #[instrument(skip(self))] + pub async fn total_balance(&self) -> Result { + let mut total = Amount::ZERO; + for (_, wallet) in self.wallets.read().await.iter() { + total += wallet.total_balance().await?; + } + Ok(total) + } + + /// Prepare to send tokens from a specific mint with optional transfer from other mints + /// + /// This method ensures that sends always happen from only one mint. If the specified + /// mint doesn't have sufficient balance and `allow_transfer` is enabled in options, + /// it will first transfer funds from other mints to the target mint. #[instrument(skip(self))] pub async fn prepare_send( &self, - wallet_key: &WalletKey, + mint_url: MintUrl, amount: Amount, - opts: SendOptions, + opts: MultiMintSendOptions, ) -> Result { + // Ensure the mint exists + let wallets = self.wallets.read().await; + let target_wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + // Check current balance of target mint + let target_balance = target_wallet.total_balance().await?; + + // If target mint has sufficient balance, prepare send directly + if target_balance >= amount { + return target_wallet.prepare_send(amount, opts.send_options).await; + } + + // If transfer is not allowed, return insufficient funds error + if !opts.allow_transfer { + return Err(Error::InsufficientFunds); + } + + // Calculate how much we need to transfer + let transfer_needed = amount - target_balance; + + // Check if transfer amount exceeds max_transfer_amount + if let Some(max_transfer) = opts.max_transfer_amount { + if transfer_needed > max_transfer { + return Err(Error::InsufficientFunds); + } + } + + // Find source wallets with available funds for transfer + let mut available_for_transfer = Amount::ZERO; + let mut source_mints = Vec::new(); + + for (source_mint_url, wallet) in wallets.iter() { + if source_mint_url == &mint_url { + continue; // Skip the target mint + } + + // Check if this mint is excluded from transfers + if opts.excluded_mints.contains(source_mint_url) { + continue; + } + + // Check if we have a restricted allowed list and this mint isn't in it + if !opts.allowed_mints.is_empty() && !opts.allowed_mints.contains(source_mint_url) { + continue; + } + + let balance = wallet.total_balance().await?; + if balance > Amount::ZERO { + source_mints.push((source_mint_url.clone(), balance)); + available_for_transfer += balance; + } + } + + // Check if we have enough funds across all mints + if available_for_transfer < transfer_needed { + return Err(Error::InsufficientFunds); + } + + // Drop the read lock before performing transfers + drop(wallets); + + // Perform transfers from source wallets to target wallet + self.transfer_parallel(&mint_url, transfer_needed, source_mints) + .await?; + + // Now prepare the send from the target mint let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let target_wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; - wallet.prepare_send(amount, opts).await + target_wallet.prepare_send(amount, opts.send_options).await } - /// Create cashu token - #[instrument(skip(self))] - pub async fn send( + /// Transfer funds from a single source wallet to target mint using Lightning Network (melt/mint) + /// + /// This function properly accounts for fees by handling different transfer modes: + /// - ExactReceive: Target receives exactly the specified amount, source pays amount + fees + /// - FullBalance: All source balance is transferred, target receives balance - fees + pub async fn transfer( &self, - wallet_key: &WalletKey, - send: PreparedSend, - memo: Option, - ) -> Result { - let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + source_mint_url: &MintUrl, + target_mint_url: &MintUrl, + mode: TransferMode, + ) -> Result { + // Get wallets for the specified mints and clone them to release the lock + let (source_wallet, target_wallet) = { + let wallets = self.wallets.read().await; + let source = wallets + .get(source_mint_url) + .ok_or(Error::UnknownMint { + mint_url: source_mint_url.to_string(), + })? + .clone(); + let target = wallets + .get(target_mint_url) + .ok_or(Error::UnknownMint { + mint_url: target_mint_url.to_string(), + })? + .clone(); + (source, target) + }; + + // Get initial balance + let source_balance_initial = source_wallet.total_balance().await?; + + // Handle different transfer modes + let (final_mint_quote, final_melt_quote) = match mode { + TransferMode::ExactReceive(amount) => { + self.handle_exact_receive_transfer( + &source_wallet, + &target_wallet, + amount, + source_balance_initial, + ) + .await? + } + TransferMode::FullBalance => { + self.handle_full_balance_transfer( + &source_wallet, + &target_wallet, + source_balance_initial, + ) + .await? + } + }; + + // Execute the transfer + let (melted, actual_receive_amount) = self + .execute_transfer( + &source_wallet, + &target_wallet, + &final_mint_quote, + &final_melt_quote, + ) + .await?; + + // Get final balances + let source_balance_final = source_wallet.total_balance().await?; + let target_balance_final = target_wallet.total_balance().await?; + + let amount_sent = source_balance_initial - source_balance_final; + let fees_paid = melted.fee_paid; + + tracing::info!( + "Transferred {} from {} to {} via Lightning (sent: {} sats, received: {} sats, fee: {} sats)", + amount_sent, + source_wallet.mint_url, + target_wallet.mint_url, + amount_sent, + actual_receive_amount, + fees_paid + ); + + Ok(TransferResult { + amount_sent, + amount_received: actual_receive_amount, + fees_paid, + source_balance_after: source_balance_final, + target_balance_after: target_balance_final, + }) + } + + /// Handle exact receive transfer mode - target gets exactly the specified amount + async fn handle_exact_receive_transfer( + &self, + source_wallet: &Wallet, + target_wallet: &Wallet, + amount: Amount, + source_balance: Amount, + ) -> Result<(MintQuote, crate::wallet::types::MeltQuote), Error> { + // Step 1: Create mint quote at target mint for the exact amount we want to receive + let mint_quote = target_wallet.mint_quote(amount, None).await?; + + // Step 2: Create melt quote at source mint for the invoice + let melt_quote = source_wallet + .melt_quote(mint_quote.request.clone(), None) + .await?; + + // Step 3: Check if source has enough balance for the total amount needed (amount + melt fees) + let total_needed = melt_quote.amount + melt_quote.fee_reserve; + if source_balance < total_needed { + return Err(Error::InsufficientFunds); + } - wallet.send(send, memo).await + Ok((mint_quote, melt_quote)) + } + + /// Handle full balance transfer mode - all source balance is transferred + async fn handle_full_balance_transfer( + &self, + source_wallet: &Wallet, + target_wallet: &Wallet, + source_balance: Amount, + ) -> Result<(MintQuote, crate::wallet::types::MeltQuote), Error> { + if source_balance == Amount::ZERO { + return Err(Error::InsufficientFunds); + } + + // Step 1: Create melt quote for full balance to discover fees + // We need to create a dummy mint quote first to get an invoice + let dummy_mint_quote = target_wallet.mint_quote(source_balance, None).await?; + let probe_melt_quote = source_wallet + .melt_quote(dummy_mint_quote.request.clone(), None) + .await?; + + // Step 2: Calculate actual receive amount (balance - fees) + let receive_amount = source_balance + .checked_sub(probe_melt_quote.fee_reserve) + .ok_or(Error::InsufficientFunds)?; + + if receive_amount == Amount::ZERO { + return Err(Error::InsufficientFunds); + } + + // Step 3: Create final mint quote for the net amount + let final_mint_quote = target_wallet.mint_quote(receive_amount, None).await?; + + // Step 4: Create final melt quote with the new invoice + let final_melt_quote = source_wallet + .melt_quote(final_mint_quote.request.clone(), None) + .await?; + + Ok((final_mint_quote, final_melt_quote)) + } + + /// Execute the actual transfer using the prepared quotes + async fn execute_transfer( + &self, + source_wallet: &Wallet, + target_wallet: &Wallet, + final_mint_quote: &MintQuote, + final_melt_quote: &crate::wallet::types::MeltQuote, + ) -> Result<(Melted, Amount), Error> { + // Step 1: Subscribe to mint quote updates before melting + let mut subscription = target_wallet + .subscribe(super::WalletSubscription::Bolt11MintQuoteState(vec![ + final_mint_quote.id.clone(), + ])) + .await; + + // Step 2: Melt from source wallet using the final melt quote + let melted = source_wallet.melt(&final_melt_quote.id).await?; + + // Step 3: Wait for payment confirmation via subscription + tracing::debug!( + "Waiting for Lightning payment confirmation (max {} seconds) for transfer from {} to {}", + TRANSFER_PAYMENT_TIMEOUT_SECS, + source_wallet.mint_url, + target_wallet.mint_url + ); + + // Wait for payment notification with overall timeout + let timeout_duration = tokio::time::Duration::from_secs(TRANSFER_PAYMENT_TIMEOUT_SECS); + + loop { + match tokio::time::timeout(timeout_duration, subscription.recv()).await { + Ok(Some(notification)) => { + // Check if this is a mint quote response with paid state + if let crate::nuts::nut17::NotificationPayload::MintQuoteBolt11Response( + quote_response, + ) = notification.deref() + { + if quote_response.state == QuoteState::Paid { + // Quote is paid, now mint the tokens + target_wallet + .mint( + &final_mint_quote.id, + crate::amount::SplitTarget::default(), + None, + ) + .await?; + break; + } + } + } + Ok(None) => { + // Subscription closed + tracing::warn!("Subscription closed while waiting for mint quote payment"); + return Err(Error::TransferTimeout { + source_mint: source_wallet.mint_url.to_string(), + target_mint: target_wallet.mint_url.to_string(), + amount: final_mint_quote.amount.unwrap_or(Amount::ZERO), + }); + } + Err(_) => { + // Overall timeout reached + tracing::warn!( + "Transfer timed out after {} seconds waiting for Lightning payment confirmation", + TRANSFER_PAYMENT_TIMEOUT_SECS + ); + return Err(Error::TransferTimeout { + source_mint: source_wallet.mint_url.to_string(), + target_mint: target_wallet.mint_url.to_string(), + amount: final_mint_quote.amount.unwrap_or(Amount::ZERO), + }); + } + } + } + + let actual_receive_amount = final_mint_quote.amount.unwrap_or(Amount::ZERO); + Ok((melted, actual_receive_amount)) + } + + /// Transfer funds from multiple source wallets to target mint in parallel + async fn transfer_parallel( + &self, + target_mint_url: &MintUrl, + total_amount: Amount, + source_mints: Vec<(MintUrl, Amount)>, + ) -> Result<(), Error> { + let mut remaining_amount = total_amount; + let mut transfer_tasks = Vec::new(); + + // Create transfer tasks for each source wallet + for (source_mint_url, available_balance) in source_mints { + if remaining_amount == Amount::ZERO { + break; + } + + let transfer_amount = std::cmp::min(remaining_amount, available_balance); + remaining_amount -= transfer_amount; + + let self_clone = self.clone(); + let source_mint_url = source_mint_url.clone(); + let target_mint_url = target_mint_url.clone(); + + // Spawn parallel transfer task + let task = spawn(async move { + self_clone + .transfer( + &source_mint_url, + &target_mint_url, + TransferMode::ExactReceive(transfer_amount), + ) + .await + .map(|result| result.amount_received) + }); + + transfer_tasks.push(task); + } + + // Wait for all transfers to complete + let mut total_transferred = Amount::ZERO; + for task in transfer_tasks { + match task.await { + Ok(Ok(amount)) => { + total_transferred += amount; + } + Ok(Err(e)) => { + tracing::error!("Transfer failed: {}", e); + return Err(e); + } + Err(e) => { + tracing::error!("Transfer task panicked: {}", e); + return Err(Error::Internal); + } + } + } + + // Check if we transferred less than expected (accounting for fees) + // We don't return an error here as fees are expected + if total_transferred < total_amount { + let fee_paid = total_amount - total_transferred; + tracing::info!( + "Transfer completed with fees: requested {}, received {}, total fees {}", + total_amount, + total_transferred, + fee_paid + ); + } + + Ok(()) } /// Mint quote for wallet #[instrument(skip(self))] pub async fn mint_quote( &self, - wallet_key: &WalletKey, + mint_url: &MintUrl, amount: Amount, description: Option, ) -> Result { let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet.mint_quote(amount, description).await } + /// Check a specific mint quote status + #[instrument(skip(self))] + pub async fn check_mint_quote( + &self, + mint_url: &MintUrl, + quote_id: &str, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + // Check the quote state from the mint + wallet.mint_quote_state(quote_id).await?; + + // Get the updated quote from local storage + let quote = wallet + .localstore + .get_mint_quote(quote_id) + .await + .map_err(Error::Database)? + .ok_or(Error::UnknownQuote)?; + + Ok(quote) + } + /// Check all mint quotes /// If quote is paid, wallet will mint #[instrument(skip(self))] - pub async fn check_all_mint_quotes( - &self, - wallet_key: Option, - ) -> Result, Error> { - let mut amount_minted = HashMap::new(); - match wallet_key { - Some(wallet_key) => { + pub async fn check_all_mint_quotes(&self, mint_url: Option) -> Result { + let mut total_amount = Amount::ZERO; + match mint_url { + Some(mint_url) => { let wallets = self.wallets.read().await; - let wallet = wallets - .get(&wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; - let amount = wallet.check_all_mint_quotes().await?; - amount_minted.insert(wallet.unit.clone(), amount); + total_amount = wallet.check_all_mint_quotes().await?; } None => { for (_, wallet) in self.wallets.read().await.iter() { let amount = wallet.check_all_mint_quotes().await?; - - amount_minted - .entry(wallet.unit.clone()) - .and_modify(|b| *b += amount) - .or_insert(amount); + total_amount += amount; } } } - Ok(amount_minted) + Ok(total_amount) } /// Mint a specific quote #[instrument(skip(self))] pub async fn mint( &self, - wallet_key: &WalletKey, + mint_url: &MintUrl, quote_id: &str, conditions: Option, ) -> Result { let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet .mint(quote_id, SplitTarget::default(), conditions) .await } - /// Receive token - /// Wallet must be already added to multimintwallet - #[instrument(skip_all)] - pub async fn receive( + /// Wait for a mint quote to be paid and automatically mint the proofs + #[cfg(not(target_arch = "wasm32"))] + #[instrument(skip(self))] + pub async fn wait_for_mint_quote( &self, - encoded_token: &str, - opts: ReceiveOptions, - ) -> Result { - let token_data = Token::from_str(encoded_token)?; + mint_url: &MintUrl, + quote_id: &str, + split_target: SplitTarget, + conditions: Option, + timeout_secs: u64, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + // Get the mint quote from local storage + let quote = wallet + .localstore + .get_mint_quote(quote_id) + .await + .map_err(Error::Database)? + .ok_or(Error::UnknownQuote)?; + + // Wait for the quote to be paid and mint the proofs + let timeout_duration = tokio::time::Duration::from_secs(timeout_secs); + wallet + .wait_and_mint_quote(quote, split_target, conditions, timeout_duration) + .await + } + + /// Receive token with multi-mint options + /// + /// This method can: + /// - Receive tokens from trusted mints (already added to the wallet) + /// - Optionally receive from untrusted mints by adding them to the wallet + /// - Optionally transfer tokens from untrusted mints to a trusted mint (and remove the untrusted mint) + /// + /// # Examples + /// ```no_run + /// # use cdk::wallet::{MultiMintWallet, MultiMintReceiveOptions}; + /// # use cdk::mint_url::MintUrl; + /// # async fn example(wallet: MultiMintWallet) -> Result<(), Box> { + /// // Receive from a trusted mint + /// let token = "cashuAey..."; + /// let amount = wallet + /// .receive(token, MultiMintReceiveOptions::default()) + /// .await?; + /// + /// // Receive from untrusted mint and add it to the wallet + /// let options = MultiMintReceiveOptions::default().allow_untrusted(true); + /// let amount = wallet.receive(token, options).await?; + /// + /// // Receive from untrusted mint, transfer to trusted mint, then remove untrusted mint + /// let trusted_mint: MintUrl = "https://trusted.mint".parse()?; + /// let options = MultiMintReceiveOptions::default().transfer_to_mint(Some(trusted_mint)); + /// let amount = wallet.receive(token, options).await?; + /// # Ok(()) + /// # } + /// ``` + #[instrument(skip_all)] + pub async fn receive( + &self, + encoded_token: &str, + opts: MultiMintReceiveOptions, + ) -> Result { + let token_data = Token::from_str(encoded_token)?; let unit = token_data.unit().unwrap_or_default(); + // Ensure the token uses the same currency unit as this wallet + if unit != self.unit { + return Err(Error::MultiMintCurrencyUnitMismatch { + expected: self.unit.clone(), + found: unit, + }); + } + let mint_url = token_data.mint_url()?; + let is_trusted = self.has_mint(&mint_url).await; - // Check that all mints in tokes have wallets - let wallet_key = WalletKey::new(mint_url.clone(), unit.clone()); - if !self.has(&wallet_key).await { - return Err(Error::UnknownWallet(wallet_key.clone())); + // If mint is not trusted and we don't allow untrusted mints, error + if !is_trusted && !opts.allow_untrusted { + return Err(Error::UnknownMint { + mint_url: mint_url.to_string(), + }); + } + + // If mint is untrusted and we need to transfer, ensure we have a target mint + let should_transfer = !is_trusted && opts.transfer_to_mint.is_some(); + + // Add the untrusted mint temporarily if needed + if !is_trusted { + self.add_mint(mint_url.clone()).await?; } - let wallet_key = WalletKey::new(mint_url.clone(), unit); let wallets = self.wallets.read().await; - let wallet = wallets - .get(&wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; // We need the keysets information to properly convert from token proof to proof let keysets_info = match self @@ -293,62 +1271,87 @@ impl MultiMintWallet { { Some(keysets_info) => keysets_info, // Hit the keysets endpoint if we don't have the keysets for this Mint - None => wallet.get_mint_keysets().await?, + None => wallet.load_mint_keysets().await?, }; let proofs = token_data.proofs(&keysets_info)?; let mut amount_received = Amount::ZERO; - let mut mint_errors = None; - match wallet - .receive_proofs(proofs, opts, token_data.memo().clone()) + .receive_proofs(proofs, opts.receive_options, token_data.memo().clone()) .await { Ok(amount) => { amount_received += amount; } Err(err) => { - tracing::error!("Could no receive proofs for mint: {}", err); - mint_errors = Some(err); + // If we added the mint temporarily for transfer only, remove it before returning error + if !is_trusted && opts.transfer_to_mint.is_some() { + drop(wallets); + self.remove_mint(&mint_url).await; + } + return Err(err); } } - match mint_errors { - None => Ok(amount_received), - Some(err) => Err(err), - } - } + drop(wallets); - /// Pay an bolt11 invoice from specific wallet - #[instrument(skip(self, bolt11))] - pub async fn pay_invoice_for_wallet( - &self, - bolt11: &str, - options: Option, - wallet_key: &WalletKey, - max_fee: Option, - ) -> Result { - let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + // If we should transfer to a trusted mint, do so now + if should_transfer { + if let Some(target_mint) = opts.transfer_to_mint { + // Ensure target mint exists and is trusted + if !self.has_mint(&target_mint).await { + // Clean up untrusted mint if we're only using it for transfer + self.remove_mint(&mint_url).await; + return Err(Error::UnknownMint { + mint_url: target_mint.to_string(), + }); + } + + // Transfer the entire balance from the untrusted mint to the target mint + // Use FullBalance mode for efficient transfer of all funds + let transfer_result = self + .transfer(&mint_url, &target_mint, TransferMode::FullBalance) + .await; + + // Handle transfer result - log details but don't fail if balance was zero + match transfer_result { + Ok(result) => { + if result.amount_sent > Amount::ZERO { + tracing::info!( + "Transferred {} sats from untrusted mint {} to trusted mint {} (received: {}, fees: {})", + result.amount_sent, + mint_url, + target_mint, + result.amount_received, + result.fees_paid + ); + } + } + Err(Error::InsufficientFunds) => { + // No balance to transfer, which is fine + tracing::debug!("No balance to transfer from untrusted mint {}", mint_url); + } + Err(e) => return Err(e), + } - let quote = wallet.melt_quote(bolt11.to_string(), options).await?; - if let Some(max_fee) = max_fee { - ensure_cdk!(quote.fee_reserve <= max_fee, Error::MaxFeeExceeded); + // Remove the untrusted mint after transfer + self.remove_mint(&mint_url).await; + } } + // Note: If allow_untrusted is true but no transfer is requested, + // the untrusted mint is kept in the wallet (as intended) - wallet.melt("e.id).await + Ok(amount_received) } /// Restore #[instrument(skip(self))] - pub async fn restore(&self, wallet_key: &WalletKey) -> Result { + pub async fn restore(&self, mint_url: &MintUrl) -> Result { let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet.restore().await } @@ -357,30 +1360,866 @@ impl MultiMintWallet { #[instrument(skip(self, token))] pub async fn verify_token_p2pk( &self, - wallet_key: &WalletKey, token: &Token, conditions: SpendingConditions, ) -> Result<(), Error> { + let mint_url = token.mint_url()?; let wallets = self.wallets.read().await; - let wallet = wallets - .get(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; wallet.verify_token_p2pk(token, conditions).await } /// Verifys all proofs in token have valid dleq proof #[instrument(skip(self, token))] - pub async fn verify_token_dleq( + pub async fn verify_token_dleq(&self, token: &Token) -> Result<(), Error> { + let mint_url = token.mint_url()?; + let wallets = self.wallets.read().await; + let wallet = wallets.get(&mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.verify_token_dleq(token).await + } + + /// Create a melt quote for a specific mint + #[instrument(skip(self, bolt11))] + pub async fn melt_quote( &self, - wallet_key: &WalletKey, - token: &Token, + mint_url: &MintUrl, + bolt11: String, + options: Option, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.melt_quote(bolt11, options).await + } + + /// Melt (pay invoice) from a specific mint using a quote ID + #[instrument(skip(self))] + pub async fn melt_with_mint( + &self, + mint_url: &MintUrl, + quote_id: &str, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.melt(quote_id).await + } + + /// Melt specific proofs from a specific mint using a quote ID + /// + /// This method allows melting proofs that may not be in the wallet's database, + /// similar to how `receive_proofs` handles external proofs. The proofs will be + /// added to the database and used for the melt operation. + /// + /// # Arguments + /// + /// * `mint_url` - The mint to use for the melt operation + /// * `quote_id` - The melt quote ID (obtained from `melt_quote`) + /// * `proofs` - The proofs to melt (can be external proofs not in the wallet's database) + /// + /// # Returns + /// + /// A `Melted` result containing the payment details and any change proofs + #[instrument(skip(self, proofs))] + pub async fn melt_proofs( + &self, + mint_url: &MintUrl, + quote_id: &str, + proofs: Proofs, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.melt_proofs(quote_id, proofs).await + } + + /// Check a specific melt quote status + #[instrument(skip(self))] + pub async fn check_melt_quote( + &self, + mint_url: &MintUrl, + quote_id: &str, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + // Check the quote state from the mint + wallet.melt_quote_status(quote_id).await?; + + // Get the updated quote from local storage + let quote = wallet + .localstore + .get_melt_quote(quote_id) + .await + .map_err(Error::Database)? + .ok_or(Error::UnknownQuote)?; + + Ok(quote) + } + + /// Create MPP (Multi-Path Payment) melt quotes from multiple mints + /// + /// This function allows manual specification of which mints and amounts to use for MPP. + /// Returns a vector of (MintUrl, MeltQuote) pairs. + #[instrument(skip(self, bolt11))] + pub async fn mpp_melt_quote( + &self, + bolt11: String, + mint_amounts: Vec<(MintUrl, Amount)>, + ) -> Result, Error> { + let mut quotes = Vec::new(); + let mut tasks = Vec::new(); + + // Spawn parallel tasks to get quotes from each mint + for (mint_url, amount) in mint_amounts { + let wallets = self.wallets.read().await; + let wallet = wallets + .get(&mint_url) + .ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })? + .clone(); + drop(wallets); + + let bolt11_clone = bolt11.clone(); + let mint_url_clone = mint_url.clone(); + + // Convert amount to millisats for MeltOptions + let amount_msat = u64::from(amount) * 1000; + let options = Some(MeltOptions::new_mpp(amount_msat)); + + let task = spawn(async move { + let quote = wallet.melt_quote(bolt11_clone, options).await; + (mint_url_clone, quote) + }); + + tasks.push(task); + } + + // Collect all quote results + for task in tasks { + match task.await { + Ok((mint_url, Ok(quote))) => { + quotes.push((mint_url, quote)); + } + Ok((mint_url, Err(e))) => { + tracing::error!("Failed to get melt quote from {}: {}", mint_url, e); + return Err(e); + } + Err(e) => { + tracing::error!("Task failed: {}", e); + return Err(Error::Internal); + } + } + } + + Ok(quotes) + } + + /// Execute MPP melts using previously obtained quotes + #[instrument(skip(self))] + pub async fn mpp_melt( + &self, + quotes: Vec<(MintUrl, String)>, // (mint_url, quote_id) + ) -> Result, Error> { + let mut results = Vec::new(); + let mut tasks = Vec::new(); + + for (mint_url, quote_id) in quotes { + let wallets = self.wallets.read().await; + let wallet = wallets + .get(&mint_url) + .ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })? + .clone(); + drop(wallets); + + let mint_url_clone = mint_url.clone(); + + let task = spawn(async move { + let melted = wallet.melt("e_id).await; + (mint_url_clone, melted) + }); + + tasks.push(task); + } + + // Collect all melt results + for task in tasks { + match task.await { + Ok((mint_url, Ok(melted))) => { + results.push((mint_url, melted)); + } + Ok((mint_url, Err(e))) => { + tracing::error!("Failed to melt from {}: {}", mint_url, e); + return Err(e); + } + Err(e) => { + tracing::error!("Task failed: {}", e); + return Err(Error::Internal); + } + } + } + + Ok(results) + } + + /// Melt (pay invoice) with automatic wallet selection (deprecated, use specific mint functions for better control) + /// + /// Automatically selects the best wallet to pay from based on: + /// - Available balance + /// - Fees + /// + /// # Examples + /// ```no_run + /// # use cdk::wallet::MultiMintWallet; + /// # use cdk::Amount; + /// # use std::sync::Arc; + /// # async fn example(wallet: Arc) -> Result<(), Box> { + /// // Pay a lightning invoice from any mint with sufficient balance + /// let invoice = "lnbc100n1p..."; + /// + /// let result = wallet.melt(invoice, None, None).await?; + /// println!("Paid {} sats, fee was {} sats", result.amount, result.fee_paid); + /// # Ok(()) + /// # } + /// ``` + #[instrument(skip(self, bolt11))] + pub async fn melt( + &self, + bolt11: &str, + options: Option, + max_fee: Option, + ) -> Result { + // Parse the invoice to get the amount + let invoice = bolt11 + .parse::() + .map_err(Error::Invoice)?; + + let amount = invoice + .amount_milli_satoshis() + .map(|msats| Amount::from(msats / 1000)) + .ok_or(Error::InvoiceAmountUndefined)?; + + let wallets = self.wallets.read().await; + let mut eligible_wallets = Vec::new(); + + for (mint_url, wallet) in wallets.iter() { + let balance = wallet.total_balance().await?; + if balance >= amount { + eligible_wallets.push((mint_url.clone(), wallet.clone())); + } + } + + if eligible_wallets.is_empty() { + return Err(Error::InsufficientFunds); + } + + // Try to get quotes from eligible wallets and select the best one + let mut best_quote = None; + let mut best_wallet = None; + + for (_, wallet) in eligible_wallets.iter() { + match wallet.melt_quote(bolt11.to_string(), options).await { + Ok(quote) => { + if let Some(max_fee) = max_fee { + if quote.fee_reserve > max_fee { + continue; + } + } + + if best_quote.is_none() { + best_quote = Some(quote); + best_wallet = Some(wallet.clone()); + } else if let Some(ref existing_quote) = best_quote { + if quote.fee_reserve < existing_quote.fee_reserve { + best_quote = Some(quote); + best_wallet = Some(wallet.clone()); + } + } + } + Err(_) => continue, + } + } + + if let (Some(quote), Some(wallet)) = (best_quote, best_wallet) { + return wallet.melt("e.id).await; + } + + Err(Error::InsufficientFunds) + } + + /// Swap proofs with automatic wallet selection + #[instrument(skip(self))] + pub async fn swap( + &self, + amount: Option, + conditions: Option, + ) -> Result, Error> { + // Find a wallet that has proofs + let wallets = self.wallets.read().await; + + for (_, wallet) in wallets.iter() { + let balance = wallet.total_balance().await?; + if balance > Amount::ZERO { + // Try to swap with this wallet + let proofs = wallet.get_unspent_proofs().await?; + if !proofs.is_empty() { + return wallet + .swap(amount, SplitTarget::default(), proofs, conditions, false) + .await; + } + } + } + + Err(Error::InsufficientFunds) + } + + /// Consolidate proofs from multiple wallets into fewer, larger proofs + /// This can help reduce the number of proofs and optimize wallet performance + #[instrument(skip(self))] + pub async fn consolidate(&self) -> Result { + let mut total_consolidated = Amount::ZERO; + let wallets = self.wallets.read().await; + + for (mint_url, wallet) in wallets.iter() { + // Get all unspent proofs for this wallet + let proofs = wallet.get_unspent_proofs().await?; + if proofs.len() > 1 { + // Consolidate by swapping all proofs for a single set + let proofs_amount = proofs.total_amount()?; + + // Swap for optimized proof set + match wallet + .swap( + Some(proofs_amount), + SplitTarget::default(), + proofs, + None, + false, + ) + .await + { + Ok(_) => { + total_consolidated += proofs_amount; + } + Err(e) => { + tracing::warn!( + "Failed to consolidate proofs for mint {:?}: {}", + mint_url, + e + ); + } + } + } + } + + 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 { + 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, 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(wallet_key) - .ok_or(Error::UnknownWallet(wallet_key.clone()))?; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; - wallet.verify_token_dleq(token).await + 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, 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 + } + + /// Melt Quote for BIP353 human-readable address + /// + /// This method resolves a BIP353 address (e.g., "alice@example.com") to a Lightning offer + /// and then creates a melt quote for that offer at the specified mint. + /// + /// # Arguments + /// + /// * `mint_url` - The mint to use for creating the melt quote + /// * `bip353_address` - Human-readable address in the format "user@domain.com" + /// * `amount_msat` - Amount to pay in millisatoshis + /// + /// # Returns + /// + /// A `MeltQuote` that can be used to execute the payment + #[cfg(all(feature = "bip353", not(target_arch = "wasm32")))] + #[instrument(skip(self, amount_msat))] + pub async fn melt_bip353_quote( + &self, + mint_url: &MintUrl, + bip353_address: &str, + amount_msat: impl Into, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.melt_bip353_quote(bip353_address, amount_msat).await + } + + /// Melt Quote for Lightning address + /// + /// This method resolves a Lightning address (e.g., "alice@example.com") to a Lightning invoice + /// and then creates a melt quote for that invoice at the specified mint. + /// + /// # Arguments + /// + /// * `mint_url` - The mint to use for creating the melt quote + /// * `lightning_address` - Lightning address in the format "user@domain.com" + /// * `amount_msat` - Amount to pay in millisatoshis + /// + /// # Returns + /// + /// A `MeltQuote` that can be used to execute the payment + #[instrument(skip(self, amount_msat))] + pub async fn melt_lightning_address_quote( + &self, + mint_url: &MintUrl, + lightning_address: &str, + amount_msat: impl Into, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet + .melt_lightning_address_quote(lightning_address, amount_msat) + .await + } + + /// Get a melt quote for a human-readable address + /// + /// This method accepts a human-readable address that could be either a BIP353 address + /// or a Lightning address. It intelligently determines which to try based on mint support: + /// + /// 1. If the mint supports Bolt12, it tries BIP353 first + /// 2. Falls back to Lightning address only if BIP353 DNS resolution fails + /// 3. If BIP353 resolves but fails at the mint, it does NOT fall back to Lightning address + /// 4. If the mint doesn't support Bolt12, it tries Lightning address directly + /// + /// # Arguments + /// + /// * `mint_url` - The mint to use for creating the melt quote + /// * `address` - Human-readable address (BIP353 or Lightning address) + /// * `amount_msat` - Amount to pay in millisatoshis + #[cfg(all(feature = "bip353", feature = "wallet", not(target_arch = "wasm32")))] + #[instrument(skip(self, amount_msat))] + pub async fn melt_human_readable_quote( + &self, + mint_url: &MintUrl, + address: &str, + amount_msat: impl Into, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.melt_human_readable_quote(address, amount_msat).await + } + + /// Get unit metadata for a specific mint + /// + /// Fetches the unit metadata from the mint's HTTP endpoint for this wallet's currency unit. + /// + /// # Arguments + /// + /// * `mint_url` - The mint to fetch unit metadata from + /// + /// # Returns + /// + /// The unit metadata for this wallet's currency unit + #[instrument(skip(self))] + pub async fn get_unit_metadata( + &self, + mint_url: &MintUrl, + ) -> Result { + let wallets = self.wallets.read().await; + let wallet = wallets.get(mint_url).ok_or(Error::UnknownMint { + mint_url: mint_url.to_string(), + })?; + + wallet.get_unit_metadata().await + } +} + +impl Drop for MultiMintWallet { + fn drop(&mut self) { + self.seed.zeroize(); + } +} + +/// Multi-Mint Receive Options +/// +/// Controls how tokens are received, especially from untrusted mints +#[derive(Debug, Clone, Default)] +pub struct MultiMintReceiveOptions { + /// Whether to allow receiving from untrusted (not yet added) mints + pub allow_untrusted: bool, + /// Mint to transfer tokens to from untrusted mints (None means keep in original mint) + pub transfer_to_mint: Option, + /// Base receive options to apply to the wallet receive + pub receive_options: ReceiveOptions, +} + +impl MultiMintReceiveOptions { + /// Create new default options + pub fn new() -> Self { + Default::default() + } + + /// Allow receiving from untrusted mints + pub fn allow_untrusted(mut self, allow: bool) -> Self { + self.allow_untrusted = allow; + self + } + + /// Set mint to transfer tokens to from untrusted mints + pub fn transfer_to_mint(mut self, mint_url: Option) -> Self { + self.transfer_to_mint = mint_url; + self + } + + /// Set the base receive options for the wallet operation + pub fn receive_options(mut self, options: ReceiveOptions) -> Self { + self.receive_options = options; + self + } +} + +/// Multi-Mint Send Options +/// +/// Controls transfer behavior when the target mint doesn't have sufficient balance +#[derive(Debug, Clone, Default)] +pub struct MultiMintSendOptions { + /// Whether to allow transferring funds from other mints to the sending mint + /// if the sending mint doesn't have sufficient balance + pub allow_transfer: bool, + /// Maximum amount to transfer from other mints (optional limit) + pub max_transfer_amount: Option, + /// Specific mints allowed for transfers (empty means all mints allowed) + pub allowed_mints: Vec, + /// Specific mints to exclude from transfers + pub excluded_mints: Vec, + /// Base send options to apply to the wallet send + pub send_options: SendOptions, +} + +impl MultiMintSendOptions { + /// Create new default options + pub fn new() -> Self { + Default::default() + } + + /// Enable transferring funds from other mints if needed + pub fn allow_transfer(mut self, allow: bool) -> Self { + self.allow_transfer = allow; + self + } + + /// Set maximum amount to transfer from other mints + pub fn max_transfer_amount(mut self, amount: Amount) -> Self { + self.max_transfer_amount = Some(amount); + self + } + + /// Add a mint to the allowed list for transfers + pub fn allow_mint(mut self, mint_url: MintUrl) -> Self { + self.allowed_mints.push(mint_url); + self + } + + /// Set all allowed mints for transfers + pub fn allowed_mints(mut self, mints: Vec) -> Self { + self.allowed_mints = mints; + self + } + + /// Add a mint to exclude from transfers + pub fn exclude_mint(mut self, mint_url: MintUrl) -> Self { + self.excluded_mints.push(mint_url); + self + } + + /// Set all excluded mints for transfers + pub fn excluded_mints(mut self, mints: Vec) -> Self { + self.excluded_mints = mints; + self + } + + /// Set the base send options for the wallet operation + pub fn send_options(mut self, options: SendOptions) -> Self { + self.send_options = options; + self + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use cdk_common::database::WalletDatabase; + + use super::*; + + async fn create_test_multi_wallet() -> MultiMintWallet { + let localstore: Arc + Send + Sync> = Arc::new( + cdk_sqlite::wallet::memory::empty() + .await + .expect("Failed to create in-memory database"), + ); + let seed = [0u8; 64]; + MultiMintWallet::new(localstore, seed, CurrencyUnit::Sat) + .await + .expect("Failed to create MultiMintWallet") + } + + #[tokio::test] + async fn test_total_balance_empty() { + let multi_wallet = create_test_multi_wallet().await; + let balance = multi_wallet.total_balance().await.unwrap(); + assert_eq!(balance, Amount::ZERO); + } + + #[tokio::test] + async fn test_prepare_send_insufficient_funds() { + use std::str::FromStr; + + let multi_wallet = create_test_multi_wallet().await; + let mint_url = MintUrl::from_str("https://mint1.example.com").unwrap(); + let options = MultiMintSendOptions::new(); + + let result = multi_wallet + .prepare_send(mint_url, Amount::from(1000), options) + .await; + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_consolidate_empty() { + let multi_wallet = create_test_multi_wallet().await; + let result = multi_wallet.consolidate().await.unwrap(); + assert_eq!(result, Amount::ZERO); + } + + #[tokio::test] + async fn test_multi_mint_wallet_creation() { + let multi_wallet = create_test_multi_wallet().await; + assert!(multi_wallet.wallets.try_read().is_ok()); + } + + #[tokio::test] + async fn test_multi_mint_send_options() { + use std::str::FromStr; + + let mint1 = MintUrl::from_str("https://mint1.example.com").unwrap(); + let mint2 = MintUrl::from_str("https://mint2.example.com").unwrap(); + let mint3 = MintUrl::from_str("https://mint3.example.com").unwrap(); + + let options = MultiMintSendOptions::new() + .allow_transfer(true) + .max_transfer_amount(Amount::from(500)) + .allow_mint(mint1.clone()) + .allow_mint(mint2.clone()) + .exclude_mint(mint3.clone()) + .send_options(SendOptions::default()); + + assert!(options.allow_transfer); + assert_eq!(options.max_transfer_amount, Some(Amount::from(500))); + assert_eq!(options.allowed_mints, vec![mint1, mint2]); + assert_eq!(options.excluded_mints, vec![mint3]); + } + + #[tokio::test] + async fn test_get_mint_keysets_unknown_mint() { + use std::str::FromStr; + + let multi_wallet = create_test_multi_wallet().await; + let mint_url = MintUrl::from_str("https://unknown-mint.example.com").unwrap(); + + // Should error when trying to get keysets for a mint that hasn't been added + let result = multi_wallet.get_mint_keysets(&mint_url).await; + assert!(result.is_err()); + + match result { + Err(Error::UnknownMint { mint_url: url }) => { + assert!(url.contains("unknown-mint.example.com")); + } + _ => panic!("Expected UnknownMint error"), + } + } + + #[tokio::test] + async fn test_multi_mint_receive_options() { + use std::str::FromStr; + + let mint_url = MintUrl::from_str("https://trusted.mint.example.com").unwrap(); + + // Test default options + let default_opts = MultiMintReceiveOptions::default(); + assert!(!default_opts.allow_untrusted); + assert!(default_opts.transfer_to_mint.is_none()); + + // Test builder pattern + let opts = MultiMintReceiveOptions::new() + .allow_untrusted(true) + .transfer_to_mint(Some(mint_url.clone())); + + assert!(opts.allow_untrusted); + assert_eq!(opts.transfer_to_mint, Some(mint_url)); + } + + #[tokio::test] + async fn test_get_token_data_unknown_mint() { + use std::str::FromStr; + + let multi_wallet = create_test_multi_wallet().await; + + // Create a token from a mint that isn't in the wallet + // This is a valid token structure pointing to an unknown mint + let token_str = "cashuBpGF0gaJhaUgArSaMTR9YJmFwgaNhYQFhc3hAOWE2ZGJiODQ3YmQyMzJiYTc2ZGIwZGYxOTcyMTZiMjlkM2I4Y2MxNDU1M2NkMjc4MjdmYzFjYzk0MmZlZGI0ZWFjWCEDhhhUP_trhpXfStS6vN6So0qWvc2X3O4NfM-Y1HISZ5JhZGlUaGFuayB5b3VhbXVodHRwOi8vbG9jYWxob3N0OjMzMzhhdWNzYXQ="; + let token = Token::from_str(token_str).unwrap(); + + // Should error because the mint (localhost:3338) hasn't been added + let result = multi_wallet.get_token_data(&token).await; + assert!(result.is_err()); + + match result { + Err(Error::UnknownMint { mint_url }) => { + assert!(mint_url.contains("localhost:3338")); + } + _ => panic!("Expected UnknownMint error"), + } + } + + #[test] + fn test_token_data_struct() { + use std::str::FromStr; + + let mint_url = MintUrl::from_str("https://example.mint.com").unwrap(); + let proofs = vec![]; + let memo = Some("Test memo".to_string()); + + let token_data = TokenData { + mint_url: mint_url.clone(), + proofs: proofs.clone(), + memo: memo.clone(), + }; + + assert_eq!(token_data.mint_url, mint_url); + assert_eq!(token_data.proofs.len(), 0); + assert_eq!(token_data.memo, memo); + + // Test with no memo + let token_data_no_memo = TokenData { + mint_url: mint_url.clone(), + proofs: vec![], + memo: None, + }; + assert!(token_data_no_memo.memo.is_none()); } } diff --git a/crates/cdk/src/wallet/payment_request.rs b/crates/cdk/src/wallet/payment_request.rs new file mode 100644 index 000000000..f4c6ce3ae --- /dev/null +++ b/crates/cdk/src/wallet/payment_request.rs @@ -0,0 +1,626 @@ +//! Utilities for paying NUT-18 Payment Requests. +//! +//! This module prepares and broadcasts payments for Cashu NUT-18 payment requests using either +//! Nostr or HTTP transports when available. If no transport is present in the request, an error +//! is returned so callers can handle alternative delivery mechanisms explicitly. + +use std::str::FromStr; + +use anyhow::Result; +use bitcoin::hashes::sha256::Hash as Sha256Hash; +use cdk_common::{Amount, PaymentRequest, PaymentRequestPayload, TransportType}; +#[cfg(feature = "nostr")] +use nostr_sdk::nips::nip19::Nip19Profile; +#[cfg(feature = "nostr")] +use nostr_sdk::prelude::*; +#[cfg(feature = "nostr")] +use nostr_sdk::{Client as NostrClient, EventBuilder, FromBech32, Keys, ToBech32}; +use reqwest::Client; + +use crate::error::Error; +use crate::nuts::nut11::{Conditions, SigFlag, SpendingConditions}; +use crate::nuts::nut18::Nut10SecretRequest; +use crate::nuts::{CurrencyUnit, Transport}; +#[cfg(feature = "nostr")] +use crate::wallet::MultiMintReceiveOptions; +use crate::wallet::{MultiMintWallet, SendOptions}; +use crate::Wallet; + +impl Wallet { + /// Pay a NUT-18 PaymentRequest using a specific wallet. + /// + /// - If the request contains a Nostr or HttpPost transport, it will try those (preferring Nostr). + /// - If no usable transport is present, this returns an error. + /// - If the request has no amount, a `custom_amount` must be provided. + pub async fn pay_request( + &self, + payment_request: PaymentRequest, + custom_amount: Option, + ) -> Result<(), Error> { + let amount = match payment_request.amount { + Some(amount) => amount, + None => match custom_amount { + Some(a) => a, + None => return Err(Error::AmountUndefined), + }, + }; + + let transports = payment_request.transports.clone(); + + // Prefer Nostr to avoid revealing IP, fall back to HTTP POST. + let transport = transports + .iter() + .find(|t| t._type == TransportType::Nostr) + .or_else(|| { + transports + .iter() + .find(|t| t._type == TransportType::HttpPost) + }); + + let prepared_send = self + .prepare_send( + amount, + SendOptions { + include_fee: true, + ..Default::default() + }, + ) + .await?; + + let token = prepared_send.confirm(None).await?; + + // We need the keysets information to properly convert from token proof to proof + let keysets_info = match self.localstore.get_mint_keysets(token.mint_url()?).await? { + Some(keysets_info) => keysets_info, + None => self.load_mint_keysets().await?, + }; + let proofs = token.proofs(&keysets_info)?; + + if let Some(transport) = transport { + let payload = PaymentRequestPayload { + id: payment_request.payment_id.clone(), + memo: None, + mint: self.mint_url.clone(), + unit: self.unit.clone(), + proofs, + }; + + match transport._type { + TransportType::Nostr => { + #[cfg(feature = "nostr")] + { + let keys = Keys::generate(); + let client = NostrClient::new(keys); + let nprofile = Nip19Profile::from_bech32(&transport.target) + .map_err(|e| Error::Custom(format!("Invalid nprofile: {e}")))?; + + let rumor = EventBuilder::new( + nostr_sdk::Kind::from_u16(14), + serde_json::to_string(&payload) + .map_err(|e| Error::Custom(format!("Serialize payload: {e}")))?, + ) + .build(nprofile.public_key); + let relays = nprofile.relays; + + for relay in relays.iter() { + client + .add_write_relay(relay) + .await + .map_err(|e| Error::Custom(format!("Add relay {relay}: {e}")))?; + } + + client.connect().await; + + let gift_wrap = client + .gift_wrap_to(relays, &nprofile.public_key, rumor, None) + .await + .map_err(|e| Error::Custom(format!("Publish Nostr event: {e}")))?; + + println!( + "Published event {} successfully to {}", + gift_wrap.val, + gift_wrap + .success + .iter() + .map(|s| s.to_string()) + .collect::>() + .join(", ") + ); + + if !gift_wrap.failed.is_empty() { + println!( + "Could not publish to {}", + gift_wrap + .failed + .keys() + .map(|relay| relay.to_string()) + .collect::>() + .join(", ") + ); + } + + Ok(()) + } + #[cfg(not(feature = "nostr"))] + Err(Error::Custom( + "Nostr is not enabled in this build".to_string(), + )) + } + + TransportType::HttpPost => { + let client = Client::new(); + + let res = client + .post(transport.target.clone()) + .json(&payload) + .send() + .await + .map_err(|e| Error::HttpError(None, e.to_string()))?; + + let status = res.status(); + if status.is_success() { + println!("Successfully posted payment"); + Ok(()) + } else { + let body = res.text().await.unwrap_or_default(); + Err(Error::HttpError(Some(status.as_u16()), body)) + } + } + } + } else { + // If no transport is available, return an error instead of printing the token + Err(Error::Custom( + "No transport available in payment request".to_string(), + )) + } + } +} + +/// Parameters for creating a PaymentRequest +/// +/// This mirrors the CLI inputs and is used by `create_request` to build a +/// NUT-18 PaymentRequest. When `transport` is set to `nostr`, the function +/// also returns a `NostrWaitInfo` that can be passed to `wait_for_nostr_payment`. +#[derive(Debug, Clone)] +pub struct CreateRequestParams { + /// Optional amount to request (in the smallest unit for the chosen currency unit) + pub amount: Option, + /// Currency unit string (e.g., "sat") + pub unit: String, + /// Optional human-readable description for the request + pub description: Option, + /// Optional set of public keys for P2PK spending conditions (multisig supported) + pub pubkeys: Option>, // multiple P2PK pubkeys + /// Required number of signatures if `pubkeys` is provided (defaults typically to 1) + pub num_sigs: u64, // required signatures for P2PK + /// Optional HTLC hash condition (mutually exclusive with `preimage`) + pub hash: Option, // HTLC hash + /// Optional HTLC preimage (mutually exclusive with `hash`) + pub preimage: Option, // HTLC preimage + /// Transport type for the request: "nostr", "http", or "none" + pub transport: String, // "nostr", "http", or "none" + /// Target URL for HTTP transport (required if `transport == http`) + pub http_url: Option, // when transport == http + /// List of Nostr relay URLs to include in the nprofile (used if `transport == nostr`) + pub nostr_relays: Option>, // when transport == nostr +} + +/// Extra information needed to wait for an incoming Nostr payment +/// +/// Returned by `create_request` when the transport is `nostr`. Pass this to +/// `wait_for_nostr_payment` to connect, subscribe, and receive the incoming +/// payment on the specified relays. +#[cfg(feature = "nostr")] +#[derive(Debug, Clone)] +pub struct NostrWaitInfo { + /// Ephemeral keys used to connect to relays and unwrap the gift-wrapped event + pub keys: Keys, + /// Nostr relays to read from while waiting for the payment + pub relays: Vec, + /// The recipient public key to subscribe to for incoming events + pub pubkey: nostr_sdk::PublicKey, +} + +impl MultiMintWallet { + /// Derive enforceable NUT-10 spending conditions from high-level request params. + /// + /// Why: + /// - Centralizes translation of CLI/SDK inputs (P2PK multisig and HTLC variants) into + /// a single, canonical `SpendingConditions` shape so requests are consistent. + /// - Prevents ambiguous construction by capping `num_sigs` to the number of provided keys + /// and rejecting malformed hashes/inputs early. + /// - Encourages safe defaults by selecting `SigFlag::SigInputs` and composing conditions + /// that can be verified by recipients and mints. + /// + /// Behavior notes (rationale): + /// - If no P2PK or HTLC data is given, returns `Ok(None)` so callers emit a plain request + /// without additional constraints. + /// - With `pubkeys` only, constructs P2PK-style conditions where the first key is used as + /// the primary spend key and the remainder contribute to multisig according to `num_sigs`. + /// - With `hash` or `preimage`, constructs an HTLC condition, optionally embedding P2PK + /// conditions to require signatures in addition to the hash lock. + /// + /// Errors: + /// - Invalid SHA-256 `hash` strings or invalid HTLC/P2PK parameterizations surface as errors + /// from parsing and `SpendingConditions` constructors. + fn get_pr_spending_conditions( + &self, + params: &CreateRequestParams, + ) -> Result, Error> { + // Spending conditions + let spending_conditions: Option = + if let Some(pubkey_strings) = ¶ms.pubkeys { + // parse pubkeys + let mut parsed_pubkeys = Vec::new(); + for p in pubkey_strings { + if let Ok(pk) = crate::nuts::nut01::PublicKey::from_str(p) { + parsed_pubkeys.push(pk); + } + } + + if parsed_pubkeys.is_empty() { + None + } else { + let num_sigs = params.num_sigs.min(parsed_pubkeys.len() as u64); + + if let Some(hash_str) = ¶ms.hash { + let conditions = Conditions { + locktime: None, + pubkeys: Some(parsed_pubkeys), + refund_keys: None, + num_sigs: Some(num_sigs), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }; + + match Sha256Hash::from_str(hash_str) { + Ok(hash) => Some(SpendingConditions::HTLCConditions { + data: hash, + conditions: Some(conditions), + }), + Err(err) => { + return Err(Error::Custom(format!("Error parsing hash: {err}"))) + } + } + } else if let Some(preimage) = ¶ms.preimage { + let conditions = Conditions { + locktime: None, + pubkeys: Some(parsed_pubkeys), + refund_keys: None, + num_sigs: Some(num_sigs), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }; + + Some(SpendingConditions::new_htlc( + preimage.to_string(), + Some(conditions), + )?) + } else { + Some(SpendingConditions::new_p2pk( + *parsed_pubkeys.first().expect("not empty"), + Some(Conditions { + locktime: None, + pubkeys: Some(parsed_pubkeys[1..].to_vec()), + refund_keys: None, + num_sigs: Some(num_sigs), + sig_flag: SigFlag::SigInputs, + num_sigs_refund: None, + }), + )) + } + } + } else if let Some(hash_str) = ¶ms.hash { + match Sha256Hash::from_str(hash_str) { + Ok(hash) => Some(SpendingConditions::HTLCConditions { + data: hash, + conditions: None, + }), + Err(err) => return Err(Error::Custom(format!("Error parsing hash: {err}"))), + } + } else if let Some(preimage) = ¶ms.preimage { + Some(SpendingConditions::new_htlc(preimage.to_string(), None)?) + } else { + None + }; + Ok(spending_conditions) + } + + /// Create a NUT-18 PaymentRequest from high-level parameters. + /// + /// Why: + /// - Ensures the CLI and SDKs construct requests consistently using wallet context. + /// - Advertises available mints for the chosen unit so payers can select compatible proofs. + /// - Optionally embeds a transport; Nostr is preferred to reduce IP exposure for the payer. + /// + /// Behavior summary (focus on rationale rather than steps): + /// - Uses `unit` to discover mints with balances as a hint to senders (helps route payments without leaking more data than necessary). + /// - Translates P2PK/multisig and HTLC inputs (pubkeys/num_sigs/hash/preimage) into a NUT-10 secret request so the receiver can enforce spending constraints. + /// - For `transport == "nostr"`, generates ephemeral keys and an nprofile pointing at the chosen relays; returns `NostrWaitInfo` so callers can wait for the incoming payment without coupling construction and reception logic. + /// - For `transport == "http"`, attaches the provided endpoint; for `none` or unknown, omits transports to let the caller deliver out-of-band. + /// + /// Returns: + /// - `(PaymentRequest, Some(NostrWaitInfo))` when `transport == "nostr"`. + /// - `(PaymentRequest, None)` otherwise. + /// + /// Errors when: + /// - `unit` cannot be parsed, relay URLs are invalid, or P2PK/HTLC parameters are malformed. + /// + /// Notes: + /// - Sets `single_use = true` to discourage replays. + /// - Ephemeral Nostr keys are intentional; keep `NostrWaitInfo` only as long as needed for reception. + #[cfg(feature = "nostr")] + pub async fn create_request( + &self, + params: CreateRequestParams, + ) -> Result<(PaymentRequest, Option), Error> { + // Collect available mints for the selected unit + let mints = self + .get_balances() + .await? + .keys() + .cloned() + .collect::>(); + + // Transports + let transport_type = params.transport.to_lowercase(); + let (transports, nostr_info): (Vec, Option) = + match transport_type.as_str() { + "nostr" => { + let keys = Keys::generate(); + let relays = if let Some(custom_relays) = ¶ms.nostr_relays { + if !custom_relays.is_empty() { + custom_relays.clone() + } else { + return Err(Error::Custom("No relays provided".to_string())); + } + } else { + return Err(Error::Custom("No relays provided".to_string())); + }; + + // Parse relay URLs for nprofile + let relay_urls = relays + .iter() + .map(|r| RelayUrl::parse(r)) + .collect::, _>>() + .map_err(|e| Error::Custom(format!("Couldn't parse relays: {e}")))?; + + let nprofile = + nostr_sdk::nips::nip19::Nip19Profile::new(keys.public_key, relay_urls); + let nostr_transport = Transport { + _type: TransportType::Nostr, + target: nprofile.to_bech32().map_err(|e| { + Error::Custom(format!("Couldn't convert nprofile to bech32: {e}")) + })?, + tags: Some(vec![vec!["n".to_string(), "17".to_string()]]), + }; + + ( + vec![nostr_transport], + Some(NostrWaitInfo { + keys, + relays, + pubkey: nprofile.public_key, + }), + ) + } + "http" => { + if let Some(url) = ¶ms.http_url { + let http_transport = Transport { + _type: TransportType::HttpPost, + target: url.clone(), + tags: None, + }; + (vec![http_transport], None) + } else { + // No URL provided, skip transport + (vec![], None) + } + } + "none" => (vec![], None), + _ => (vec![], None), + }; + + let nut10 = self + .get_pr_spending_conditions(¶ms)? + .map(Nut10SecretRequest::from); + + let req = PaymentRequest { + payment_id: None, + amount: params.amount.map(Amount::from), + unit: Some(CurrencyUnit::from_str(¶ms.unit)?), + single_use: Some(true), + mints: Some(mints), + description: params.description, + transports, + nut10, + }; + + Ok((req, nostr_info)) + } + + /// Create a NUT-18 PaymentRequest from high-level parameters (Nostr disabled build). + /// + /// Why: + /// - Keep request construction consistent even when Nostr is not compiled in. + /// - Still advertise available mints for the unit so payers can route proofs correctly. + /// - Allow callers to attach an HTTP transport when out-of-band delivery is acceptable. + /// + /// Behavior notes: + /// - Rejects `transport == "nostr"` early so callers can surface a clear UX error. + /// - Encodes P2PK/multisig and HTLC constraints into a NUT-10 secret request for enforceable spending conditions. + /// + /// Returns the constructed PaymentRequest and sets `single_use = true` to discourage replay. + #[cfg(not(feature = "nostr"))] + pub async fn create_request( + &self, + params: CreateRequestParams, + ) -> Result { + // Collect available mints for the selected unit + let mints = self + .get_balances() + .await? + .keys() + .cloned() + .collect::>(); + + // Transports + let transport_type = params.transport.to_lowercase(); + let transports: Vec = match transport_type.as_str() { + "nostr" => { + return Err(Error::Custom( + "Nostr is not supported in this build".to_string(), + )) + } + "http" => { + if let Some(url) = ¶ms.http_url { + let http_transport = Transport { + _type: TransportType::HttpPost, + target: url.clone(), + tags: None, + }; + vec![http_transport] + } else { + // No URL provided, skip transport + vec![] + } + } + _ => vec![], + }; + + let nut10 = self + .get_pr_spending_conditions(¶ms)? + .map(Nut10SecretRequest::from); + + let req = PaymentRequest { + payment_id: None, + amount: params.amount.map(Amount::from), + unit: Some(CurrencyUnit::from_str(¶ms.unit)?), + single_use: Some(true), + mints: Some(mints), + description: params.description, + transports, + nut10, + }; + + Ok(req) + } + + /// Wait for a Nostr payment for the previously constructed PaymentRequest and receive it into the wallet. + #[cfg(all(feature = "nostr", not(target_arch = "wasm32")))] + pub async fn wait_for_nostr_payment(&self, info: NostrWaitInfo) -> Result { + use futures::StreamExt; + + use crate::wallet::streams::nostr::NostrPaymentEventStream; + + let NostrWaitInfo { + keys, + relays, + pubkey, + } = info; + + let mut stream = NostrPaymentEventStream::new(keys, relays, pubkey); + let cancel = stream.cancel_token(); + + // Optional: you may expose cancel to caller, or use a timeout here. + // tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(120)).await; cancel.cancel(); }); + + while let Some(item) = stream.next().await { + match item { + Ok(payload) => { + let token = crate::nuts::Token::new( + payload.mint, + payload.proofs, + payload.memo, + payload.unit, + ); + + let amount = self + .receive(&token.to_string(), MultiMintReceiveOptions::default()) + .await?; + + // Stop after first successful receipt + cancel.cancel(); + return Ok(amount); + } + Err(_) => { + // Keep listening on parse errors; if you prefer fail-fast, return the error + continue; + } + } + } + + // If stream ended without receiving a payment, return zero. + Ok(Amount::ZERO) + } + + /// Wait for a Nostr payment for the previously constructed PaymentRequest and receive it into the wallet. + /// + /// wasm32 fallback: Streams are not available; we await the first matching notification and process it. + #[cfg(all(feature = "nostr", target_arch = "wasm32"))] + pub async fn wait_for_nostr_payment(&self, info: NostrWaitInfo) -> Result { + use nostr_sdk::prelude::*; + + let NostrWaitInfo { + keys, + relays, + pubkey, + } = info; + + let client = nostr_sdk::Client::new(keys); + + for r in &relays { + client + .add_read_relay(r.clone()) + .await + .map_err(|e| crate::error::Error::Custom(format!("Add relay {r}: {e}")))?; + } + + client.connect().await; + + // Subscribe to events addressed to `pubkey` + let filter = Filter::new().pubkey(pubkey); + client + .subscribe(filter, None) + .await + .map_err(|e| crate::error::Error::Custom(format!("Subscribe: {e}")))?; + + // Await notifications until we successfully parse a payment payload and receive it + let mut notifications = client.notifications(); + while let Ok(notification) = notifications.recv().await { + if let RelayPoolNotification::Event { event, .. } = notification { + match client.unwrap_gift_wrap(&event).await { + Ok(unwrapped) => { + let rumor = unwrapped.rumor; + match serde_json::from_str::(&rumor.content) { + Ok(payload) => { + let token = crate::nuts::Token::new( + payload.mint, + payload.proofs, + payload.memo, + payload.unit, + ); + + let amount = self + .receive(&token.to_string(), MultiMintReceiveOptions::default()) + .await?; + + return Ok(amount); + } + Err(_) => { + // Ignore malformed payloads and continue listening + continue; + } + } + } + Err(_) => { + // Ignore unwrap errors and continue listening + continue; + } + } + } + } + + Ok(Amount::ZERO) + } +} diff --git a/crates/cdk/src/wallet/proofs.rs b/crates/cdk/src/wallet/proofs.rs index 2fe0c9e67..3026a5960 100644 --- a/crates/cdk/src/wallet/proofs.rs +++ b/crates/cdk/src/wallet/proofs.rs @@ -1,5 +1,6 @@ use std::collections::{HashMap, HashSet}; +use cdk_common::amount::KeysetFeeAndAmounts; use cdk_common::wallet::TransactionId; use cdk_common::Id; use tracing::instrument; @@ -188,11 +189,16 @@ impl Wallet { amount: Amount, proofs: Proofs, active_keyset_ids: &Vec, - keyset_fees: &HashMap, + fees_and_keyset_amounts: &KeysetFeeAndAmounts, include_fees: bool, ) -> Result<(Proofs, Option<(Proof, Amount)>), Error> { - let mut input_proofs = - Self::select_proofs(amount, proofs, active_keyset_ids, keyset_fees, include_fees)?; + let mut input_proofs = Self::select_proofs( + amount, + proofs, + active_keyset_ids, + fees_and_keyset_amounts, + include_fees, + )?; let mut exchange = None; // How much amounts do we have selected in our proof sets? @@ -211,9 +217,9 @@ impl Wallet { input_proofs.sort_by(|a, b| a.amount.cmp(&b.amount)); if let Some(proof_to_exchange) = input_proofs.pop() { - let fee_ppk = keyset_fees + let fee_ppk = fees_and_keyset_amounts .get(&proof_to_exchange.keyset_id) - .cloned() + .map(|fee_and_amounts| fee_and_amounts.fee()) .unwrap_or_default() .into(); @@ -239,14 +245,9 @@ impl Wallet { amount: Amount, proofs: Proofs, active_keyset_ids: &Vec, - keyset_fees: &HashMap, + fees_and_keyset_amounts: &KeysetFeeAndAmounts, include_fees: bool, ) -> Result { - tracing::debug!( - "amount={}, proofs={:?}", - amount, - proofs.iter().map(|p| p.amount.into()).collect::>() - ); if amount == Amount::ZERO { return Ok(vec![]); } @@ -256,18 +257,19 @@ impl Wallet { let mut proofs = proofs; proofs.sort_by(|a, b| a.cmp(b).reverse()); - // Split the amount into optimal amounts - let optimal_amounts = amount.split(); - // Track selected proofs and remaining amounts (include all inactive proofs first) - let mut selected_proofs: HashSet = proofs + let inactive_proofs: Proofs = proofs .iter() .filter(|p| !p.is_active(active_keyset_ids)) .cloned() .collect(); + let mut selected_proofs: HashSet = inactive_proofs.iter().cloned().collect(); if selected_proofs.total_amount()? >= amount { tracing::debug!("All inactive proofs are sufficient"); - return Ok(selected_proofs.into_iter().collect()); + // Still need to filter to minimum set, not return all of them + let mut inactive_selected = selected_proofs.into_iter().collect::>(); + inactive_selected.sort_by(|a, b| a.cmp(b).reverse()); + return Self::select_least_amount_over(inactive_selected, amount); } let mut remaining_amounts: Vec = Vec::new(); @@ -294,27 +296,41 @@ impl Wallet { } }; - // Select proofs with the optimal amounts - for optimal_amount in optimal_amounts { - if !select_proof(&proofs, optimal_amount, true) { - // Add the remaining amount to the remaining amounts because proof with the optimal amount was not found - remaining_amounts.push(optimal_amount); + // Get fee_and_amounts for the first active keyset (use for optimal amount splitting) + // We only need to split once - iterating over all keysets would cause duplicate selections + let fee_and_amounts = active_keyset_ids + .iter() + .find_map(|id| fees_and_keyset_amounts.get(id)) + .or_else(|| fees_and_keyset_amounts.values().next()); + + // Select proofs with the optimal amounts (only split once, not per keyset) + if let Some(fee_and_amounts) = fee_and_amounts { + for optimal_amount in amount.split(fee_and_amounts) { + if !select_proof(&proofs, optimal_amount, true) { + // Add the remaining amount to the remaining amounts because proof with the optimal amount was not found + remaining_amounts.push(optimal_amount); + } } } // If all the optimal amounts are selected, return the selected proofs if remaining_amounts.is_empty() { - tracing::debug!("All optimal amounts are selected"); + let result: Proofs = selected_proofs.into_iter().collect(); + tracing::debug!( + "All optimal amounts are selected, returning {} proofs with total {}", + result.len(), + result.total_amount().unwrap_or_default() + ); if include_fees { return Self::include_fees( amount, proofs, - selected_proofs.into_iter().collect(), + result, active_keyset_ids, - keyset_fees, + fees_and_keyset_amounts, ); } else { - return Ok(selected_proofs.into_iter().collect()); + return Ok(result); } } @@ -373,7 +389,7 @@ impl Wallet { proofs, selected_proofs, active_keyset_ids, - keyset_fees, + fees_and_keyset_amounts, ); } @@ -429,50 +445,66 @@ impl Wallet { proofs: Proofs, mut selected_proofs: Proofs, active_keyset_ids: &Vec, - keyset_fees: &HashMap, + fees_and_keyset_amounts: &KeysetFeeAndAmounts, ) -> Result { tracing::debug!("Including fees"); - let fee = - calculate_fee(&selected_proofs.count_by_keyset(), keyset_fees).unwrap_or_default(); - let net_amount = selected_proofs.total_amount()? - fee; - tracing::debug!( - "Net amount={}, fee={}, total amount={}", - net_amount, - fee, - selected_proofs.total_amount()? - ); - if net_amount >= amount { - tracing::debug!( - "Selected proofs: {:?}", - selected_proofs - .iter() - .map(|p| p.amount.into()) - .collect::>(), - ); - return Ok(selected_proofs); - } - tracing::debug!("Net amount is less than the required amount"); - let remaining_amount = amount - net_amount; - let remaining_proofs = proofs + let keyset_fees: HashMap = fees_and_keyset_amounts + .iter() + .map(|(key, values)| (*key, values.fee())) + .collect(); + + let mut remaining_proofs: Proofs = proofs .into_iter() .filter(|p| !selected_proofs.contains(p)) - .collect::(); - selected_proofs.extend(Wallet::select_proofs( - remaining_amount, - remaining_proofs, - active_keyset_ids, - &HashMap::new(), // Fees are already calculated - false, - )?); - tracing::debug!( - "Selected proofs: {:?}", - selected_proofs - .iter() - .map(|p| p.amount.into()) - .collect::>(), - ); - Ok(selected_proofs) + .collect(); + + loop { + let fee = + calculate_fee(&selected_proofs.count_by_keyset(), &keyset_fees).unwrap_or_default(); + let total = selected_proofs.total_amount()?; + let net_amount = total - fee; + + tracing::debug!( + "Net amount={}, fee={}, total amount={}", + net_amount, + fee, + total + ); + + if net_amount >= amount { + tracing::debug!( + "Selected proofs: {:?}", + selected_proofs + .iter() + .map(|p| p.amount.into()) + .collect::>(), + ); + return Ok(selected_proofs); + } + + if remaining_proofs.is_empty() { + return Err(Error::InsufficientFunds); + } + + let shortfall = amount - net_amount; + tracing::debug!("Net amount is less than required, shortfall={}", shortfall); + + let additional = Wallet::select_proofs( + shortfall, + remaining_proofs.clone(), + active_keyset_ids, + fees_and_keyset_amounts, + false, + )?; + + if additional.is_empty() { + return Err(Error::InsufficientFunds); + } + + remaining_proofs.retain(|p| !additional.contains(p)); + selected_proofs.extend(additional); + } } } @@ -503,17 +535,40 @@ mod tests { #[test] fn test_select_proofs_empty() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = vec![]; - let selected_proofs = - Wallet::select_proofs(0.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + let selected_proofs = Wallet::select_proofs( + 0.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 0); } #[test] fn test_select_proofs_insufficient() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = vec![proof(1), proof(2), proof(4)]; - let selected_proofs = - Wallet::select_proofs(8.into(), proofs, &vec![id()], &HashMap::new(), false); + let selected_proofs = Wallet::select_proofs( + 8.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ); assert!(selected_proofs.is_err()); } @@ -528,8 +583,22 @@ mod tests { proof(32), proof(64), ]; - let mut selected_proofs = - Wallet::select_proofs(77.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + + let mut selected_proofs = Wallet::select_proofs( + 77.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); selected_proofs.sort(); assert_eq!(selected_proofs.len(), 4); assert_eq!(selected_proofs[0].amount, 1.into()); @@ -540,9 +609,21 @@ mod tests { #[test] fn test_select_proofs_over() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = vec![proof(1), proof(2), proof(4), proof(8), proof(32), proof(64)]; - let selected_proofs = - Wallet::select_proofs(31.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + let selected_proofs = Wallet::select_proofs( + 31.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 1); assert_eq!(selected_proofs[0].amount, 32.into()); } @@ -550,8 +631,21 @@ mod tests { #[test] fn test_select_proofs_smaller_over() { let proofs = vec![proof(8), proof(16), proof(32)]; - let selected_proofs = - Wallet::select_proofs(23.into(), proofs, &vec![id()], &HashMap::new(), false).unwrap(); + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + + let selected_proofs = Wallet::select_proofs( + 23.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 2); assert_eq!(selected_proofs[0].amount, 16.into()); assert_eq!(selected_proofs[1].amount, 8.into()); @@ -559,10 +653,21 @@ mod tests { #[test] fn test_select_proofs_many_ones() { + let active_id = id(); + let mut fee_and_keyset_amounts = HashMap::new(); + fee_and_keyset_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = (0..1024).map(|_| proof(1)).collect::>(); - let selected_proofs = - Wallet::select_proofs(1024.into(), proofs, &vec![id()], &HashMap::new(), false) - .unwrap(); + let selected_proofs = Wallet::select_proofs( + 1024.into(), + proofs, + &vec![active_id], + &fee_and_keyset_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 1024); selected_proofs .iter() @@ -571,10 +676,21 @@ mod tests { #[test] fn test_select_proof_change() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = vec![proof(64), proof(4), proof(32)]; - let (selected_proofs, exchange) = - Wallet::select_exact_proofs(97.into(), proofs, &vec![id()], &HashMap::new(), false) - .unwrap(); + let (selected_proofs, exchange) = Wallet::select_exact_proofs( + 97.into(), + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert!(exchange.is_some()); let (proof_to_exchange, amount) = exchange.unwrap(); @@ -585,14 +701,20 @@ mod tests { #[test] fn test_select_proofs_huge_proofs() { + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); let proofs = (0..32) .flat_map(|i| (0..5).map(|_| proof(1 << i)).collect::>()) .collect::>(); let mut selected_proofs = Wallet::select_proofs( ((1u64 << 32) - 1).into(), proofs, - &vec![id()], - &HashMap::new(), + &vec![active_id], + &keyset_fee_and_amounts, false, ) .unwrap(); @@ -608,11 +730,1422 @@ mod tests { #[test] fn test_select_proofs_with_fees() { let proofs = vec![proof(64), proof(4), proof(32)]; - let mut keyset_fees = HashMap::new(); - keyset_fees.insert(id(), 100); - let selected_proofs = - Wallet::select_proofs(10.into(), proofs, &vec![id()], &keyset_fees, false).unwrap(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert(id(), (100, (0..32).map(|x| 2u64.pow(x)).collect()).into()); + let selected_proofs = Wallet::select_proofs( + 10.into(), + proofs, + &vec![id()], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); assert_eq!(selected_proofs.len(), 1); assert_eq!(selected_proofs[0].amount, 32.into()); } + + #[test] + fn test_select_proofs_include_fees_accounts_for_additional_proof_fees() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (100, (0..32).map(|x| 2u64.pow(x)).collect()).into(), + ); + + let proofs = vec![ + proof(512), + proof(256), + proof(128), + proof(64), + proof(32), + proof(16), + proof(8), + proof(4), + proof(2), + proof(1), + ]; + + let amount: Amount = 1010.into(); + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Net amount {} should be >= requested amount {} (total={}, fee={})", + net, + amount, + total, + fee + ); + } + + #[test] + fn test_select_proofs_include_fees_iterates_until_stable() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active_id, + (100, (0..32).map(|x| 2u64.pow(x)).collect()).into(), + ); + + let mut proofs = Vec::new(); + for i in 0..10 { + proofs.push(proof(1 << i)); + } + proofs.push(proof(2)); + proofs.push(proof(4)); + + let amount: Amount = 1010.into(); + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Net amount {} should be >= requested amount {} (total={}, fee={}, num_proofs={})", + net, + amount, + total, + fee, + selected_proofs.len() + ); + } + + // ======================================================================== + // Fee-Aware Proof Selection Tests (fee_ppk = 200) + // ======================================================================== + + fn keyset_fee_and_amounts_with_fee( + fee_ppk: u64, + ) -> HashMap { + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + id(), + (fee_ppk, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + keyset_fee_and_amounts + } + + fn standard_proofs() -> Vec { + vec![ + proof(1), + proof(2), + proof(4), + proof(8), + proof(16), + proof(32), + proof(64), + proof(128), + proof(256), + proof(512), + proof(1024), + proof(2048), + proof(4096), + ] + } + + fn fragmented_proofs() -> Vec { + let mut proofs = Vec::new(); + for _ in 0..10 { + proofs.push(proof(1)); + } + for _ in 0..8 { + proofs.push(proof(2)); + } + for _ in 0..6 { + proofs.push(proof(4)); + } + for _ in 0..5 { + proofs.push(proof(8)); + } + for _ in 0..4 { + proofs.push(proof(16)); + } + for _ in 0..3 { + proofs.push(proof(32)); + } + for _ in 0..2 { + proofs.push(proof(64)); + } + for _ in 0..2 { + proofs.push(proof(128)); + } + for _ in 0..2 { + proofs.push(proof(256)); + } + for _ in 0..2 { + proofs.push(proof(512)); + } + for _ in 0..2 { + proofs.push(proof(1024)); + } + for _ in 0..2 { + proofs.push(proof(2048)); + } + proofs + } + + fn large_proofs() -> Vec { + vec![ + proof(4096), + proof(2048), + proof(1024), + proof(512), + proof(256), + ] + } + + fn mixed_proofs() -> Vec { + vec![ + proof(4096), + proof(1024), + proof(256), + proof(256), + proof(128), + proof(64), + proof(32), + proof(16), + proof(8), + proof(4), + proof(2), + proof(1), + proof(1), + ] + } + + #[test] + fn test_select_proofs_with_fees_single_proof_exact() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![proof(4096)]; + let amount: Amount = 4095.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert_eq!(selected_proofs.len(), 1); + assert_eq!(selected_proofs[0].amount, 4096.into()); + assert!(net >= amount, "4096 - 1 (fee) = 4095 >= 4095"); + } + + #[test] + fn test_select_proofs_with_fees_single_proof_insufficient() { + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![proof(4096)]; + let amount: Amount = 4096.into(); + + let result = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ); + + assert!(result.is_err(), "4096 - 1 (fee) = 4095 < 4096, should fail"); + } + + #[test] + fn test_select_proofs_with_fees_two_proofs_fee_threshold() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![proof(4096), proof(1024)]; + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!(net >= amount, "5120 - 1 = 5119 >= 5000"); + } + + #[test] + fn test_select_proofs_with_fees_iterative_fee_adjustment() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![ + proof(4096), + proof(1024), + proof(512), + proof(256), + proof(128), + proof(8), + ]; + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Net amount {} should be >= requested amount {} (total={}, fee={})", + net, + amount, + total, + fee + ); + } + + #[test] + fn test_select_proofs_with_fees_fee_increases_with_proofs() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![ + proof(1024), + proof(1024), + proof(1024), + proof(1024), + proof(1024), + ]; + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Net amount {} should be >= requested amount {}", + net, + amount + ); + } + + #[test] + fn test_select_proofs_with_fees_standard_proofs() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = standard_proofs(); + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Standard proofs: net {} should be >= {} (total={}, fee={}, num_proofs={})", + net, + amount, + total, + fee, + selected_proofs.len() + ); + } + + #[test] + fn test_select_proofs_with_fees_mixed_proofs() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = mixed_proofs(); + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Mixed proofs: net {} should be >= {} (total={}, fee={}, num_proofs={})", + net, + amount, + total, + fee, + selected_proofs.len() + ); + } + + // ======================================================================== + // High Fee Tests (fee_ppk = 1000, i.e., 1 sat per proof) + // ======================================================================== + + #[test] + fn test_select_proofs_high_fees_one_sat_per_proof() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000); + + let proofs = vec![ + proof(4096), + proof(4096), + proof(4096), + proof(4096), + proof(4096), + proof(4096), + proof(4096), + proof(4096), + proof(4096), + proof(4096), + proof(4096), + proof(512), + proof(1024), + proof(1024), + proof(1024), + proof(1024), + proof(1024), + proof(1024), + proof(1024), + proof(1024), + proof(512), + proof(512), + proof(512), + proof(512), + proof(512), + proof(512), + proof(256), + proof(256), + proof(256), + proof(256), + proof(256), + proof(256), + proof(128), + proof(128), + proof(128), + proof(128), + proof(128), + proof(128), + proof(128), + proof(8), + proof(8), + proof(8), + proof(8), + proof(8), + proof(8), + proof(8), + proof(4), + proof(4), + proof(4), + proof(4), + proof(4), + proof(4), + proof(4), + proof(2), + proof(2), + proof(2), + proof(2), + proof(2), + proof(2), + proof(1), + proof(1), + proof(1), + proof(1), + proof(1), + proof(4096), + ]; + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + + let net = total - fee; + + assert!( + net == amount, + "Selected proofs should cover amount after fees" + ); + assert!(fee == Amount::from(selected_proofs.len() as u64)); + assert!(fee > Amount::ZERO); + } + + #[test] + fn test_select_proofs_high_fees_prefers_larger_proofs() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000); + + let mut proofs = Vec::new(); + for _ in 0..100 { + proofs.push(proof(64)); + } + proofs.push(proof(4096)); + proofs.push(proof(1024)); + + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!(net >= amount, "Net amount {} should be >= {}", net, amount); + } + + #[test] + fn test_select_proofs_high_fees_exact_with_fee() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000); + + let proofs = vec![proof(4096), proof(1024)]; + let amount: Amount = 5118.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert_eq!(selected_proofs.len(), 2); + assert_eq!(net, 5118.into(), "5120 - 2 = 5118"); + } + + #[test] + fn test_select_proofs_high_fees_large_proofs() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000); + + let proofs = large_proofs(); + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Large proofs: net {} should be >= {} (total={}, fee={}, num_proofs={})", + net, + amount, + total, + fee, + selected_proofs.len() + ); + } + + // ======================================================================== + // Edge Case Tests + // ======================================================================== + + #[test] + fn test_select_proofs_with_fees_zero_amount() { + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = standard_proofs(); + let amount: Amount = 0.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + assert_eq!( + selected_proofs.len(), + 0, + "Zero amount should return empty selection" + ); + } + + #[test] + fn test_select_proofs_with_fees_empty_proofs() { + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs: Vec = vec![]; + let amount: Amount = 5000.into(); + + let result = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ); + + assert!( + result.is_err(), + "Empty proofs should return InsufficientFunds" + ); + } + + #[test] + fn test_select_proofs_with_fees_all_proofs_same_size() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![ + proof(1024), + proof(1024), + proof(1024), + proof(1024), + proof(1024), + proof(1024), + ]; + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!(net >= amount, "Net {} should be >= {}", net, amount); + } + + #[test] + fn test_select_proofs_with_fees_fee_exceeds_small_proof() { + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(1000); + + let proofs = vec![proof(1)]; + let amount: Amount = 1.into(); + + let result = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ); + + assert!( + result.is_err(), + "1 sat proof with 1 sat fee is uneconomical" + ); + } + + #[test] + fn test_select_proofs_with_fees_barely_sufficient() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![ + proof(4096), + proof(1024), + proof(512), + proof(256), + proof(128), + proof(8), + proof(1), + ]; + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Barely sufficient: net {} should be >= {} (total={}, fee={})", + net, + amount, + total, + fee + ); + } + + // ======================================================================== + // Stress Tests + // ======================================================================== + + #[test] + fn test_select_proofs_many_small_proofs_with_fees() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(100); + + let mut proofs: Vec = (0..500).map(|_| proof(16)).collect(); + proofs.extend((0..200).map(|_| proof(8))); + proofs.extend((0..100).map(|_| proof(4))); + + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Net {} should be >= {} (total={}, fee={}, num_proofs={})", + net, + amount, + total, + fee, + selected_proofs.len() + ); + } + + #[test] + fn test_select_proofs_fee_convergence_with_many_proofs() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(100); + + let proofs: Vec = (0..600).map(|_| proof(16)).collect(); + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Fee convergence should work: net={}, amount={}, total={}, fee={}, proofs={}", + net, + amount, + total, + fee, + selected_proofs.len() + ); + } + + #[test] + fn test_select_proofs_fragmented_proofs_with_fees() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = fragmented_proofs(); + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Fragmented proofs: net {} should be >= {} (total={}, fee={}, num_proofs={})", + net, + amount, + total, + fee, + selected_proofs.len() + ); + } + + // ======================================================================== + // Regression Tests + // ======================================================================== + + #[test] + fn test_regression_swap_insufficient_small_proof() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![ + proof(4096), + proof(1024), + proof(512), + proof(256), + proof(128), + proof(8), + ]; + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Regression: should handle small proofs correctly. Net={}, expected >= {}", + net, + amount + ); + } + + #[test] + fn test_regression_fragmented_proofs_with_fees() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let mut proofs = Vec::new(); + for _ in 0..20 { + proofs.push(proof(1)); + } + for _ in 0..15 { + proofs.push(proof(2)); + } + for _ in 0..12 { + proofs.push(proof(4)); + } + for _ in 0..10 { + proofs.push(proof(8)); + } + for _ in 0..8 { + proofs.push(proof(16)); + } + for _ in 0..6 { + proofs.push(proof(32)); + } + for _ in 0..5 { + proofs.push(proof(64)); + } + for _ in 0..4 { + proofs.push(proof(128)); + } + for _ in 0..3 { + proofs.push(proof(256)); + } + for _ in 0..3 { + proofs.push(proof(512)); + } + for _ in 0..2 { + proofs.push(proof(1024)); + } + for _ in 0..2 { + proofs.push(proof(2048)); + } + + let amount: Amount = 5000.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Fragmented proofs should work: net={}, amount={}", + net, + amount + ); + } + + #[test] + fn test_regression_exact_amount_with_multiple_denominations() { + use cdk_common::nuts::nut00::ProofsMethods; + + use crate::fees::calculate_fee; + + let active_id = id(); + let keyset_fee_and_amounts = keyset_fee_and_amounts_with_fee(200); + + let proofs = vec![ + proof(4096), + proof(1024), + proof(512), + proof(256), + proof(128), + proof(8), + proof(4), + proof(2), + proof(1), + ]; + let amount: Amount = 5007.into(); + + let selected_proofs = Wallet::select_proofs( + amount, + proofs, + &vec![active_id], + &keyset_fee_and_amounts, + true, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + let fee = calculate_fee( + &selected_proofs.count_by_keyset(), + &keyset_fee_and_amounts + .iter() + .map(|(k, v)| (*k, v.fee())) + .collect(), + ) + .unwrap(); + let net = total - fee; + + assert!( + net >= amount, + "Exact amount with multiple denominations: net {} should be >= {} (total={}, fee={})", + net, + amount, + total, + fee + ); + } + + // ======================================================================== + // Inactive Keyset Tests + // ======================================================================== + + fn inactive_id() -> Id { + Id::from_bytes(&[0x00, 1, 1, 1, 1, 1, 1, 1]).unwrap() + } + + fn proof_with_keyset(amount: u64, keyset_id: Id) -> Proof { + Proof::new( + Amount::from(amount), + keyset_id, + Secret::generate(), + PublicKey::from_hex( + "03deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ) + .unwrap(), + ) + } + + #[test] + fn test_select_proofs_inactive_keyset_exact_amount() { + use cdk_common::nuts::nut00::ProofsMethods; + + let inactive = inactive_id(); + let active = id(); + + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + + let proofs = vec![ + proof_with_keyset(1, inactive), + proof_with_keyset(1, inactive), + proof_with_keyset(2, inactive), + proof_with_keyset(4, inactive), + proof_with_keyset(8, inactive), + proof_with_keyset(16, inactive), + ]; + + let selected_proofs = Wallet::select_proofs( + 4.into(), + proofs, + &vec![active], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + assert_eq!( + total, + 4.into(), + "Should select exactly 4 sats worth of proofs from inactive keyset, got {}", + total + ); + } + + #[test] + fn test_select_proofs_inactive_keyset_minimum_over() { + use cdk_common::nuts::nut00::ProofsMethods; + + let inactive = inactive_id(); + let active = id(); + + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active, + (0, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + + let proofs = vec![ + proof_with_keyset(8, inactive), + proof_with_keyset(16, inactive), + proof_with_keyset(32, inactive), + ]; + + let selected_proofs = Wallet::select_proofs( + 5.into(), + proofs, + &vec![active], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + assert_eq!( + total, + 8.into(), + "Should select minimum amount (8) that covers 5 sats, got {}", + total + ); + assert_eq!(selected_proofs.len(), 1, "Should select only 1 proof"); + } + + #[test] + fn test_select_proofs_active_keyset_exact_4_sats_with_fee() { + use cdk_common::nuts::nut00::ProofsMethods; + + let active = id(); + + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active, + (100, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + + let proofs = vec![ + proof(1), + proof(1), + proof(1), + proof(1), + proof(2), + proof(2), + proof(2), + proof(2), + proof(4), + proof(4), + proof(4), + proof(4), + proof(8), + proof(8), + proof(8), + proof(16), + proof(16), + proof(16), + ]; + + let selected_proofs = Wallet::select_proofs( + 4.into(), + proofs, + &vec![active], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + assert_eq!( + total, + 4.into(), + "Should select exactly 4 sats worth of proofs, got {}", + total + ); + assert_eq!( + selected_proofs.len(), + 1, + "Should select only 1 proof (the 4-sat one)" + ); + } + + #[test] + fn test_select_proofs_multiple_keysets_does_not_double_select() { + use cdk_common::nuts::nut00::ProofsMethods; + + let active = id(); + let other_keyset = inactive_id(); + + let mut keyset_fee_and_amounts = HashMap::new(); + keyset_fee_and_amounts.insert( + active, + (100, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + keyset_fee_and_amounts.insert( + other_keyset, + (100, (0..32).map(|x| 2u64.pow(x)).collect::>()).into(), + ); + + let proofs = vec![ + proof(1), + proof(1), + proof(1), + proof(1), + proof(2), + proof(2), + proof(2), + proof(2), + proof(4), + proof(4), + proof(4), + proof(4), + proof(8), + proof(8), + proof(8), + proof(16), + proof(16), + proof(16), + ]; + + let selected_proofs = Wallet::select_proofs( + 4.into(), + proofs, + &vec![active], + &keyset_fee_and_amounts, + false, + ) + .unwrap(); + + let total = selected_proofs.total_amount().unwrap(); + assert_eq!( + total, + 4.into(), + "Should select exactly 4 sats worth even with multiple keysets in fee map, got {}", + total + ); + assert_eq!(selected_proofs.len(), 1, "Should select only 1 proof"); + } } diff --git a/crates/cdk/src/wallet/receive.rs b/crates/cdk/src/wallet/receive.rs index 04afa7283..b53620a3a 100644 --- a/crates/cdk/src/wallet/receive.rs +++ b/crates/cdk/src/wallet/receive.rs @@ -26,23 +26,15 @@ impl Wallet { opts: ReceiveOptions, memo: Option, ) -> Result { - let mint_url = &self.mint_url; - // Add mint if it does not exist in the store - if self - .localstore - .get_mint(self.mint_url.clone()) - .await? - .is_none() - { - tracing::debug!("Mint not in localstore fetching info for: {mint_url}"); - self.get_mint_info().await?; - } + // Incase the wallet is getting ecash for the first time + // we want to get the mint info for our db + let _mint_info = self.load_mint_info().await?; - let _ = self.get_active_mint_keyset().await?; + let mint_url = &self.mint_url; - let active_keyset_id = self.get_active_mint_keyset().await?.id; + let active_keyset_id = self.fetch_active_keyset().await?.id; - let keys = self.get_keyset_keys(active_keyset_id).await?; + let keys = self.load_keyset_keys(active_keyset_id).await?; let mut proofs = proofs; @@ -70,7 +62,7 @@ impl Wallet { for proof in &mut proofs { // Verify that proof DLEQ is valid if proof.dleq.is_some() { - let keys = self.get_keyset_keys(proof.keyset_id).await?; + let keys = self.load_keyset_keys(proof.keyset_id).await?; let key = keys.amount_key(proof.amount).ok_or(Error::AmountKey)?; proof.verify_dleq(key)?; } @@ -138,7 +130,12 @@ impl Wallet { } } - let swap_response = self.client.post_swap(pre_swap.swap_request).await?; + let swap_response = self + .try_proof_operation_or_reclaim( + pre_swap.swap_request.inputs().clone(), + self.client.post_swap(pre_swap.swap_request), + ) + .await?; // Proof to keep let recv_proofs = construct_proofs( @@ -177,6 +174,9 @@ impl Wallet { timestamp: unix_time(), memo, metadata: opts.metadata, + quote_id: None, + payment_request: None, + payment_proof: None, }) .await?; @@ -196,12 +196,12 @@ impl Wallet { /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { - /// let seed = random::<[u8; 32]>(); + /// let seed = random::<[u8; 64]>(); /// let mint_url = "https://fake.thesimplekid.dev"; /// let unit = CurrencyUnit::Sat; /// /// let localstore = memory::empty().await?; - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap(); /// let token = "cashuAeyJ0b2tlbiI6W3sicHJvb2ZzIjpbeyJhbW91bnQiOjEsInNlY3JldCI6ImI0ZjVlNDAxMDJhMzhiYjg3NDNiOTkwMzU5MTU1MGYyZGEzZTQxNWEzMzU0OTUyN2M2MmM5ZDc5MGVmYjM3MDUiLCJDIjoiMDIzYmU1M2U4YzYwNTMwZWVhOWIzOTQzZmRhMWEyY2U3MWM3YjNmMGNmMGRjNmQ4NDZmYTc2NWFhZjc3OWZhODFkIiwiaWQiOiIwMDlhMWYyOTMyNTNlNDFlIn1dLCJtaW50IjoiaHR0cHM6Ly90ZXN0bnV0LmNhc2h1LnNwYWNlIn1dLCJ1bml0Ijoic2F0In0="; /// let amount_receive = wallet.receive(token, ReceiveOptions::default()).await?; /// Ok(()) @@ -249,12 +249,12 @@ impl Wallet { /// /// #[tokio::main] /// async fn main() -> anyhow::Result<()> { - /// let seed = random::<[u8; 32]>(); + /// let seed = random::<[u8; 64]>(); /// let mint_url = "https://fake.thesimplekid.dev"; /// let unit = CurrencyUnit::Sat; /// /// let localstore = memory::empty().await?; - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); + /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), seed, None).unwrap(); /// let token_raw = hex::decode("6372617742a4617481a261694800ad268c4d1f5826617081a3616101617378403961366462623834376264323332626137366462306466313937323136623239643362386363313435353363643237383237666331636339343266656462346561635821038618543ffb6b8695df4ad4babcde92a34a96bdcd97dcee0d7ccf98d4721267926164695468616e6b20796f75616d75687474703a2f2f6c6f63616c686f73743a33333338617563736174").unwrap(); /// let amount_receive = wallet.receive_raw(&token_raw, ReceiveOptions::default()).await?; /// Ok(()) diff --git a/crates/cdk/src/wallet/reclaim.rs b/crates/cdk/src/wallet/reclaim.rs new file mode 100644 index 000000000..8a3e8a75f --- /dev/null +++ b/crates/cdk/src/wallet/reclaim.rs @@ -0,0 +1,125 @@ +use std::collections::HashMap; +use std::future::Future; + +use cdk_common::{CheckStateRequest, ProofsMethods}; +use tracing::instrument; + +use crate::nuts::Proofs; +use crate::{Error, Wallet}; + +#[cfg(not(target_arch = "wasm32"))] +type BoxFuture<'a, T> = futures::future::BoxFuture<'a, T>; + +/// +#[cfg(target_arch = "wasm32")] +type BoxFuture<'a, T> = futures::future::LocalBoxFuture<'a, T>; + +/// MaybeSend +/// +/// Which is Send for most platforms but WASM. +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send {} + +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} + +/// Autoimplement MaybeSend for T +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSend for T {} + +#[cfg(target_arch = "wasm32")] +impl MaybeSend for T {} + +/// Size of proofs to send to avoid hitting the mint limit. +const BATCH_PROOF_SIZE: usize = 100; + +impl Wallet { + /// Synchronizes the states with the mint + #[instrument(skip(self, proofs))] + pub async fn sync_proofs_state(&self, proofs: Proofs) -> Result<(), Error> { + let proof_ys = proofs.ys()?; + + let statuses = self + .client + .post_check_state(CheckStateRequest { ys: proof_ys }) + .await? + .states; + + for (state, unspent) in proofs + .into_iter() + .zip(statuses) + .map(|(p, s)| (s.state, p)) + .fold(HashMap::<_, Vec<_>>::new(), |mut acc, (cat, item)| { + acc.entry(cat).or_default().push(item); + acc + }) + { + self.localstore + .update_proofs_state( + unspent + .iter() + .map(|x| x.y()) + .collect::, _>>()?, + state, + ) + .await?; + } + + Ok(()) + } + + /// Perform an async task, which is assumed to be a foreign mint call that can fail. If fails, + /// the proofs used in the request are synchronize with the mint and update it locally + #[inline(always)] + pub(crate) fn try_proof_operation_or_reclaim<'a, F, R>( + &'a self, + inputs: Proofs, + f: F, + ) -> BoxFuture<'a, F::Output> + where + F: Future> + MaybeSend + 'a, + R: MaybeSend + Sync + 'a, + { + Box::pin(async move { + match f.await { + Ok(r) => Ok(r), + Err(err) => { + tracing::error!( + "Http operation failed with \"{}\", revering {} proofs states to UNSPENT", + err, + inputs.len() + ); + + let swap_reverted_proofs = self + .in_error_swap_reverted_proofs + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + ) + .is_ok(); + + if swap_reverted_proofs { + tracing::error!( + "Attempting to swap exposed {} proofs to new proofs", + inputs.len() + ); + for proofs in inputs.chunks(BATCH_PROOF_SIZE) { + let _ = self.sync_proofs_state(proofs.to_owned()).await.inspect_err( + |err| { + tracing::warn!("Failed to swap exposed proofs ({})", err); + }, + ); + } + + self.in_error_swap_reverted_proofs + .store(false, std::sync::atomic::Ordering::SeqCst); + } + + Err(err) + } + } + }) + } +} diff --git a/crates/cdk/src/wallet/send.rs b/crates/cdk/src/wallet/send.rs index a98547b44..b34a40b5d 100644 --- a/crates/cdk/src/wallet/send.rs +++ b/crates/cdk/src/wallet/send.rs @@ -1,12 +1,15 @@ use std::collections::HashMap; use std::fmt::Debug; +use cdk_common::nut02::KeySetInfosMethods; use cdk_common::util::unix_time; use cdk_common::wallet::{Transaction, TransactionDirection}; +use cdk_common::Id; use tracing::instrument; use super::SendKind; use crate::amount::SplitTarget; +use crate::fees::calculate_fee; use crate::nuts::nut00::ProofsMethods; use crate::nuts::{Proofs, SpendingConditions, State, Token}; use crate::{Amount, Error, Wallet}; @@ -20,7 +23,7 @@ impl Wallet { /// ```no_compile /// let send = wallet.prepare_send(Amount::from(10), SendOptions::default()).await?; /// assert!(send.fee() <= Amount::from(1)); - /// let token = wallet.send(send, None).await?; + /// let token = send.confirm(None).await?; /// ``` #[instrument(skip(self), err)] pub async fn prepare_send( @@ -32,16 +35,13 @@ impl Wallet { // If online send check mint for current keysets fees if opts.send_kind.is_online() { - if let Err(e) = self.get_active_mint_keyset().await { - tracing::error!( - "Error fetching active mint keyset: {:?}. Using stored keysets", - e - ); + if let Err(e) = self.refresh_keysets().await { + tracing::error!("Error refreshing keysets: {:?}. Using stored keysets", e); } } // Get keyset fees from localstore - let keyset_fees = self.get_keyset_fees().await?; + let keyset_fees = self.get_keyset_fees_and_amounts().await?; // Get available proofs matching conditions let mut available_proofs = self @@ -78,13 +78,40 @@ impl Wallet { // Select proofs let active_keyset_ids = self - .get_active_mint_keysets() + .get_mint_keysets() .await? - .into_iter() + .active() .map(|k| k.id) .collect(); + + // When including fees, we need to account for both: + // 1. Input fees (to spend the selected proofs) + // 2. Output fees (send_fee - fee to redeem the token we create) + // + // If proofs don't exactly match the desired denominations, a swap is needed. + // The swap consumes the input fee, and the outputs must cover amount + send_fee. + // So we select proofs for (amount + send_fee) to ensure the swap can succeed. + let active_keyset_id = self.get_active_keyset().await?.id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; + + let selection_amount = if opts.include_fee { + let send_split = amount.split_with_fee(&fee_and_amounts)?; + let send_fee = self + .get_proofs_fee_by_count( + vec![(active_keyset_id, send_split.len() as u64)] + .into_iter() + .collect(), + ) + .await?; + amount + send_fee + } else { + amount + }; + let selected_proofs = Wallet::select_proofs( - amount, + selection_amount, available_proofs, &active_keyset_ids, &keyset_fees, @@ -130,11 +157,13 @@ impl Wallet { force_swap: bool, ) -> Result { // Split amount with fee if necessary + let active_keyset_id = self.get_active_keyset().await?.id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; let (send_amounts, send_fee) = if opts.include_fee { - let active_keyset_id = self.get_active_mint_keyset().await?.id; - let keyset_fee_ppk = self.get_keyset_fees_by_id(active_keyset_id).await?; - tracing::debug!("Keyset fee per proof: {:?}", keyset_fee_ppk); - let send_split = amount.split_with_fee(keyset_fee_ppk)?; + tracing::debug!("Keyset fee per proof: {:?}", fee_and_amounts.fee()); + let send_split = amount.split_with_fee(&fee_and_amounts)?; let send_fee = self .get_proofs_fee_by_count( vec![(active_keyset_id, send_split.len() as u64)] @@ -144,7 +173,7 @@ impl Wallet { .await?; (send_split, send_fee) } else { - let send_split = amount.split(); + let send_split = amount.split(&fee_and_amounts); let send_fee = Amount::ZERO; (send_split, send_fee) }; @@ -162,74 +191,131 @@ impl Wallet { exact_proofs &= proofs.len() <= max_proofs; } - // Split proofs to swap and send - let mut proofs_to_swap = Proofs::new(); - let mut proofs_to_send = Proofs::new(); - if force_swap { - proofs_to_swap = proofs; - } else if exact_proofs || opts.send_kind.is_offline() || opts.send_kind.has_tolerance() { - proofs_to_send = proofs; - } else { - let mut remaining_send_amounts = send_amounts.clone(); - for proof in proofs { - if let Some(idx) = remaining_send_amounts - .iter() - .position(|a| a == &proof.amount) - { - proofs_to_send.push(proof); - remaining_send_amounts.remove(idx); - } else { - proofs_to_swap.push(proof); - } - } - } + // Determine if we should send all proofs directly + let is_exact_or_offline = + exact_proofs || opts.send_kind.is_offline() || opts.send_kind.has_tolerance(); - // Calculate swap fee - let swap_fee = self.get_proofs_fee(&proofs_to_swap).await?; + // Get keyset fees for the split function + let keyset_fees_and_amounts = self.get_keyset_fees_and_amounts().await?; + let keyset_fees: HashMap = keyset_fees_and_amounts + .iter() + .map(|(key, values)| (*key, values.fee())) + .collect(); + + // Split proofs between send and swap + let split_result = split_proofs_for_send( + proofs, + &send_amounts, + amount, + send_fee, + &keyset_fees, + force_swap, + is_exact_or_offline, + )?; // Return prepared send Ok(PreparedSend { + wallet: self.clone(), amount, options: opts, - proofs_to_swap, - swap_fee, - proofs_to_send, + proofs_to_swap: split_result.proofs_to_swap, + swap_fee: split_result.swap_fee, + proofs_to_send: split_result.proofs_to_send, send_fee, }) } +} - /// Finalize A Send Transaction - /// - /// This function finalizes a send transaction by constructing a token the [`PreparedSend`]. - /// See [`Wallet::prepare_send`] for more information. +/// Prepared send +pub struct PreparedSend { + wallet: Wallet, + amount: Amount, + options: SendOptions, + proofs_to_swap: Proofs, + swap_fee: Amount, + proofs_to_send: Proofs, + send_fee: Amount, +} + +impl PreparedSend { + /// Amount + pub fn amount(&self) -> Amount { + self.amount + } + + /// Send options + pub fn options(&self) -> &SendOptions { + &self.options + } + + /// Proofs to swap (i.e., proofs that need to be swapped before constructing the token) + pub fn proofs_to_swap(&self) -> &Proofs { + &self.proofs_to_swap + } + + /// Swap fee + pub fn swap_fee(&self) -> Amount { + self.swap_fee + } + + /// Proofs to send (i.e., proofs that will be included in the token) + pub fn proofs_to_send(&self) -> &Proofs { + &self.proofs_to_send + } + + /// Send fee + pub fn send_fee(&self) -> Amount { + self.send_fee + } + + /// All proofs + pub fn proofs(&self) -> Proofs { + let mut proofs = self.proofs_to_swap.clone(); + proofs.extend(self.proofs_to_send.clone()); + proofs + } + + /// Total fee + pub fn fee(&self) -> Amount { + self.swap_fee + self.send_fee + } + + /// Confirm the prepared send and create a token #[instrument(skip(self), err)] - pub async fn send(&self, send: PreparedSend, memo: Option) -> Result { - tracing::info!("Sending prepared send"); - let total_send_fee = send.fee(); - let mut proofs_to_send = send.proofs_to_send; + pub async fn confirm(self, memo: Option) -> Result { + tracing::info!("Confirming prepared send"); + let total_send_fee = self.fee(); + let mut proofs_to_send = self.proofs_to_send; // Get active keyset ID - let active_keyset_id = self.get_active_mint_keyset().await?.id; + let active_keyset_id = self.wallet.fetch_active_keyset().await?.id; tracing::debug!("Active keyset ID: {:?}", active_keyset_id); // Get keyset fees - let keyset_fee_ppk = self.get_keyset_fees_by_id(active_keyset_id).await?; + let keyset_fee_ppk = self + .wallet + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; tracing::debug!("Keyset fees: {:?}", keyset_fee_ppk); // Calculate total send amount - let total_send_amount = send.amount + send.send_fee; + let total_send_amount = self.amount + self.send_fee; tracing::debug!("Total send amount: {}", total_send_amount); // Swap proofs if necessary - if !send.proofs_to_swap.is_empty() { - let swap_amount = total_send_amount - proofs_to_send.total_amount()?; + if !self.proofs_to_swap.is_empty() { + let swap_amount = total_send_amount + .checked_sub(proofs_to_send.total_amount()?) + .unwrap_or(Amount::ZERO); tracing::debug!("Swapping proofs; swap_amount={:?}", swap_amount); + if let Some(proofs) = self + .wallet .swap( Some(swap_amount), SplitTarget::None, - send.proofs_to_swap, - send.options.conditions.clone(), + self.proofs_to_swap, + self.options.conditions.clone(), false, // already included in swap_amount ) .await? @@ -243,15 +329,16 @@ impl Wallet { ); // Check if sufficient proofs are available - if send.amount > proofs_to_send.total_amount()? { + if self.amount > proofs_to_send.total_amount()? { return Err(Error::InsufficientFunds); } // Check if proofs are reserved or unspent let sendable_proof_ys = self + .wallet .get_proofs_with( Some(vec![State::Reserved, State::Unspent]), - send.options.conditions.clone().map(|c| vec![c]), + self.options.conditions.clone().map(|c| vec![c]), ) .await? .ys()?; @@ -269,45 +356,50 @@ impl Wallet { "Updating proofs state to pending spent: {:?}", proofs_to_send.ys()? ); - self.localstore + self.wallet + .localstore .update_proofs_state(proofs_to_send.ys()?, State::PendingSpent) .await?; // Include token memo - let send_memo = send.options.memo.or(memo); + let send_memo = self.options.memo.or(memo); let memo = send_memo.and_then(|m| if m.include_memo { Some(m.memo) } else { None }); // Add transaction to store - self.localstore + self.wallet + .localstore .add_transaction(Transaction { - mint_url: self.mint_url.clone(), + mint_url: self.wallet.mint_url.clone(), direction: TransactionDirection::Outgoing, - amount: send.amount, + amount: self.amount, fee: total_send_fee, - unit: self.unit.clone(), + unit: self.wallet.unit.clone(), ys: proofs_to_send.ys()?, timestamp: unix_time(), memo: memo.clone(), - metadata: send.options.metadata, + metadata: self.options.metadata, + quote_id: None, + payment_request: None, + payment_proof: None, }) .await?; // Create and return token Ok(Token::new( - self.mint_url.clone(), + self.wallet.mint_url.clone(), proofs_to_send, memo, - self.unit.clone(), + self.wallet.unit.clone(), )) } - /// Cancel prepared send - pub async fn cancel_send(&self, send: PreparedSend) -> Result<(), Error> { + /// Cancel the prepared send + pub async fn cancel(self) -> Result<(), Error> { tracing::info!("Cancelling prepared send"); // Double-check proofs state - let reserved_proofs = self.get_reserved_proofs().await?.ys()?; - if !send + let reserved_proofs = self.wallet.get_reserved_proofs().await?.ys()?; + if !self .proofs() .ys()? .iter() @@ -316,68 +408,15 @@ impl Wallet { return Err(Error::UnexpectedProofState); } - self.localstore - .update_proofs_state(send.proofs().ys()?, State::Unspent) + self.wallet + .localstore + .update_proofs_state(self.proofs().ys()?, State::Unspent) .await?; Ok(()) } } -/// Prepared send -pub struct PreparedSend { - amount: Amount, - options: SendOptions, - proofs_to_swap: Proofs, - swap_fee: Amount, - proofs_to_send: Proofs, - send_fee: Amount, -} - -impl PreparedSend { - /// Amount - pub fn amount(&self) -> Amount { - self.amount - } - - /// Send options - pub fn options(&self) -> &SendOptions { - &self.options - } - - /// Proofs to swap (i.e., proofs that need to be swapped before constructing the token) - pub fn proofs_to_swap(&self) -> &Proofs { - &self.proofs_to_swap - } - - /// Swap fee - pub fn swap_fee(&self) -> Amount { - self.swap_fee - } - - /// Proofs to send (i.e., proofs that will be included in the token) - pub fn proofs_to_send(&self) -> &Proofs { - &self.proofs_to_send - } - - /// Send fee - pub fn send_fee(&self) -> Amount { - self.send_fee - } - - /// All proofs - pub fn proofs(&self) -> Proofs { - let mut proofs = self.proofs_to_swap.clone(); - proofs.extend(self.proofs_to_send.clone()); - proofs - } - - /// Total fee - pub fn fee(&self) -> Amount { - self.swap_fee + self.send_fee - } -} - impl Debug for PreparedSend { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("PreparedSend") @@ -445,3 +484,1211 @@ impl SendMemo { } } } + +/// Result of splitting proofs for a send operation +#[derive(Debug, Clone)] +pub struct ProofSplitResult { + /// Proofs that can be sent directly (matching desired denominations) + pub proofs_to_send: Proofs, + /// Proofs that need to be swapped first + pub proofs_to_swap: Proofs, + /// Fee required for the swap operation + pub swap_fee: Amount, +} + +/// Split proofs between those to send directly and those requiring swap. +/// +/// This is a pure function that implements the core logic of `internal_prepare_send`: +/// 1. Match proofs to desired send amounts +/// 2. Ensure proofs_to_swap can cover swap fees plus needed output +/// 3. Move proofs from send to swap if needed to cover fees +/// +/// # Arguments +/// * `proofs` - All selected proofs to split +/// * `send_amounts` - Desired output denominations +/// * `amount` - Amount to send +/// * `send_fee` - Fee the recipient will pay to redeem +/// * `keyset_fees` - Map of keyset ID to fee_ppk +/// * `force_swap` - If true, all proofs go to swap +/// * `is_exact_or_offline` - If true (exact match or offline mode), all proofs go to send +pub fn split_proofs_for_send( + proofs: Proofs, + send_amounts: &[Amount], + amount: Amount, + send_fee: Amount, + keyset_fees: &HashMap, + force_swap: bool, + is_exact_or_offline: bool, +) -> Result { + let mut proofs_to_swap = Proofs::new(); + let mut proofs_to_send = Proofs::new(); + + if force_swap { + proofs_to_swap = proofs; + } else if is_exact_or_offline { + proofs_to_send = proofs; + } else { + let mut remaining_send_amounts: Vec = send_amounts.to_vec(); + for proof in proofs { + if let Some(idx) = remaining_send_amounts + .iter() + .position(|a| a == &proof.amount) + { + proofs_to_send.push(proof); + remaining_send_amounts.remove(idx); + } else { + proofs_to_swap.push(proof); + } + } + + // Check if swap is actually needed + if !proofs_to_swap.is_empty() { + let swap_output_needed = (amount + send_fee) + .checked_sub(proofs_to_send.total_amount()?) + .unwrap_or(Amount::ZERO); + + if swap_output_needed == Amount::ZERO { + // proofs_to_send already covers the full amount, no swap needed + // Clear proofs_to_swap - these are just leftover proofs that don't match + // any send denomination but aren't needed for the send + proofs_to_swap.clear(); + } else { + // Ensure proofs_to_swap can cover the swap's input fee plus the needed output + loop { + let swap_input_fee = + calculate_fee(&proofs_to_swap.count_by_keyset(), keyset_fees)?; + let swap_total = proofs_to_swap.total_amount()?; + + let swap_can_produce = swap_total.checked_sub(swap_input_fee); + + match swap_can_produce { + Some(can_produce) if can_produce >= swap_output_needed => { + break; + } + _ => { + if proofs_to_send.is_empty() { + return Err(Error::InsufficientFunds); + } + + // Move the smallest proof from send to swap + proofs_to_send.sort_by(|a, b| a.amount.cmp(&b.amount)); + let proof_to_move = proofs_to_send.remove(0); + proofs_to_swap.push(proof_to_move); + } + } + } + } + } + } + + let swap_fee = calculate_fee(&proofs_to_swap.count_by_keyset(), keyset_fees)?; + + Ok(ProofSplitResult { + proofs_to_send, + proofs_to_swap, + swap_fee, + }) +} + +#[cfg(test)] +mod tests { + use cdk_common::secret::Secret; + use cdk_common::{Amount, Id, Proof, PublicKey}; + + use super::*; + + fn id() -> Id { + Id::from_bytes(&[0; 8]).unwrap() + } + + fn proof(amount: u64) -> Proof { + Proof::new( + Amount::from(amount), + id(), + Secret::generate(), + PublicKey::from_hex( + "03deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ) + .unwrap(), + ) + } + + fn proofs(amounts: &[u64]) -> Proofs { + amounts.iter().map(|&a| proof(a)).collect() + } + + fn keyset_fees_with_ppk(fee_ppk: u64) -> HashMap { + let mut fees = HashMap::new(); + fees.insert(id(), fee_ppk); + fees + } + + fn amounts(values: &[u64]) -> Vec { + values.iter().map(|&v| Amount::from(v)).collect() + } + + // ======================================================================== + // No Swap Needed (Exact Proofs) Tests + // ======================================================================== + + #[test] + fn test_split_exact_match_simple() { + let input_proofs = proofs(&[8, 2]); + let send_amounts = amounts(&[8, 2]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(10), + Amount::from(1), + &keyset_fees, + false, + true, // exact match + ) + .unwrap(); + + assert_eq!(result.proofs_to_send.len(), 2); + assert!(result.proofs_to_swap.is_empty()); + assert_eq!(result.swap_fee, Amount::ZERO); + } + + #[test] + fn test_split_exact_match_six_proofs() { + let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 32]); + let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 32]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(4000), + Amount::from(2), + &keyset_fees, + false, + true, + ) + .unwrap(); + + assert_eq!(result.proofs_to_send.len(), 6); + assert!(result.proofs_to_swap.is_empty()); + } + + #[test] + fn test_split_exact_match_ten_proofs() { + let input_proofs = proofs(&[4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8]); + let send_amounts = amounts(&[4096, 2048, 1024, 512, 256, 128, 64, 32, 16, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(8000), + Amount::from(2), + &keyset_fees, + false, + true, + ) + .unwrap(); + + assert_eq!(result.proofs_to_send.len(), 10); + assert!(result.proofs_to_swap.is_empty()); + } + + #[test] + fn test_split_exact_match_powers_of_two() { + let input_proofs = proofs(&[4096, 512, 256, 128, 8]); + let send_amounts = amounts(&[4096, 512, 256, 128, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(5000), + Amount::from(1), + &keyset_fees, + false, + true, + ) + .unwrap(); + + assert_eq!(result.proofs_to_send.len(), 5); + assert!(result.proofs_to_swap.is_empty()); + } + + // ======================================================================== + // Swap Required - Partial Match Tests + // ======================================================================== + + #[test] + fn test_split_single_mismatch() { + let input_proofs = proofs(&[8, 4, 2, 1]); + let send_amounts = amounts(&[8, 2]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(10), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + let swap_amounts_result: Vec = result + .proofs_to_swap + .iter() + .map(|p| p.amount.into()) + .collect(); + + assert!(send_amounts_result.contains(&8)); + assert!(send_amounts_result.contains(&2)); + assert!(swap_amounts_result.contains(&4) || swap_amounts_result.contains(&1)); + } + + #[test] + fn test_split_multiple_mismatches() { + let input_proofs = proofs(&[4096, 1024, 512, 256, 64, 32, 16, 8]); + let send_amounts = amounts(&[4096, 512, 256, 128, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(5000), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + + // 4096, 512, 256, 8 should match; 128 not in input, 1024, 64, 32, 16 to swap + assert!(send_amounts_result.contains(&4096)); + assert!(send_amounts_result.contains(&512)); + assert!(send_amounts_result.contains(&256)); + assert!(send_amounts_result.contains(&8)); + assert!(!result.proofs_to_swap.is_empty()); + } + + #[test] + fn test_split_half_match() { + let input_proofs = proofs(&[2048, 2048, 1024, 512, 256, 128, 64, 32]); + let send_amounts = amounts(&[4096, 512, 256, 128, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(5000), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + + // Only 512, 256, 128 should match (no 4096 or 8 in input) + assert!(send_amounts_result.contains(&512)); + assert!(send_amounts_result.contains(&256)); + assert!(send_amounts_result.contains(&128)); + assert!(!result.proofs_to_swap.is_empty()); + } + + #[test] + fn test_split_large_swap_set() { + let input_proofs = proofs(&[1024, 1024, 1024, 1024, 1024, 512, 256, 128, 64, 32, 16, 8]); + let send_amounts = amounts(&[4096, 512, 256, 128, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(5000), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + + assert!(send_amounts_result.contains(&512)); + assert!(send_amounts_result.contains(&256)); + assert!(send_amounts_result.contains(&128)); + assert!(send_amounts_result.contains(&8)); + // All 1024s and 64, 32, 16 should be in swap + assert!(result.proofs_to_swap.len() >= 5); + } + + #[test] + fn test_split_dense_small_proofs() { + let input_proofs = proofs(&[ + 512, 256, 256, 128, 128, 128, 64, 64, 64, 64, 32, 32, 16, 16, 8, 8, 4, 4, 2, 2, + ]); + let send_amounts = amounts(&[1024, 256, 128, 64, 16, 8, 4]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(1500), + Amount::from(2), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // No 1024 in input, so swap needed + assert!(!result.proofs_to_swap.is_empty()); + // Should have matched some proofs + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + assert!( + send_amounts_result.contains(&256) + || send_amounts_result.contains(&128) + || send_amounts_result.contains(&64) + ); + } + + // ======================================================================== + // Swap Required - No Match Tests + // ======================================================================== + + #[test] + fn test_split_fragmented_no_match() { + // 64×10, 32×5, 16×10, 8×5 = 640 + 160 + 160 + 40 = 1000 + let mut input_amounts = vec![]; + for _ in 0..10 { + input_amounts.push(64); + } + for _ in 0..5 { + input_amounts.push(32); + } + for _ in 0..10 { + input_amounts.push(16); + } + for _ in 0..5 { + input_amounts.push(8); + } + let input_proofs = proofs(&input_amounts); + let send_amounts = amounts(&[512, 256, 128, 64, 32, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(1000), + Amount::from(2), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // Some proofs should match (64, 32, 8 exist in input) + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + // 512, 256, 128 don't exist so need swap + assert!(!result.proofs_to_swap.is_empty()); + // But 64, 32, 8 should be in send + assert!( + send_amounts_result.contains(&64) + || send_amounts_result.contains(&32) + || send_amounts_result.contains(&8) + ); + } + + #[test] + fn test_split_large_fragmented() { + // 256×8, 128×4, 64×8, 32×4, 16×8, 8×4 = 2048 + 512 + 512 + 128 + 128 + 32 = 3360 + let mut input_amounts = vec![]; + for _ in 0..8 { + input_amounts.push(256); + } + for _ in 0..4 { + input_amounts.push(128); + } + for _ in 0..8 { + input_amounts.push(64); + } + for _ in 0..4 { + input_amounts.push(32); + } + for _ in 0..8 { + input_amounts.push(16); + } + for _ in 0..4 { + input_amounts.push(8); + } + let input_proofs = proofs(&input_amounts); + // Total = 8*256 + 4*128 + 8*64 + 4*32 + 8*16 + 4*8 = 2048+512+512+128+128+32 = 3360 + // Use send_amounts that DON'T all exist in input to force swap + let send_amounts = amounts(&[512, 256, 128, 64, 32, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(1000), + Amount::from(2), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // 256, 128, 64, 32, 8 exist in input but 512 doesn't + // proofs_to_send = [256, 128, 64, 32, 8] = 488 + // swap_output_needed = (1000 + 2) - 488 = 514 + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + assert!( + send_amounts_result.contains(&256) + || send_amounts_result.contains(&128) + || send_amounts_result.contains(&32) + ); + // Most proofs need swapping since we need to produce 514 from swap + assert!(result.proofs_to_swap.len() > 10); + } + + // ======================================================================== + // Swap Fee Adjustment Tests + // ======================================================================== + + #[test] + fn test_split_swap_sufficient() { + let input_proofs = proofs(&[4096, 512, 256, 128, 8, 64, 32]); + let send_amounts = amounts(&[4096, 512, 256, 128, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(5000), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // 64, 32 go to swap (96 total), fee = 1, can produce 95 >= 0 needed + let swap_amounts: Vec = result + .proofs_to_swap + .iter() + .map(|p| p.amount.into()) + .collect(); + assert!(swap_amounts.contains(&64) || swap_amounts.contains(&32)); + } + + #[test] + fn test_split_swap_barely_sufficient() { + // Test where proofs_to_send doesn't fully cover amount+fee, requiring swap + let input_proofs = proofs(&[2048, 1024, 256, 128, 32, 16, 8, 4, 2, 1]); + // Note: removed 64 from input, so send_amounts won't fully match + let send_amounts = amounts(&[2048, 1024, 256, 128, 64]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(3520), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // proofs_to_send = [2048, 1024, 256, 128] = 3456 (no 64 in input) + // swap_output_needed = (3520 + 1) - 3456 = 65 + // proofs_to_swap = [32, 16, 8, 4, 2, 1] = 63, fee = 2, can produce 61 < 65 + // So swap needs more proofs moved from send + assert!(!result.proofs_to_swap.is_empty()); + + let swap_total: u64 = result + .proofs_to_swap + .iter() + .map(|p| u64::from(p.amount)) + .sum(); + let swap_fee: u64 = result.swap_fee.into(); + assert!(swap_total - swap_fee >= 65); + } + + #[test] + fn test_split_move_one_proof() { + // Scenario: to_send has [4096, 512, 256, 128, 64, 32], to_swap has [16, 8] + // swap_output_needed = 50, swap can produce 24-1=23 < 50 + // Need to move 32 to swap: 24+32=56, fee=1, can produce 55 >= 50 + let input_proofs = proofs(&[4096, 512, 256, 128, 64, 32, 16, 8]); + let send_amounts = amounts(&[4096, 512, 256, 128, 64, 32]); + let keyset_fees = keyset_fees_with_ppk(200); + + // We need swap to produce 50 sats + // send = 4096+512+256+128+64+32 = 5088, amount+fee = 5088+50 = 5138 + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(5088), + Amount::from(50), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // Should have moved 32 (smallest) from send to swap + let swap_total: u64 = result + .proofs_to_swap + .iter() + .map(|p| u64::from(p.amount)) + .sum(); + // 16 + 8 + 32 = 56, or some variation + assert!(swap_total >= 50); + } + + #[test] + fn test_split_move_multiple_proofs() { + let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 8, 4, 2, 1]); + let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 64]); + let keyset_fees = keyset_fees_with_ppk(200); + + // swap has [8,4,2,1] = 15, need output of 100 + // fee = 1, can produce 14 < 100 + // Need to move proofs + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(4032), + Amount::from(100), + &keyset_fees, + false, + false, + ) + .unwrap(); + + let swap_total: u64 = result + .proofs_to_swap + .iter() + .map(|p| u64::from(p.amount)) + .sum(); + let swap_fee: u64 = result.swap_fee.into(); + // Should have moved enough to cover 100 + assert!(swap_total - swap_fee >= 100); + } + + #[test] + fn test_split_high_fee_many_proofs() { + let input_proofs = proofs(&[1024, 512, 256, 128, 64, 32, 16, 8, 4, 4, 2, 2, 1, 1, 1, 1]); + let send_amounts = amounts(&[1024, 512, 256, 128, 64, 32, 16, 8]); + let keyset_fees = keyset_fees_with_ppk(1000); // 1 sat per proof + + // swap has [4,4,2,2,1,1,1,1] = 16, 8 proofs, fee = 8, can produce 8 + // Need to produce 10 + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(2040), + Amount::from(10), + &keyset_fees, + false, + false, + ) + .unwrap(); + + let swap_total: u64 = result + .proofs_to_swap + .iter() + .map(|p| u64::from(p.amount)) + .sum(); + let swap_fee: u64 = result.swap_fee.into(); + assert!(swap_total - swap_fee >= 10); + } + + #[test] + fn test_split_fee_eats_small_proofs() { + let input_proofs = proofs(&[4096, 512, 256, 128, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]); + let send_amounts = amounts(&[4096, 512, 256, 128]); + let keyset_fees = keyset_fees_with_ppk(1000); // 1 sat per proof + + // swap has 10×1 = 10, fee = 10, can produce 0 + // Need to produce 5 + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(4992), + Amount::from(5), + &keyset_fees, + false, + false, + ) + .unwrap(); + + let swap_total: u64 = result + .proofs_to_swap + .iter() + .map(|p| u64::from(p.amount)) + .sum(); + let swap_fee: u64 = result.swap_fee.into(); + // Must have moved a larger proof (128) to swap + assert!(swap_total - swap_fee >= 5); + assert!(swap_total > 10); // More than just the 1s + } + + #[test] + fn test_split_cascading_fee_increase() { + let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1]); + let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 64]); + let keyset_fees = keyset_fees_with_ppk(500); // 0.5 sat per proof + + // swap has [32,16,8,4,2,1] = 63, 6 proofs, fee = 3, can produce 60 + // Need 80 + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(4032), + Amount::from(80), + &keyset_fees, + false, + false, + ) + .unwrap(); + + let swap_total: u64 = result + .proofs_to_swap + .iter() + .map(|p| u64::from(p.amount)) + .sum(); + let swap_fee: u64 = result.swap_fee.into(); + assert!(swap_total - swap_fee >= 80); + } + + // ======================================================================== + // Complex Scenarios with Many Proofs + // ======================================================================== + + #[test] + fn test_split_20_proofs_mixed() { + // [2048, 1024, 512, 256×2, 128×2, 64×4, 32×4, 16×4] + // Count: 1 + 1 + 1 + 2 + 2 + 4 + 4 + 4 = 19 proofs. Need one more for 20. + let mut input_amounts = vec![2048, 1024, 512]; + input_amounts.extend(vec![256; 2]); + input_amounts.extend(vec![128; 2]); + input_amounts.extend(vec![64; 4]); + input_amounts.extend(vec![32; 4]); + input_amounts.extend(vec![16; 4]); + input_amounts.push(8); // Add one more to make 20 + let input_proofs = proofs(&input_amounts); + // Use send amounts that match proofs in input + let send_amounts = amounts(&[2048, 1024, 512, 256, 128]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(3968), // 2048+1024+512+256+128 = 3968 + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // All send_amounts exist in input + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + // Check some proofs went to send + assert!( + send_amounts_result.contains(&2048) + || send_amounts_result.contains(&1024) + || send_amounts_result.contains(&512) + ); + // Some proofs to swap (the extras) + assert!(!result.proofs_to_swap.is_empty()); + // Total proofs preserved + assert_eq!( + result.proofs_to_send.len() + result.proofs_to_swap.len(), + 20 + ); + } + + #[test] + fn test_split_30_small_proofs() { + // [256×2, 128×4, 64×6, 32×6, 16×6, 8×6] + let mut input_amounts = vec![]; + input_amounts.extend(vec![256; 2]); + input_amounts.extend(vec![128; 4]); + input_amounts.extend(vec![64; 6]); + input_amounts.extend(vec![32; 6]); + input_amounts.extend(vec![16; 6]); + input_amounts.extend(vec![8; 6]); + let input_proofs = proofs(&input_amounts); + let send_amounts = amounts(&[1024, 512, 256, 128, 64, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(2000), + Amount::from(6), // 30 proofs = 6 sat fee @ 200ppk + &keyset_fees, + false, + false, + ) + .unwrap(); + + assert_eq!( + result.proofs_to_send.len() + result.proofs_to_swap.len(), + 30 + ); + } + + #[test] + fn test_split_15_proofs_high_fee() { + // [4096, 1024×2, 512×2, 256×2, 128×2, 64×2, 32×2, 16×2] + let mut input_amounts = vec![4096]; + input_amounts.extend(vec![1024; 2]); + input_amounts.extend(vec![512; 2]); + input_amounts.extend(vec![256; 2]); + input_amounts.extend(vec![128; 2]); + input_amounts.extend(vec![64; 2]); + input_amounts.extend(vec![32; 2]); + input_amounts.extend(vec![16; 2]); + let input_proofs = proofs(&input_amounts); + let send_amounts = amounts(&[4096, 2048, 1024, 512, 256, 64]); + let keyset_fees = keyset_fees_with_ppk(500); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(8000), + Amount::from(8), // 15 proofs = 8 sat fee @ 500ppk + &keyset_fees, + false, + false, + ) + .unwrap(); + + assert_eq!( + result.proofs_to_send.len() + result.proofs_to_swap.len(), + 15 + ); + } + + #[test] + fn test_split_uniform_25_proofs() { + let input_proofs = proofs(&[256; 25]); + let send_amounts = amounts(&[4096, 512, 256, 128, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(5000), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // Only one 256 matches + let send_count = result.proofs_to_send.len(); + let swap_count = result.proofs_to_swap.len(); + assert_eq!(send_count + swap_count, 25); + assert_eq!(send_count, 1); // Only one 256 matches + } + + #[test] + fn test_split_tiered_18_proofs() { + // [4096, 2048, 1024×2, 512×2, 256×4, 128×4, 64×4] + let mut input_amounts = vec![4096, 2048]; + input_amounts.extend(vec![1024; 2]); + input_amounts.extend(vec![512; 2]); + input_amounts.extend(vec![256; 4]); + input_amounts.extend(vec![128; 4]); + input_amounts.extend(vec![64; 4]); + let input_proofs = proofs(&input_amounts); + let send_amounts = amounts(&[8192, 1024, 512, 256, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(10000), + Amount::from(4), // 18 proofs = 4 sat fee @ 200ppk + &keyset_fees, + false, + false, + ) + .unwrap(); + + assert_eq!( + result.proofs_to_send.len() + result.proofs_to_swap.len(), + 18 + ); + } + + #[test] + fn test_split_dust_consolidation() { + // [16×50, 8×50, 4×50, 2×50, 1×50] = 250 proofs + let mut input_amounts = vec![]; + input_amounts.extend(vec![16; 50]); + input_amounts.extend(vec![8; 50]); + input_amounts.extend(vec![4; 50]); + input_amounts.extend(vec![2; 50]); + input_amounts.extend(vec![1; 50]); + let input_proofs = proofs(&input_amounts); + let send_amounts = amounts(&[1024, 256, 128, 64, 16, 8, 4]); + let keyset_fees = keyset_fees_with_ppk(100); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(1500), + Amount::from(25), // 250 proofs = 25 sat fee @ 100ppk + &keyset_fees, + false, + false, + ) + .unwrap(); + + // 16, 8, 4 exist and match + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + assert!( + send_amounts_result.contains(&16) + || send_amounts_result.contains(&8) + || send_amounts_result.contains(&4) + ); + } + + // ======================================================================== + // Force Swap Scenarios + // ======================================================================== + + #[test] + fn test_split_force_swap_8_proofs() { + let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 32, 16]); + let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 32]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(3000), + Amount::from(2), + &keyset_fees, + true, // force_swap + false, + ) + .unwrap(); + + assert!(result.proofs_to_send.is_empty()); + assert_eq!(result.proofs_to_swap.len(), 8); + } + + #[test] + fn test_split_force_swap_15_proofs() { + let mut input_amounts = vec![]; + input_amounts.extend(vec![1024; 5]); + input_amounts.extend(vec![512; 5]); + input_amounts.extend(vec![256; 5]); + let input_proofs = proofs(&input_amounts); + let send_amounts = amounts(&[8000]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(8000), + Amount::from(3), + &keyset_fees, + true, // force_swap + false, + ) + .unwrap(); + + assert!(result.proofs_to_send.is_empty()); + assert_eq!(result.proofs_to_swap.len(), 15); + } + + #[test] + fn test_split_force_swap_fragmented() { + // 64×10, 32×10, 16×10, 8×10 = 40 proofs + let mut input_amounts = vec![]; + input_amounts.extend(vec![64; 10]); + input_amounts.extend(vec![32; 10]); + input_amounts.extend(vec![16; 10]); + input_amounts.extend(vec![8; 10]); + let input_proofs = proofs(&input_amounts); + let send_amounts = amounts(&[2000]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(2000), + Amount::from(8), + &keyset_fees, + true, // force_swap + false, + ) + .unwrap(); + + assert!(result.proofs_to_send.is_empty()); + assert_eq!(result.proofs_to_swap.len(), 40); + } + + // ======================================================================== + // Edge Cases + // ======================================================================== + + #[test] + fn test_split_single_large_proof() { + let input_proofs = proofs(&[8192]); + let send_amounts = amounts(&[4096, 2048, 1024, 512, 256, 64]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(8000), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // 8192 doesn't match any send amount, goes to swap + assert!(result.proofs_to_send.is_empty()); + assert_eq!(result.proofs_to_swap.len(), 1); + } + + #[test] + fn test_split_many_1sat_proofs() { + let input_proofs = proofs(&[1; 100]); + let send_amounts = amounts(&[32, 16, 2]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(50), + Amount::from(20), // 100 proofs = 20 sat fee @ 200ppk + &keyset_fees, + false, + false, + ) + .unwrap(); + + // No proofs match (no 32, 16, or 2 individual proofs) + assert!(result.proofs_to_send.is_empty()); + assert_eq!(result.proofs_to_swap.len(), 100); + } + + #[test] + fn test_split_all_same_denomination() { + let input_proofs = proofs(&[512; 10]); + let send_amounts = amounts(&[4096, 512, 256, 128, 8]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(4000), + Amount::from(2), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // Only one 512 matches + let send_count = result.proofs_to_send.len(); + assert_eq!(send_count, 1); + assert_eq!(result.proofs_to_swap.len(), 9); + } + + #[test] + fn test_split_alternating_sizes() { + let input_proofs = proofs(&[1024, 64, 1024, 64, 1024, 64, 1024, 64]); + let send_amounts = amounts(&[4096, 256, 128]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(4000), + Amount::from(2), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // No proofs match exactly + assert!(result.proofs_to_send.is_empty()); + assert_eq!(result.proofs_to_swap.len(), 8); + } + + #[test] + fn test_split_power_of_two_boundary() { + let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1]); + let send_amounts = amounts(&[2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(4095), + Amount::from(3), // 12 proofs = 3 sat fee @ 200ppk + &keyset_fees, + false, + false, + ) + .unwrap(); + + // All proofs match + assert_eq!(result.proofs_to_send.len(), 12); + assert!(result.proofs_to_swap.is_empty()); + } + + #[test] + fn test_split_just_over_boundary() { + // Total = 2048+1024+512+256+128+64+32+16+8+4+2+1+1 = 4096 + // With an extra proof to give some buffer for fees + let input_proofs = proofs(&[2048, 1024, 512, 256, 128, 64, 32, 16, 8, 4, 2, 1, 1, 64]); + // Total now = 4160 + let send_amounts = amounts(&[2048, 1024, 512, 1]); + let keyset_fees = keyset_fees_with_ppk(200); + + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(3585), // 2048+1024+512+1 = 3585 + Amount::from(3), // 14 proofs = 3 sat fee @ 200ppk + &keyset_fees, + false, + false, + ) + .unwrap(); + + // 2048, 1024, 512, 1 match + let send_amounts_result: Vec = result + .proofs_to_send + .iter() + .map(|p| p.amount.into()) + .collect(); + assert!(send_amounts_result.contains(&1) || send_amounts_result.contains(&2048)); + // Some proofs go to swap + assert!(!result.proofs_to_swap.is_empty()); + // Total proofs preserved + assert_eq!( + result.proofs_to_send.len() + result.proofs_to_swap.len(), + 14 + ); + } + + // ======================================================================== + // Regression Tests + // ======================================================================== + + #[test] + fn test_split_regression_insufficient_swap_fee() { + // Scenario where initial swap proofs can't cover their own fee + let input_proofs = proofs(&[4096, 512, 256, 128, 1, 1]); + let send_amounts = amounts(&[4096, 512, 256, 128]); + let keyset_fees = keyset_fees_with_ppk(1000); // 1 sat per proof + + // swap has [1,1] = 2, fee = 2, can produce 0 + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(4992), + Amount::from(1), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // Should have moved proofs to make swap viable + let swap_total: u64 = result + .proofs_to_swap + .iter() + .map(|p| u64::from(p.amount)) + .sum(); + let swap_fee: u64 = result.swap_fee.into(); + // Must be able to produce at least 1 + assert!(swap_total > swap_fee || result.proofs_to_swap.is_empty()); + } + + #[test] + fn test_split_regression_many_small_in_swap() { + // Many small proofs in swap that individually have high fee overhead + let mut input_amounts = vec![4096, 1024]; + input_amounts.extend(vec![1; 20]); + let input_proofs = proofs(&input_amounts); + let send_amounts = amounts(&[4096, 1024]); + let keyset_fees = keyset_fees_with_ppk(500); + + // swap has 20×1 = 20, fee = 10, can produce 10 + // Need to produce something for change + let result = split_proofs_for_send( + input_proofs, + &send_amounts, + Amount::from(5120), + Amount::from(5), + &keyset_fees, + false, + false, + ) + .unwrap(); + + // Should handle this gracefully + assert!(result.proofs_to_send.len() + result.proofs_to_swap.len() == 22); + } +} diff --git a/crates/cdk/src/wallet/streams/mod.rs b/crates/cdk/src/wallet/streams/mod.rs new file mode 100644 index 000000000..fb7cf80b8 --- /dev/null +++ b/crates/cdk/src/wallet/streams/mod.rs @@ -0,0 +1,124 @@ +//! Wallet waiter APIn +use std::future::Future; +use std::pin::Pin; + +use cdk_common::amount::SplitTarget; +use cdk_common::wallet::{MeltQuote, MintQuote}; +use cdk_common::{PaymentMethod, SpendingConditions}; +use payment::PaymentStream; +use proof::{MultipleMintQuoteProofStream, SingleMintQuoteProofStream}; + +use super::{Wallet, WalletSubscription}; + +pub mod payment; +pub mod proof; +mod wait; + +/// Shared type +type RecvFuture<'a, Ret> = Pin + Send + 'a>>; + +#[allow(private_bounds)] +#[allow(clippy::enum_variant_names)] +enum WaitableEvent { + MeltQuote(Vec), + MintQuote(Vec<(String, PaymentMethod)>), +} + +impl From<&[MeltQuote]> for WaitableEvent { + fn from(events: &[MeltQuote]) -> Self { + WaitableEvent::MeltQuote(events.iter().map(|event| event.id.to_owned()).collect()) + } +} + +impl From<&MeltQuote> for WaitableEvent { + fn from(event: &MeltQuote) -> Self { + WaitableEvent::MeltQuote(vec![event.id.to_owned()]) + } +} + +impl From<&[MintQuote]> for WaitableEvent { + fn from(events: &[MintQuote]) -> Self { + WaitableEvent::MintQuote( + events + .iter() + .map(|event| (event.id.to_owned(), event.payment_method.clone())) + .collect(), + ) + } +} + +impl From<&MintQuote> for WaitableEvent { + fn from(event: &MintQuote) -> Self { + WaitableEvent::MintQuote(vec![(event.id.to_owned(), event.payment_method.clone())]) + } +} + +impl WaitableEvent { + fn into_subscription(self) -> Vec { + match self { + WaitableEvent::MeltQuote(quotes) => { + vec![WalletSubscription::Bolt11MeltQuoteState(quotes)] + } + WaitableEvent::MintQuote(quotes) => { + let (bolt11, bolt12) = quotes.into_iter().fold( + (Vec::new(), Vec::new()), + |mut acc, (quote_id, payment_method)| { + match payment_method { + PaymentMethod::Bolt11 => acc.0.push(quote_id), + PaymentMethod::Bolt12 => acc.1.push(quote_id), + PaymentMethod::Custom(_) => acc.0.push(quote_id), + } + acc + }, + ); + + let mut subscriptions = Vec::new(); + + if !bolt11.is_empty() { + subscriptions.push(WalletSubscription::Bolt11MintQuoteState(bolt11)); + } + + if !bolt12.is_empty() { + subscriptions.push(WalletSubscription::Bolt12MintQuoteState(bolt12)); + } + + subscriptions + } + } + } +} + +impl Wallet { + /// Streams all proofs from a single mint quote + #[inline(always)] + pub fn proof_stream( + &self, + quote: MintQuote, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> SingleMintQuoteProofStream<'_> { + SingleMintQuoteProofStream::new(self, quote, amount_split_target, spending_conditions) + } + + /// Streams all new proofs for a set of mints + #[inline(always)] + pub fn mints_proof_stream( + &self, + quotes: Vec, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> MultipleMintQuoteProofStream<'_> { + MultipleMintQuoteProofStream::new(self, quotes, amount_split_target, spending_conditions) + } + + /// Returns a BoxFuture that will wait for payment on the given event with a timeout check + #[allow(private_bounds)] + pub fn payment_stream(&self, events: T) -> PaymentStream<'_> + where + T: Into, + { + PaymentStream::new(self, events.into().into_subscription()) + } +} +#[cfg(all(feature = "nostr", not(target_arch = "wasm32")))] +pub mod nostr; diff --git a/crates/cdk/src/wallet/streams/nostr.rs b/crates/cdk/src/wallet/streams/nostr.rs new file mode 100644 index 000000000..45dc7d8d9 --- /dev/null +++ b/crates/cdk/src/wallet/streams/nostr.rs @@ -0,0 +1,207 @@ +//! Nostr payment event stream +//! +//! This stream exposes incoming Nostr payment messages as a standard `Stream>` +//! so callers can `select!`/`next().await`, cancel via `CancellationToken`, or combine with other streams. + +use std::task::Poll; + +use cdk_common::PaymentRequestPayload; +use futures::{FutureExt, Stream}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use crate::error::Error; +use crate::wallet::streams::RecvFuture; + +#[allow(clippy::type_complexity)] +pub struct NostrPaymentEventStream { + cancel: CancellationToken, + // Internal channel receiver for parsed payloads + rx: Option>>, + // A future that initializes the client + subscription and spawns the notification pump + init_fut: Option>>, + // Future to detect external cancellation + cancel_fut: Option>, + // Future awaiting the next item from `rx` + rx_future: Option< + RecvFuture< + 'static, + ( + Option>, + mpsc::Receiver>, + ), + >, + >, +} + +impl NostrPaymentEventStream { + pub fn new(keys: nostr_sdk::Keys, relays: Vec, pubkey: nostr_sdk::PublicKey) -> Self { + let cancel = CancellationToken::new(); + let (tx, rx) = mpsc::channel::>(32); + + let init_cancel = cancel.clone(); + let init_fut = Box::pin(async move { + let client = nostr_sdk::Client::new(keys); + + for r in &relays { + client + .add_read_relay(r.clone()) + .await + .map_err(|e| Error::Custom(format!("Add relay {r}: {e}")))?; + } + + client.connect().await; + + // Subscribe to events addressed to `pubkey` + let filter = nostr_sdk::Filter::new().pubkey(pubkey); + client + .subscribe(filter, None) + .await + .map_err(|e| Error::Custom(format!("Subscribe: {e}")))?; + + let client_for_handler = client.clone(); + // Pump notifications in a background task into the channel until cancelled + let _bg = tokio::spawn(async move { + // Use handle_notifications to avoid manually wiring broadcast receivers + let tx_err = tx.clone(); + let res = client + .handle_notifications(move |notification| { + let tx = tx.clone(); + let client = client_for_handler.clone(); + let cancel = init_cancel.clone(); + async move { + if cancel.is_cancelled() { + return Ok(true); + } + if let nostr_sdk::RelayPoolNotification::Event { event, .. } = + notification + { + match client.unwrap_gift_wrap(&event).await { + Ok(unwrapped) => { + let rumor = unwrapped.rumor; + match serde_json::from_str::( + &rumor.content, + ) { + Ok(payload) => { + // Best-effort send; if receiver closed, instruct exit + if tx.send(Ok(payload)).await.is_err() { + return Ok(true); + } + } + Err(e) => { + let _ = tx + .send(Err(Error::Custom(format!( + "Invalid payload JSON: {e}" + )))) + .await; + } + } + } + Err(e) => { + let _ = tx + .send(Err(Error::Custom(format!( + "Unwrap gift wrap failed: {e}" + )))) + .await; + } + } + } + Ok(false) + } + }) + .await; + + if let Err(e) = res { + let _ = tx_err + .send(Err(Error::Custom(format!( + "Notification handler error: {e}" + )))) + .await; + } + }); + + Ok(()) + }); + + Self { + cancel, + rx: Some(rx), + init_fut: Some(init_fut), + cancel_fut: None, + rx_future: None, + } + } + + pub fn cancel_token(&self) -> CancellationToken { + self.cancel.clone() + } +} + +impl Stream for NostrPaymentEventStream { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + let this = self.get_mut(); + + // Check external cancellation + if this.cancel_fut.is_none() { + let cancel = this.cancel.clone(); + this.cancel_fut = Some(Box::pin(async move { cancel.cancelled().await })); + } + if let Some(mut fut) = this.cancel_fut.take() { + if fut.poll_unpin(cx).is_ready() { + // Drop receiver to end the stream + this.rx.take(); + return Poll::Ready(None); + } + this.cancel_fut = Some(fut); + } + + // Drive initialization + if let Some(mut init) = this.init_fut.take() { + match init.poll_unpin(cx) { + Poll::Pending => { + this.init_fut = Some(init); + return Poll::Pending; + } + Poll::Ready(Err(e)) => { + return Poll::Ready(Some(Err(e))); + } + Poll::Ready(Ok(())) => { + // fallthrough + } + } + } + + // Drive next item from the internal channel + if this.rx.is_none() { + return Poll::Ready(None); + } + + if this.rx_future.is_none() { + let mut rx = this.rx.take().expect("receiver"); + this.rx_future = Some(Box::pin(async move { + let item = rx.recv().await; + (item, rx) + })); + } + + let mut fut = this.rx_future.take().ok_or(Error::Internal)?; + match fut.poll_unpin(cx) { + Poll::Pending => { + this.rx_future = Some(fut); + Poll::Pending + } + Poll::Ready((item, rx)) => { + this.rx = Some(rx); + match item { + None => Poll::Ready(None), + Some(item) => Poll::Ready(Some(item)), + } + } + } + } +} diff --git a/crates/cdk/src/wallet/streams/payment.rs b/crates/cdk/src/wallet/streams/payment.rs new file mode 100644 index 000000000..354e0611c --- /dev/null +++ b/crates/cdk/src/wallet/streams/payment.rs @@ -0,0 +1,206 @@ +//! Payment Stream +//! +//! This future Stream will wait events for a Mint Quote be paid. If it is for a Bolt12 it will not stop +//! but it will eventually error on a Timeout. +//! +//! Bolt11 will emit a single event. +use std::task::Poll; + +use cdk_common::{Amount, Error, MeltQuoteState, MintQuoteState, NotificationPayload}; +use futures::future::join_all; +use futures::stream::FuturesUnordered; +use futures::{FutureExt, Stream, StreamExt}; +use tokio_util::sync::CancellationToken; + +use super::RecvFuture; +use crate::event::MintEvent; +use crate::wallet::subscription::ActiveSubscription; +use crate::{Wallet, WalletSubscription}; + +type SubscribeReceived = (Option>, Vec); +type PaymentValue = (String, Option); + +/// PaymentWaiter +pub struct PaymentStream<'a> { + wallet: Option<(&'a Wallet, Vec)>, + is_finalized: bool, + active_subscription: Option>, + + cancel_token: CancellationToken, + + // Future events + subscriber_future: Option>>, + subscription_receiver_future: Option>, + cancellation_future: Option>, +} + +impl<'a> PaymentStream<'a> { + /// Creates a new instance of the + pub fn new(wallet: &'a Wallet, filters: Vec) -> Self { + Self { + wallet: Some((wallet, filters)), + is_finalized: false, + active_subscription: None, + cancel_token: Default::default(), + subscriber_future: None, + subscription_receiver_future: None, + cancellation_future: None, + } + } + + /// Get cancellation token + pub fn get_cancel_token(&self) -> CancellationToken { + self.cancel_token.clone() + } + + /// Creating a wallet subscription is an async event, this may change in the future, but for now, + /// creating a new Subscription should be polled, as any other async event. This function will + /// return None if the subscription is already active, Some(()) otherwise + fn poll_init_subscription(&mut self, cx: &mut std::task::Context<'_>) -> Option<()> { + if let Some((wallet, filters)) = self.wallet.take() { + self.subscriber_future = Some(Box::pin(async move { + join_all(filters.into_iter().map(|w| wallet.subscribe(w))).await + })); + } + + let mut subscriber_future = self.subscriber_future.take()?; + + match subscriber_future.poll_unpin(cx) { + Poll::Pending => { + self.subscriber_future = Some(subscriber_future); + Some(()) + } + Poll::Ready(active_subscription) => { + self.active_subscription = Some(active_subscription); + None + } + } + } + + /// Checks if the stream has been externally cancelled + fn poll_cancel(&mut self, cx: &mut std::task::Context<'_>) -> bool { + let mut cancellation_future = self.cancellation_future.take().unwrap_or_else(|| { + let cancel_token = self.cancel_token.clone(); + Box::pin(async move { cancel_token.cancelled().await }) + }); + + if cancellation_future.poll_unpin(cx).is_ready() { + self.subscription_receiver_future = None; + true + } else { + self.cancellation_future = Some(cancellation_future); + false + } + } + + /// Polls the subscription for any new event + fn poll_event( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> Poll>> { + let (subscription_receiver_future, active_subscription) = ( + self.subscription_receiver_future.take(), + self.active_subscription.take(), + ); + + if subscription_receiver_future.is_none() && active_subscription.is_none() { + // Unexpected state, we should have an in-flight future or the active_subscription to + // create the future to read an event + return Poll::Ready(Some(Err(Error::Internal))); + } + + let mut receiver = subscription_receiver_future.unwrap_or_else(|| { + let mut subscription_receiver = + active_subscription.expect("active subscription object"); + + Box::pin(async move { + let mut futures: FuturesUnordered<_> = subscription_receiver + .iter_mut() + .map(|sub| sub.recv()) + .collect(); + + if let Some(Some(winner)) = futures.next().await { + drop(futures); + return (Some(winner), subscription_receiver); + } + + drop(futures); + (None, subscription_receiver) + }) + }); + + match receiver.poll_unpin(cx) { + Poll::Pending => { + self.subscription_receiver_future = Some(receiver); + Poll::Pending + } + Poll::Ready((notification, subscription)) => { + tracing::debug!("Receive payment notification {:?}", notification); + // This future is now fulfilled, put the active_subscription again back to object. Next time next().await is called, + // the future will be created in subscription_receiver_future. + self.active_subscription = Some(subscription); + self.cancellation_future = None; // resets timeout + match notification { + None => { + self.is_finalized = true; + Poll::Ready(None) + } + Some(info) => { + match info.into_inner() { + NotificationPayload::MintQuoteBolt11Response(info) => { + if info.state == MintQuoteState::Paid { + self.is_finalized = true; + return Poll::Ready(Some(Ok((info.quote, None)))); + } + } + NotificationPayload::MintQuoteBolt12Response(info) => { + let to_be_issued = info.amount_paid - info.amount_issued; + if to_be_issued > Amount::ZERO { + return Poll::Ready(Some(Ok((info.quote, Some(to_be_issued))))); + } + } + NotificationPayload::MeltQuoteBolt11Response(info) => { + if info.state == MeltQuoteState::Paid { + self.is_finalized = true; + return Poll::Ready(Some(Ok((info.quote, None)))); + } + } + _ => {} + } + + // We got an event but it is not what was expected, we need to call `recv` + // again, and to copy-paste this is a recursive call that should be resolved + // to a Poll::Pending *but* will trigger the future execution + self.poll_event(cx) + } + } + } + } + } +} + +impl Stream for PaymentStream<'_> { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + + if this.is_finalized { + // end of stream + return Poll::Ready(None); + } + + if this.poll_cancel(cx) { + return Poll::Ready(None); + } + + if this.poll_init_subscription(cx).is_some() { + return Poll::Pending; + } + + this.poll_event(cx) + } +} diff --git a/crates/cdk/src/wallet/streams/proof.rs b/crates/cdk/src/wallet/streams/proof.rs new file mode 100644 index 000000000..a95472287 --- /dev/null +++ b/crates/cdk/src/wallet/streams/proof.rs @@ -0,0 +1,185 @@ +//! Mint Stream +//! +//! This will mint after a mint quote has been paid. If the quote is for a Bolt12 it will keep minting until a timeout is reached. +//! +//! Bolt11 will mint once + +use std::collections::HashMap; +use std::task::Poll; + +use cdk_common::amount::SplitTarget; +use cdk_common::wallet::MintQuote; +use cdk_common::{Error, PaymentMethod, Proofs, SpendingConditions}; +use futures::{FutureExt, Stream, StreamExt}; +use tokio_util::sync::CancellationToken; + +use super::payment::PaymentStream; +use super::{RecvFuture, WaitableEvent}; +use crate::Wallet; + +/// Proofs for many mint quotes, as they are minted, in streams +pub struct MultipleMintQuoteProofStream<'a> { + payment_stream: PaymentStream<'a>, + wallet: &'a Wallet, + quotes: HashMap, + amount_split_target: SplitTarget, + spending_conditions: Option, + minting_future: Option>>, +} + +impl<'a> MultipleMintQuoteProofStream<'a> { + /// Create a new Stream + pub fn new( + wallet: &'a Wallet, + quotes: Vec, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> Self { + let filter: WaitableEvent = quotes.as_slice().into(); + + Self { + payment_stream: PaymentStream::new(wallet, filter.into_subscription()), + wallet, + amount_split_target, + spending_conditions, + quotes: quotes + .into_iter() + .map(|mint_quote| (mint_quote.id.clone(), mint_quote)) + .collect(), + minting_future: None, + } + } + + /// Get cancellation token + pub fn get_cancel_token(&self) -> CancellationToken { + self.payment_stream.get_cancel_token() + } +} + +impl Stream for MultipleMintQuoteProofStream<'_> { + type Item = Result<(MintQuote, Proofs), Error>; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + + if let Some(mut minting_future) = this.minting_future.take() { + return match minting_future.poll_unpin(cx) { + Poll::Pending => { + this.minting_future = Some(minting_future); + Poll::Pending + } + Poll::Ready(proofs) => Poll::Ready(Some(proofs)), + }; + } + + match this.payment_stream.poll_next_unpin(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(result) => match result { + None => Poll::Ready(None), + Some(result) => { + let (quote_id, amount) = match result { + Err(err) => { + tracing::error!( + "Error while waiting for payment for {:?}", + this.quotes.keys().collect::>() + ); + return Poll::Ready(Some(Err(err))); + } + Ok(amount) => amount, + }; + + let mint_quote = if let Some(quote) = this.quotes.get("e_id) { + quote.clone() + } else { + tracing::error!("Cannot find mint_quote {} internally", quote_id); + return Poll::Ready(Some(Err(Error::UnknownQuote))); + }; + + let amount_split_target = this.amount_split_target.clone(); + let spending_conditions = this.spending_conditions.clone(); + let wallet = this.wallet; + + tracing::debug!( + "Received payment ({:?}) notification for {}. Minting...", + amount, + mint_quote.id + ); + + let mut minting_future = Box::pin(async move { + match mint_quote.payment_method { + PaymentMethod::Bolt11 => wallet + .mint(&mint_quote.id, amount_split_target, spending_conditions) + .await + .map(|proofs| (mint_quote, proofs)), + PaymentMethod::Bolt12 => wallet + .mint_bolt12( + &mint_quote.id, + amount, + amount_split_target, + spending_conditions, + ) + .await + .map(|proofs| (mint_quote, proofs)), + _ => Err(Error::UnsupportedPaymentMethod), + } + }); + + match minting_future.poll_unpin(cx) { + Poll::Pending => { + this.minting_future = Some(minting_future); + Poll::Pending + } + Poll::Ready(result) => Poll::Ready(Some(result)), + } + } + }, + } + } +} + +/// Proofs for a single mint quote +pub struct SingleMintQuoteProofStream<'a>(MultipleMintQuoteProofStream<'a>); + +impl<'a> SingleMintQuoteProofStream<'a> { + /// Create a new Stream + pub fn new( + wallet: &'a Wallet, + quote: MintQuote, + amount_split_target: SplitTarget, + spending_conditions: Option, + ) -> Self { + Self(MultipleMintQuoteProofStream::new( + wallet, + vec![quote], + amount_split_target, + spending_conditions, + )) + } + + /// Get cancellation token + pub fn get_cancel_token(&self) -> CancellationToken { + self.0.payment_stream.get_cancel_token() + } +} + +impl Stream for SingleMintQuoteProofStream<'_> { + type Item = Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.get_mut(); + match this.0.poll_next_unpin(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(result) => match result { + None => Poll::Ready(None), + Some(Err(err)) => Poll::Ready(Some(Err(err))), + Some(Ok((_, proofs))) => Poll::Ready(Some(Ok(proofs))), + }, + } + } +} diff --git a/crates/cdk/src/wallet/streams/wait.rs b/crates/cdk/src/wallet/streams/wait.rs new file mode 100644 index 000000000..61f7e332e --- /dev/null +++ b/crates/cdk/src/wallet/streams/wait.rs @@ -0,0 +1,50 @@ +use cdk_common::amount::SplitTarget; +use cdk_common::wallet::MintQuote; +use cdk_common::{Amount, Error, Proofs, SpendingConditions}; +use futures::future::BoxFuture; +use futures::StreamExt; +use tokio::time::{timeout, Duration}; + +use super::Wallet; + +impl Wallet { + #[inline(always)] + /// Mints a mint quote once it is paid + pub async fn wait_and_mint_quote( + &self, + quote: MintQuote, + amount_split_target: SplitTarget, + spending_conditions: Option, + timeout_duration: Duration, + ) -> Result { + let mut stream = self.proof_stream(quote, amount_split_target, spending_conditions); + + timeout(timeout_duration, async move { + stream.next().await.ok_or(Error::Internal)? + }) + .await + .map_err(|_| Error::Timeout)? + } + + /// Returns a BoxFuture that will wait for payment on the given event with a timeout check + #[allow(private_bounds)] + pub fn wait_for_payment( + &self, + event: &MintQuote, + timeout_duration: Duration, + ) -> BoxFuture<'_, Result, Error>> { + let mut stream = self.payment_stream(event); + + Box::pin(async move { + timeout(timeout_duration, async { + stream + .next() + .await + .ok_or(Error::Internal)? + .map(|(_quote, amount)| amount) + }) + .await + .map_err(|_| Error::Timeout)? + }) + } +} diff --git a/crates/cdk/src/wallet/subscription/http.rs b/crates/cdk/src/wallet/subscription/http.rs deleted file mode 100644 index 65a284802..000000000 --- a/crates/cdk/src/wallet/subscription/http.rs +++ /dev/null @@ -1,154 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; - -use tokio::sync::{mpsc, RwLock}; -use tokio::time; - -use super::WsSubscriptionBody; -use crate::nuts::nut17::Kind; -use crate::nuts::{nut01, nut05, nut07, nut23, CheckStateRequest, NotificationPayload}; -use crate::pub_sub::SubId; -use crate::wallet::MintConnector; -use crate::Wallet; - -#[derive(Debug, Hash, PartialEq, Eq)] -enum UrlType { - Mint(String), - Melt(String), - PublicKey(nut01::PublicKey), -} - -#[derive(Debug, Eq, PartialEq)] -enum AnyState { - MintQuoteState(nut23::QuoteState), - MeltQuoteState(nut05::QuoteState), - PublicKey(nut07::State), - Empty, -} - -type SubscribedTo = HashMap>, SubId, AnyState)>; - -async fn convert_subscription( - sub_id: SubId, - subscriptions: &Arc>>, - subscribed_to: &mut SubscribedTo, -) -> Option<()> { - let subscription = subscriptions.read().await; - let sub = subscription.get(&sub_id)?; - tracing::debug!("New subscription: {:?}", sub); - match sub.1.kind { - Kind::Bolt11MintQuote => { - for id in sub.1.filters.iter().map(|id| UrlType::Mint(id.clone())) { - subscribed_to.insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty)); - } - } - Kind::Bolt11MeltQuote => { - for id in sub.1.filters.iter().map(|id| UrlType::Melt(id.clone())) { - subscribed_to.insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty)); - } - } - Kind::ProofState => { - for id in sub - .1 - .filters - .iter() - .map(|id| nut01::PublicKey::from_hex(id).map(UrlType::PublicKey)) - { - match id { - Ok(id) => { - subscribed_to - .insert(id, (sub.0.clone(), sub.1.id.clone(), AnyState::Empty)); - } - Err(err) => { - tracing::error!("Error parsing public key: {:?}. Subscription ignored, will never yield any result", err); - } - } - } - } - } - - Some(()) -} - -#[inline] -pub async fn http_main>( - initial_state: S, - http_client: Arc, - subscriptions: Arc>>, - mut new_subscription_recv: mpsc::Receiver, - mut on_drop: mpsc::Receiver, - _wallet: Arc, -) { - let mut interval = time::interval(Duration::from_secs(2)); - let mut subscribed_to = HashMap::, _, AnyState)>::new(); - - for sub_id in initial_state { - convert_subscription(sub_id, &subscriptions, &mut subscribed_to).await; - } - - loop { - tokio::select! { - _ = interval.tick() => { - for (url, (sender, _, last_state)) in subscribed_to.iter_mut() { - tracing::debug!("Polling: {:?}", url); - match url { - UrlType::Mint(id) => { - - let response = http_client.get_mint_quote_status(id).await; - if let Ok(response) = response { - if *last_state == AnyState::MintQuoteState(response.state) { - continue; - } - *last_state = AnyState::MintQuoteState(response.state); - if let Err(err) = sender.try_send(NotificationPayload::MintQuoteBolt11Response(response)) { - tracing::error!("Error sending mint quote response: {:?}", err); - } - } - } - UrlType::Melt(id) => { - - let response = http_client.get_melt_quote_status(id).await; - if let Ok(response) = response { - if *last_state == AnyState::MeltQuoteState(response.state) { - continue; - } - *last_state = AnyState::MeltQuoteState(response.state); - if let Err(err) = sender.try_send(NotificationPayload::MeltQuoteBolt11Response(response)) { - tracing::error!("Error sending melt quote response: {:?}", err); - } - } - } - UrlType::PublicKey(id) => { - let responses = http_client.post_check_state(CheckStateRequest { - ys: vec![*id], - } - ).await; - if let Ok(mut responses) = responses { - let response = if let Some(state) = responses.states.pop() { - state - } else { - continue; - }; - - if *last_state == AnyState::PublicKey(response.state) { - continue; - } - *last_state = AnyState::PublicKey(response.state); - if let Err(err) = sender.try_send(NotificationPayload::ProofState(response)) { - tracing::error!("Error sending proof state response: {:?}", err); - } - } - } - } - } - } - Some(subid) = new_subscription_recv.recv() => { - convert_subscription(subid, &subscriptions, &mut subscribed_to).await; - } - Some(id) = on_drop.recv() => { - subscribed_to.retain(|_, (_, sub_id, _)| *sub_id != id); - } - } - } -} diff --git a/crates/cdk/src/wallet/subscription/mod.rs b/crates/cdk/src/wallet/subscription/mod.rs index 6acaed442..a367b05de 100644 --- a/crates/cdk/src/wallet/subscription/mod.rs +++ b/crates/cdk/src/wallet/subscription/mod.rs @@ -7,27 +7,33 @@ //! the HTTP client. use std::collections::HashMap; use std::fmt::Debug; +use std::sync::atomic::AtomicUsize; use std::sync::Arc; -use cdk_common::subscription::Params; -use tokio::sync::{mpsc, RwLock}; -use tokio::task::JoinHandle; -use tracing::error; - -use super::Wallet; +use cdk_common::nut17::ws::{WsMethodRequest, WsRequest, WsUnsubscribeRequest}; +use cdk_common::nut17::{Kind, NotificationId}; +use cdk_common::parking_lot::RwLock; +use cdk_common::pub_sub::remote_consumer::{ + Consumer, InternalRelay, RemoteActiveConsumer, StreamCtrl, SubscribeMessage, Transport, +}; +use cdk_common::pub_sub::{Error as PubsubError, Spec, Subscriber}; +use cdk_common::subscription::WalletParams; +use cdk_common::CheckStateRequest; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::event::MintEvent; use crate::mint_url::MintUrl; -use crate::pub_sub::SubId; use crate::wallet::MintConnector; -mod http; -#[cfg(all( - not(feature = "http_subscription"), - feature = "mint", - not(target_arch = "wasm32") -))] +#[cfg(not(target_arch = "wasm32"))] mod ws; -type WsSubscriptionBody = (mpsc::Sender, Params); +/// Notification Payload +pub type NotificationPayload = crate::nuts::NotificationPayload; + +/// Type alias +pub type ActiveSubscription = RemoteActiveConsumer; /// Subscription manager /// @@ -44,298 +50,267 @@ type WsSubscriptionBody = (mpsc::Sender, Params); /// The subscribers have a simple-to-use interface, receiving an /// ActiveSubscription struct, which can be used to receive updates and to /// unsubscribe from updates automatically on the drop. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct SubscriptionManager { - all_connections: Arc>>, + all_connections: Arc>>>>, http_client: Arc, + prefer_http: bool, +} + +impl Debug for SubscriptionManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Subscription Manager connected to {:?}", + self.all_connections + .write() + .keys() + .cloned() + .collect::>() + ) + } } impl SubscriptionManager { /// Create a new subscription manager - pub fn new(http_client: Arc) -> Self { + pub fn new(http_client: Arc, prefer_http: bool) -> Self { Self { all_connections: Arc::new(RwLock::new(HashMap::new())), http_client, + prefer_http, } } /// Subscribe to updates from a mint server with a given filter - pub async fn subscribe( + pub fn subscribe( &self, mint_url: MintUrl, - filter: Params, - wallet: Arc, - ) -> ActiveSubscription { - let subscription_clients = self.all_connections.read().await; - let id = filter.id.clone(); - if let Some(subscription_client) = subscription_clients.get(&mint_url) { - let (on_drop_notif, receiver) = subscription_client.subscribe(filter).await; - ActiveSubscription::new(receiver, id, on_drop_notif) - } else { - drop(subscription_clients); - - #[cfg(all( - not(feature = "http_subscription"), - feature = "mint", - not(target_arch = "wasm32") - ))] - let is_ws_support = self - .http_client - .get_mint_info() - .await - .map(|info| !info.nuts.nut17.supported.is_empty()) - .unwrap_or_default(); - - #[cfg(any( - feature = "http_subscription", - not(feature = "mint"), - target_arch = "wasm32" - ))] - let is_ws_support = false; - - tracing::debug!( - "Connect to {:?} to subscribe. WebSocket is supported ({})", - mint_url, - is_ws_support - ); - - let mut subscription_clients = self.all_connections.write().await; - let subscription_client = SubscriptionClient::new( - mint_url.clone(), - self.http_client.clone(), - is_ws_support, - wallet, - ); - let (on_drop_notif, receiver) = subscription_client.subscribe(filter).await; - subscription_clients.insert(mint_url, subscription_client); - - ActiveSubscription::new(receiver, id, on_drop_notif) - } + filter: WalletParams, + ) -> Result, PubsubError> { + self.all_connections + .write() + .entry(mint_url.clone()) + .or_insert_with(|| { + Consumer::new( + SubscriptionClient { + mint_url, + http_client: self.http_client.clone(), + req_id: 0.into(), + }, + self.prefer_http, + (), + ) + }) + .subscribe(filter) } } -/// Subscription client -/// -/// If the server supports WebSocket subscriptions, this client will be used, -/// otherwise the HTTP pool and pause will be used (which is the less efficient -/// method). -#[derive(Debug)] -pub struct SubscriptionClient { - new_subscription_notif: mpsc::Sender, - on_drop_notif: mpsc::Sender, - subscriptions: Arc>>, - worker: Option>, -} +/// MintSubTopics +#[derive(Clone, Default)] +pub struct MintSubTopics {} -type NotificationPayload = crate::nuts::NotificationPayload; +#[async_trait::async_trait] +impl Spec for MintSubTopics { + type SubscriptionId = String; -/// Active Subscription -pub struct ActiveSubscription { - sub_id: Option, - on_drop_notif: mpsc::Sender, - receiver: mpsc::Receiver, -} + type Event = MintEvent; -impl ActiveSubscription { - fn new( - receiver: mpsc::Receiver, - sub_id: SubId, - on_drop_notif: mpsc::Sender, - ) -> Self { - Self { - sub_id: Some(sub_id), - on_drop_notif, - receiver, - } - } + type Topic = NotificationId; - /// Try to receive a notification - pub fn try_recv(&mut self) -> Result, Error> { - match self.receiver.try_recv() { - Ok(payload) => Ok(Some(payload)), - Err(mpsc::error::TryRecvError::Empty) => Ok(None), - Err(mpsc::error::TryRecvError::Disconnected) => Err(Error::Disconnected), - } - } + type Context = (); - /// Receive a notification asynchronously - pub async fn recv(&mut self) -> Option { - self.receiver.recv().await + fn new_instance(_context: Self::Context) -> Arc + where + Self: Sized, + { + Arc::new(Self {}) } -} -impl Drop for ActiveSubscription { - fn drop(&mut self) { - if let Some(sub_id) = self.sub_id.take() { - let _ = self.on_drop_notif.try_send(sub_id); - } + async fn fetch_events(self: &Arc, _topics: Vec, _reply_to: Subscriber) + where + Self: Sized, + { } } -/// Subscription client error -#[derive(thiserror::Error, Debug)] -pub enum Error { - /// Url error - #[error("Could not join paths: {0}")] - Url(#[from] crate::mint_url::Error), - /// Disconnected from the notification channel - #[error("Disconnected from the notification channel")] - Disconnected, +/// Subscription client +/// +/// If the server supports WebSocket subscriptions, this client will be used, +/// otherwise the HTTP pool and pause will be used (which is the less efficient +/// method). +#[derive(Debug)] +#[allow(dead_code)] +pub struct SubscriptionClient { + http_client: Arc, + mint_url: MintUrl, + req_id: AtomicUsize, } +#[allow(dead_code)] impl SubscriptionClient { - /// Create new [`SubscriptionClient`] - pub fn new( - url: MintUrl, - http_client: Arc, - prefer_ws_method: bool, - wallet: Arc, - ) -> Self { - let subscriptions = Arc::new(RwLock::new(HashMap::new())); - let (new_subscription_notif, new_subscription_recv) = mpsc::channel(100); - let (on_drop_notif, on_drop_recv) = mpsc::channel(1000); - - Self { - new_subscription_notif, - on_drop_notif, - subscriptions: subscriptions.clone(), - worker: Some(Self::start_worker( - prefer_ws_method, - http_client, - url, - subscriptions, - new_subscription_recv, - on_drop_recv, - wallet, - )), - } + fn get_sub_request( + &self, + id: String, + params: NotificationId, + ) -> Option<(usize, String)> { + let (kind, filter) = match params { + NotificationId::ProofState(x) => (Kind::ProofState, x.to_string()), + NotificationId::MeltQuoteBolt11(q) | NotificationId::MeltQuoteBolt12(q) => { + (Kind::Bolt11MeltQuote, q) + } + NotificationId::MintQuoteBolt11(q) => (Kind::Bolt11MintQuote, q), + NotificationId::MintQuoteBolt12(q) => (Kind::Bolt12MintQuote, q), + }; + + let request: WsRequest<_> = ( + WsMethodRequest::Subscribe(WalletParams { + kind, + filters: vec![filter], + id: id.into(), + }), + self.req_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + ) + .into(); + + serde_json::to_string(&request) + .inspect_err(|err| { + tracing::error!("Could not serialize subscribe message: {:?}", err); + }) + .map(|json| (request.id, json)) + .ok() } - #[allow(unused_variables)] - fn start_worker( - prefer_ws_method: bool, - http_client: Arc, - url: MintUrl, - subscriptions: Arc>>, - new_subscription_recv: mpsc::Receiver, - on_drop_recv: mpsc::Receiver, - wallet: Arc, - ) -> JoinHandle<()> { - #[cfg(any( - feature = "http_subscription", - not(feature = "mint"), - target_arch = "wasm32" - ))] - return Self::http_worker( - http_client, - subscriptions, - new_subscription_recv, - on_drop_recv, - wallet, - ); - - #[cfg(all( - not(feature = "http_subscription"), - feature = "mint", - not(target_arch = "wasm32") - ))] - if prefer_ws_method { - Self::ws_worker( - http_client, - url, - subscriptions, - new_subscription_recv, - on_drop_recv, - wallet, - ) - } else { - Self::http_worker( - http_client, - subscriptions, - new_subscription_recv, - on_drop_recv, - wallet, - ) + fn get_unsub_request(&self, sub_id: String) -> Option { + let request: WsRequest<_> = ( + WsMethodRequest::Unsubscribe(WsUnsubscribeRequest { sub_id }), + self.req_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed), + ) + .into(); + + match serde_json::to_string(&request) { + Ok(json) => Some(json), + Err(err) => { + tracing::error!("Could not serialize unsubscribe message: {:?}", err); + None + } } } +} - /// Subscribe to a WebSocket channel - pub async fn subscribe( - &self, - filter: Params, - ) -> (mpsc::Sender, mpsc::Receiver) { - let mut subscriptions = self.subscriptions.write().await; - let id = filter.id.clone(); - - let (sender, receiver) = mpsc::channel(10_000); - subscriptions.insert(id.clone(), (sender, filter)); - drop(subscriptions); +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl Transport for SubscriptionClient { + type Spec = MintSubTopics; - let _ = self.new_subscription_notif.send(id).await; - (self.on_drop_notif.clone(), receiver) + fn new_name(&self) -> ::SubscriptionId { + Uuid::new_v4().to_string() } - /// HTTP subscription client - /// - /// This is a poll based subscription, where the client will poll the server - /// from time to time to get updates, notifying the subscribers on changes - fn http_worker( - http_client: Arc, - subscriptions: Arc>>, - new_subscription_recv: mpsc::Receiver, - on_drop: mpsc::Receiver, - wallet: Arc, - ) -> JoinHandle<()> { - let http_worker = http::http_main( - vec![], - http_client, - subscriptions, - new_subscription_recv, - on_drop, - wallet, - ); + async fn stream( + &self, + _ctrls: mpsc::Receiver>, + _topics: Vec>, + _reply_to: InternalRelay, + ) -> Result<(), PubsubError> { + #[cfg(not(target_arch = "wasm32"))] + let r = ws::stream_client(self, _ctrls, _topics, _reply_to).await; #[cfg(target_arch = "wasm32")] - let ret = tokio::task::spawn_local(http_worker); + let r = Err(PubsubError::NotSupported); - #[cfg(not(target_arch = "wasm32"))] - let ret = tokio::spawn(http_worker); - - ret + r } - /// WebSocket subscription client - /// - /// This is a WebSocket based subscription, where the client will connect to - /// the server and stay there idle waiting for server-side notifications - #[cfg(all( - not(feature = "http_subscription"), - feature = "mint", - not(target_arch = "wasm32") - ))] - fn ws_worker( - http_client: Arc, - url: MintUrl, - subscriptions: Arc>>, - new_subscription_recv: mpsc::Receiver, - on_drop: mpsc::Receiver, - wallet: Arc, - ) -> JoinHandle<()> { - tokio::spawn(ws::ws_main( - http_client, - url, - subscriptions, - new_subscription_recv, - on_drop, - wallet, - )) - } -} + /// Poll on demand + async fn poll( + &self, + topics: Vec>, + reply_to: InternalRelay, + ) -> Result<(), PubsubError> { + let proofs = topics + .iter() + .filter_map(|(_, x)| match &x { + NotificationId::ProofState(p) => Some(*p), + _ => None, + }) + .collect::>(); + + if !proofs.is_empty() { + for state in self + .http_client + .post_check_state(CheckStateRequest { ys: proofs }) + .await + .map_err(|e| PubsubError::Internal(Box::new(e)))? + .states + { + reply_to.send(MintEvent::new(NotificationPayload::ProofState(state))); + } + } -impl Drop for SubscriptionClient { - fn drop(&mut self) { - if let Some(sender) = self.worker.take() { - sender.abort(); + for topic in topics + .into_iter() + .map(|(_, x)| x) + .filter(|x| !matches!(x, NotificationId::ProofState(_))) + { + match topic { + NotificationId::MintQuoteBolt11(id) => { + let response = match self.http_client.get_mint_quote_status(&id).await { + Ok(success) => success, + Err(err) => { + tracing::error!("Error with MintBolt11 {} with {:?}", id, err); + continue; + } + }; + + reply_to.send(MintEvent::new( + NotificationPayload::MintQuoteBolt11Response(response.clone()), + )); + } + NotificationId::MeltQuoteBolt11(id) => { + let response = match self.http_client.get_melt_quote_status(&id).await { + Ok(success) => success, + Err(err) => { + tracing::error!("Error with MeltBolt11 {} with {:?}", id, err); + continue; + } + }; + + reply_to.send(MintEvent::new( + NotificationPayload::MeltQuoteBolt11Response(response), + )); + } + NotificationId::MintQuoteBolt12(id) => { + let response = match self.http_client.get_mint_quote_bolt12_status(&id).await { + Ok(success) => success, + Err(err) => { + tracing::error!("Error with MintBolt12 {} with {:?}", id, err); + continue; + } + }; + + reply_to.send(MintEvent::new( + NotificationPayload::MintQuoteBolt12Response(response), + )); + } + NotificationId::MeltQuoteBolt12(id) => { + let response = match self.http_client.get_melt_bolt12_quote_status(&id).await { + Ok(success) => success, + Err(err) => { + tracing::error!("Error with MeltBolt12 {} with {:?}", id, err); + continue; + } + }; + + reply_to.send(MintEvent::new( + NotificationPayload::MeltQuoteBolt11Response(response), + )); + } + _ => {} + } } + + Ok(()) } } diff --git a/crates/cdk/src/wallet/subscription/ws.rs b/crates/cdk/src/wallet/subscription/ws.rs index 2b5a7d065..1c3198291 100644 --- a/crates/cdk/src/wallet/subscription/ws.rs +++ b/crates/cdk/src/wallet/subscription/ws.rs @@ -1,217 +1,168 @@ -use std::collections::{HashMap, HashSet}; -use std::sync::atomic::AtomicUsize; -use std::sync::Arc; - -use cdk_common::subscription::Params; -use cdk_common::ws::{WsMessageOrResponse, WsMethodRequest, WsRequest, WsUnsubscribeRequest}; +use cdk_common::nut17::ws::WsMessageOrResponse; +use cdk_common::pub_sub::remote_consumer::{InternalRelay, StreamCtrl, SubscribeMessage}; +use cdk_common::pub_sub::Error as PubsubError; +#[cfg(feature = "auth")] +use cdk_common::{Method, RoutePath}; use futures::{SinkExt, StreamExt}; -use tokio::sync::{mpsc, RwLock}; +use tokio::sync::mpsc; use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::Message; -use super::http::http_main; -use super::WsSubscriptionBody; -use crate::mint_url::MintUrl; -use crate::pub_sub::SubId; -use crate::wallet::MintConnector; -use crate::Wallet; - -const MAX_ATTEMPT_FALLBACK_HTTP: usize = 10; - -async fn fallback_to_http>( - initial_state: S, - http_client: Arc, - subscriptions: Arc>>, - new_subscription_recv: mpsc::Receiver, - on_drop: mpsc::Receiver, - wallet: Arc, -) { - http_main( - initial_state, - http_client, - subscriptions, - new_subscription_recv, - on_drop, - wallet, - ) - .await -} - -#[inline] -pub async fn ws_main( - http_client: Arc, - mint_url: MintUrl, - subscriptions: Arc>>, - mut new_subscription_recv: mpsc::Receiver, - mut on_drop: mpsc::Receiver, - wallet: Arc, -) { - let url = mint_url +use super::{MintSubTopics, SubscriptionClient}; + +#[inline(always)] +pub(crate) async fn stream_client( + client: &SubscriptionClient, + mut ctrl: mpsc::Receiver>, + topics: Vec>, + reply_to: InternalRelay, +) -> Result<(), PubsubError> { + let mut url = client + .mint_url .join_paths(&["v1", "ws"]) - .as_mut() - .map(|url| { - if url.scheme() == "https" { - url.set_scheme("wss").expect("Could not set scheme"); - } else { - url.set_scheme("ws").expect("Could not set scheme"); - } - url - }) - .expect("Could not join paths") - .to_string(); + .expect("Could not join paths"); - let mut active_subscriptions = HashMap::>::new(); - let mut failure_count = 0; + if url.scheme() == "https" { + url.set_scheme("wss").expect("Could not set scheme"); + } else { + url.set_scheme("ws").expect("Could not set scheme"); + } - loop { - tracing::debug!("Connecting to {}", url); - let ws_stream = match connect_async(&url).await { - Ok((ws_stream, _)) => ws_stream, - Err(err) => { - failure_count += 1; - tracing::error!("Could not connect to server: {:?}", err); - if failure_count > MAX_ATTEMPT_FALLBACK_HTTP { - tracing::error!( - "Could not connect to server after {MAX_ATTEMPT_FALLBACK_HTTP} attempts, falling back to HTTP-subscription client" - ); - return fallback_to_http( - active_subscriptions.into_keys(), - http_client, - subscriptions, - new_subscription_recv, - on_drop, - wallet, - ) - .await; + #[cfg(not(feature = "auth"))] + let request = url.to_string().into_client_request().map_err(|err| { + tracing::error!("Failed to create client request: {:?}", err); + // Fallback to HTTP client if we can't create the WebSocket request + cdk_common::pub_sub::Error::NotSupported + })?; + + #[cfg(feature = "auth")] + let mut request = url.to_string().into_client_request().map_err(|err| { + tracing::error!("Failed to create client request: {:?}", err); + // Fallback to HTTP client if we can't create the WebSocket request + cdk_common::pub_sub::Error::NotSupported + })?; + + #[cfg(feature = "auth")] + { + let auth_wallet = client.http_client.get_auth_wallet().await; + let token = match auth_wallet.as_ref() { + Some(auth_wallet) => { + let endpoint = cdk_common::ProtectedEndpoint::new(Method::Get, RoutePath::Ws); + match auth_wallet.get_auth_for_request(&endpoint).await { + Ok(token) => token, + Err(err) => { + tracing::warn!("Failed to get auth token: {:?}", err); + None + } } - continue; } + None => None, }; - tracing::debug!("Connected to {}", url); - - failure_count = 0; - let (mut write, mut read) = ws_stream.split(); - let req_id = AtomicUsize::new(0); + if let Some(auth_token) = token { + let header_key = match &auth_token { + cdk_common::AuthToken::ClearAuth(_) => "Clear-auth", + cdk_common::AuthToken::BlindAuth(_) => "Blind-auth", + }; - let get_sub_request = |params: Params| -> Option<(usize, String)> { - let request: WsRequest = ( - WsMethodRequest::Subscribe(params), - req_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - ) - .into(); - - match serde_json::to_string(&request) { - Ok(json) => Some((request.id, json)), + match auth_token.to_string().parse() { + Ok(header_value) => { + request.headers_mut().insert(header_key, header_value); + } Err(err) => { - tracing::error!("Could not serialize subscribe message: {:?}", err); - None + tracing::warn!("Failed to parse auth token as header value: {:?}", err); } } - }; + } + } - let get_unsub_request = |sub_id: SubId| -> Option { - let request: WsRequest = ( - WsMethodRequest::Unsubscribe(WsUnsubscribeRequest { sub_id }), - req_id.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - ) - .into(); + tracing::debug!("Connecting to {}", url); + let ws_stream = connect_async(request) + .await + .map(|(ws_stream, _)| ws_stream) + .map_err(|err| { + tracing::error!("Error connecting: {err:?}"); - match serde_json::to_string(&request) { - Ok(json) => Some(json), - Err(err) => { - tracing::error!("Could not serialize unsubscribe message: {:?}", err); - None - } - } + cdk_common::pub_sub::Error::Internal(Box::new(err)) + })?; + + tracing::debug!("Connected to {}", url); + let (mut write, mut read) = ws_stream.split(); + + for (name, index) in topics { + let (_, req) = if let Some(req) = client.get_sub_request(name, index) { + req + } else { + continue; }; - // Websocket reconnected, restore all subscriptions - let mut subscription_requests = HashSet::new(); - - let read_subscriptions = subscriptions.read().await; - for (sub_id, _) in active_subscriptions.iter() { - if let Some(Some((req_id, req))) = read_subscriptions - .get(sub_id) - .map(|(_, params)| get_sub_request(params.clone())) - { - let _ = write.send(Message::Text(req.into())).await; - subscription_requests.insert(req_id); - } - } - drop(read_subscriptions); - - loop { - tokio::select! { - Some(msg) = read.next() => { - let msg = match msg { - Ok(msg) => msg, - Err(_) => break, - }; - let msg = match msg { - Message::Text(msg) => msg, - _ => continue, - }; - let msg = match serde_json::from_str::(&msg) { - Ok(msg) => msg, - Err(_) => continue, - }; - - match msg { - WsMessageOrResponse::Notification(payload) => { - tracing::debug!("Received notification from server: {:?}", payload); - let _ = active_subscriptions.get(&payload.params.sub_id).map(|sender| { - let _ = sender.try_send(payload.params.payload); - }); - } - WsMessageOrResponse::Response(response) => { - tracing::debug!("Received response from server: {:?}", response); - subscription_requests.remove(&response.id); + let _ = write.send(Message::Text(req.into())).await; + } + + loop { + tokio::select! { + Some(msg) = ctrl.recv() => { + match msg { + StreamCtrl::Subscribe(msg) => { + let (_, req) = if let Some(req) = client.get_sub_request(msg.0, msg.1) { + req + } else { + continue; + }; + let _ = write.send(Message::Text(req.into())).await; + } + StreamCtrl::Unsubscribe(msg) => { + let req = if let Some(req) = client.get_unsub_request(msg) { + req + } else { + continue; + }; + let _ = write.send(Message::Text(req.into())).await; + } + StreamCtrl::Stop => { + if let Err(err) = write.send(Message::Close(None)).await { + tracing::error!("Closing error {err:?}"); } - WsMessageOrResponse::ErrorResponse(error) => { - tracing::error!("Received error from server: {:?}", error); - if subscription_requests.contains(&error.id) { - // If the server sends an error response to a subscription request, we should - // fallback to HTTP. - // TODO: Add some retry before giving up to HTTP. - return fallback_to_http( - active_subscriptions.into_keys(), - http_client, - subscriptions, - new_subscription_recv, - on_drop, - wallet - ).await; - } + break; + } + }; + } + Some(msg) = read.next() => { + let msg = match msg { + Ok(msg) => msg, + Err(_) => { + if let Err(err) = write.send(Message::Close(None)).await { + tracing::error!("Closing error {err:?}"); } + break; } - - } - Some(subid) = new_subscription_recv.recv() => { - let subscription = subscriptions.read().await; - let sub = if let Some(subscription) = subscription.get(&subid) { - subscription - } else { - continue - }; - tracing::debug!("Subscribing to {:?}", sub.1); - active_subscriptions.insert(subid, sub.0.clone()); - if let Some((req_id, json)) = get_sub_request(sub.1.clone()) { - let _ = write.send(Message::Text(json.into())).await; - subscription_requests.insert(req_id); + }; + let msg = match msg { + Message::Text(msg) => msg, + _ => continue, + }; + let msg = match serde_json::from_str::>(&msg) { + Ok(msg) => msg, + Err(_) => continue, + }; + + match msg { + WsMessageOrResponse::Notification(payload) => { + reply_to.send(payload.params.payload); } - }, - Some(subid) = on_drop.recv() => { - let mut subscription = subscriptions.write().await; - if let Some(sub) = subscription.remove(&subid) { - drop(sub); + WsMessageOrResponse::Response(response) => { + tracing::debug!("Received response from server: {:?}", response); } - tracing::debug!("Unsubscribing from {:?}", subid); - if let Some(json) = get_unsub_request(subid) { - let _ = write.send(Message::Text(json.into())).await; + WsMessageOrResponse::ErrorResponse(error) => { + tracing::debug!("Received an error from server: {:?}", error); + return Err(PubsubError::InternalStr(error.error.message)); } } + } } } + + Ok(()) } diff --git a/crates/cdk/src/wallet/swap.rs b/crates/cdk/src/wallet/swap.rs index 5e9f15aad..ace000a40 100644 --- a/crates/cdk/src/wallet/swap.rs +++ b/crates/cdk/src/wallet/swap.rs @@ -1,3 +1,4 @@ +use cdk_common::nut02::KeySetInfosMethods; use tracing::instrument; use crate::amount::SplitTarget; @@ -34,15 +35,19 @@ impl Wallet { ) .await?; - let swap_response = self.client.post_swap(pre_swap.swap_request).await?; + let swap_response = self + .try_proof_operation_or_reclaim( + pre_swap.swap_request.inputs().clone(), + self.client.post_swap(pre_swap.swap_request), + ) + .await?; let active_keyset_id = pre_swap.pre_mint_secrets.keyset_id; + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; - let active_keys = self - .localstore - .get_keys(&active_keyset_id) - .await? - .ok_or(Error::NoActiveKeyset)?; + let active_keys = self.load_keyset_keys(active_keyset_id).await?; let post_swap_proofs = construct_proofs( swap_response.signatures, @@ -51,10 +56,6 @@ impl Wallet { &active_keys, )?; - self.localstore - .increment_keyset_counter(&active_keyset_id, pre_swap.derived_secret_count) - .await?; - let mut added_proofs = Vec::new(); let change_proofs; let send_proofs; @@ -75,7 +76,8 @@ impl Wallet { let mut proofs_to_send = Proofs::new(); let mut proofs_to_keep = Proofs::new(); - let mut amount_split = amount.split_targeted(&amount_split_target)?; + let mut amount_split = + amount.split_targeted(&amount_split_target, &fee_and_amounts)?; for proof in all_proofs { if let Some(idx) = amount_split.iter().position(|&a| a == proof.amount) @@ -91,16 +93,6 @@ impl Wallet { } }; - let send_amount = proofs_to_send.total_amount()?; - - if send_amount.ne(&(amount + pre_swap.fee)) { - tracing::warn!( - "Send amount proofs is {:?} expected {:?}", - send_amount, - amount - ); - } - let send_proofs_info = proofs_to_send .clone() .into_iter() @@ -155,24 +147,25 @@ impl Wallet { ) .await?; - let (available_proofs, proofs_sum) = available_proofs.into_iter().map(|p| p.proof).fold( - (Vec::new(), Amount::ZERO), - |(mut acc1, mut acc2), p| { - acc2 += p.amount; + let (available_proofs, proofs_sum) = available_proofs + .into_iter() + .map(|p| p.proof) + .try_fold((Vec::new(), Amount::ZERO), |(mut acc1, acc2), p| { + let new_sum = acc2.checked_add(p.amount).ok_or(Error::AmountOverflow)?; acc1.push(p); - (acc1, acc2) - }, - ); + Ok::<_, Error>((acc1, new_sum)) + })?; ensure_cdk!(proofs_sum >= amount, Error::InsufficientFunds); let active_keyset_ids = self - .get_active_mint_keysets() + .get_mint_keysets() .await? - .into_iter() + .active() .map(|k| k.id) .collect(); - let keyset_fees = self.get_keyset_fees().await?; + + let keyset_fees = self.get_keyset_fees_and_amounts().await?; let proofs = Wallet::select_proofs( amount, available_proofs, @@ -203,7 +196,7 @@ impl Wallet { include_fees: bool, ) -> Result { tracing::info!("Creating swap"); - let active_keyset_id = self.get_active_mint_keyset().await?.id; + let active_keyset_id = self.fetch_active_keyset().await?.id; // Desired amount is either amount passed or value of all proof let proofs_total = proofs.total_amount()?; @@ -215,13 +208,24 @@ impl Wallet { let fee = self.get_proofs_fee(&proofs).await?; - let change_amount: Amount = proofs_total - amount.unwrap_or(Amount::ZERO) - fee; + let total_to_subtract = amount + .unwrap_or(Amount::ZERO) + .checked_add(fee) + .ok_or(Error::AmountOverflow)?; + + let change_amount: Amount = proofs_total + .checked_sub(total_to_subtract) + .ok_or(Error::InsufficientFunds)?; + + let fee_and_amounts = self + .get_keyset_fees_and_amounts_by_id(active_keyset_id) + .await?; let (send_amount, change_amount) = match include_fees { true => { let split_count = amount .unwrap_or(Amount::ZERO) - .split_targeted(&SplitTarget::default()) + .split_targeted(&SplitTarget::default(), &fee_and_amounts) .unwrap() .len(); @@ -230,8 +234,12 @@ impl Wallet { .await?; ( - amount.map(|a| a + fee_to_redeem), - change_amount - fee_to_redeem, + amount + .map(|a| a.checked_add(fee_to_redeem).ok_or(Error::AmountOverflow)) + .transpose()?, + change_amount + .checked_sub(fee_to_redeem) + .ok_or(Error::InsufficientFunds)?, ) } false => (amount, change_amount), @@ -240,27 +248,65 @@ impl Wallet { // If a non None split target is passed use that // else use state refill let change_split_target = match amount_split_target { - SplitTarget::None => self.determine_split_target_values(change_amount).await?, + SplitTarget::None => { + self.determine_split_target_values(change_amount, &fee_and_amounts) + .await? + } s => s, }; let derived_secret_count; - let count = self - .localstore - .get_keyset_counter(&active_keyset_id) - .await?; + // Calculate total secrets needed and atomically reserve counter range + let total_secrets_needed = match spending_conditions { + Some(_) => { + // For spending conditions, we only need to count change secrets + change_amount + .split_targeted(&change_split_target, &fee_and_amounts)? + .len() as u32 + } + None => { + // For no spending conditions, count both send and change secrets + let send_count = send_amount + .unwrap_or(Amount::ZERO) + .split_targeted(&SplitTarget::default(), &fee_and_amounts)? + .len() as u32; + let change_count = change_amount + .split_targeted(&change_split_target, &fee_and_amounts)? + .len() as u32; + send_count + change_count + } + }; + + // Atomically get the counter range we need + let starting_counter = if total_secrets_needed > 0 { + tracing::debug!( + "Incrementing keyset {} counter by {}", + active_keyset_id, + total_secrets_needed + ); + + let new_counter = self + .localstore + .increment_keyset_counter(&active_keyset_id, total_secrets_needed) + .await?; + + new_counter - total_secrets_needed + } else { + 0 // No secrets needed, don't increment the counter + }; - let mut count = count.map_or(0, |c| c + 1); + let mut count = starting_counter; let (mut desired_messages, change_messages) = match spending_conditions { Some(conditions) => { - let change_premint_secrets = PreMintSecrets::from_xpriv( + let change_premint_secrets = PreMintSecrets::from_seed( active_keyset_id, count, - self.xpriv, + &self.seed, change_amount, &change_split_target, + &fee_and_amounts, )?; derived_secret_count = change_premint_secrets.len(); @@ -271,27 +317,30 @@ impl Wallet { send_amount.unwrap_or(Amount::ZERO), &SplitTarget::default(), &conditions, + &fee_and_amounts, )?, change_premint_secrets, ) } None => { - let premint_secrets = PreMintSecrets::from_xpriv( + let premint_secrets = PreMintSecrets::from_seed( active_keyset_id, count, - self.xpriv, + &self.seed, send_amount.unwrap_or(Amount::ZERO), &SplitTarget::default(), + &fee_and_amounts, )?; count += premint_secrets.len() as u32; - let change_premint_secrets = PreMintSecrets::from_xpriv( + let change_premint_secrets = PreMintSecrets::from_seed( active_keyset_id, count, - self.xpriv, + &self.seed, change_amount, &change_split_target, + &fee_and_amounts, )?; derived_secret_count = change_premint_secrets.len() + premint_secrets.len(); diff --git a/crates/cdk/src/wallet/transactions.rs b/crates/cdk/src/wallet/transactions.rs index 3a0f618b1..f609c01cf 100644 --- a/crates/cdk/src/wallet/transactions.rs +++ b/crates/cdk/src/wallet/transactions.rs @@ -1,4 +1,5 @@ use cdk_common::wallet::{Transaction, TransactionDirection, TransactionId}; +use cdk_common::Proofs; use crate::{Error, Wallet}; @@ -29,6 +30,28 @@ impl Wallet { Ok(transaction) } + /// Get proofs for a transaction by transaction ID + /// + /// This retrieves all proofs associated with a transaction by looking up + /// the transaction's Y values and fetching the corresponding proofs. + pub async fn get_proofs_for_transaction(&self, id: TransactionId) -> Result { + let transaction = self + .localstore + .get_transaction(id) + .await? + .ok_or(Error::TransactionNotFound)?; + + let proofs = self + .localstore + .get_proofs_by_ys(transaction.ys) + .await? + .into_iter() + .map(|p| p.proof) + .collect(); + + Ok(proofs) + } + /// Revert a transaction pub async fn revert_transaction(&self, id: TransactionId) -> Result<(), Error> { let tx = self diff --git a/docker-compose.ldk-node.yaml b/docker-compose.ldk-node.yaml new file mode 100644 index 000000000..8f83386c6 --- /dev/null +++ b/docker-compose.ldk-node.yaml @@ -0,0 +1,137 @@ +version: '3.8' + +services: + # CDK Mint service with LDK Node backend + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./misc/provisioning/prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + - '--enable-feature=otlp-write-receiver' + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - cdk + + # Grafana for visualization + grafana: + image: grafana/grafana:latest + ports: + - "3011:3000" + volumes: + - ./misc/provisioning/datasources:/etc/grafana/provisioning/datasources + - ./misc/provisioning/dashboards:/etc/grafana/provisioning/dashboards + environment: + - GF_DASHBOARDS_JSON_ENABLED=true + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_PROVISIONING_PATHS=/etc/grafana/provisioning + networks: + - cdk + + mintd-ldk-node: + # Use the ldk-node tagged image from the same repository + image: cashubtc/mintd:ldk-node-amd64 + # Alternatively, build locally: + # build: + # context: . + # dockerfile: Dockerfile.ldk-node + 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 + - CDK_MINTD_LISTEN_HOST=0.0.0.0 + - CDK_MINTD_LISTEN_PORT=8085 + - CDK_MINTD_MNEMONIC= + # Database configuration - choose one: + # Option 1: SQLite (embedded, no additional setup needed) + - CDK_MINTD_DATABASE=sqlite + # Option 2: ReDB (embedded, no additional setup needed) + # - CDK_MINTD_DATABASE=redb + # Option 3: PostgreSQL (requires postgres service, enable with: docker-compose --profile postgres up) + # - CDK_MINTD_DATABASE=postgres + # - CDK_MINTD_DATABASE_URL=postgresql://cdk_user:cdk_password@postgres:5432/cdk_mint + # 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_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 + command: ["cdk-mintd"] + depends_on: + - prometheus + - grafana + networks: + - cdk + # Uncomment when using PostgreSQL: + # depends_on: + # - postgres + + # PostgreSQL database service + # Enable with: docker-compose --profile postgres up + postgres: + image: postgres:16-alpine + container_name: mint_postgres + restart: unless-stopped + profiles: + - postgres + environment: + - POSTGRES_USER=cdk_user + - POSTGRES_PASSWORD=cdk_password + - POSTGRES_DB=cdk_mint + - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cdk_user -d cdk_mint"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - cdk + +volumes: + postgres_data: + driver: local + ldk_node_data: + driver: local + +networks: + cdk: + driver: bridge diff --git a/docker-compose.postgres.yaml b/docker-compose.postgres.yaml new file mode 100644 index 000000000..54aa9c083 --- /dev/null +++ b/docker-compose.postgres.yaml @@ -0,0 +1,52 @@ +# Docker Compose configuration for CDK Mint with PostgreSQL +# Usage: docker-compose -f docker-compose.postgres.yaml up + +services: + # CDK Mint service with PostgreSQL + mintd: + build: + context: . + dockerfile: Dockerfile + container_name: mint + ports: + - "8085:8085" + environment: + - CDK_MINTD_URL=https://example.com + - CDK_MINTD_LN_BACKEND=fakewallet + - CDK_MINTD_LISTEN_HOST=0.0.0.0 + - CDK_MINTD_LISTEN_PORT=8085 + - CDK_MINTD_MNEMONIC= + # PostgreSQL database configuration + - CDK_MINTD_DATABASE=postgres + - CDK_MINTD_DATABASE_URL=postgresql://cdk_user:cdk_password@postgres:5432/cdk_mint + # Cache configuration + - CDK_MINTD_CACHE_BACKEND=memory + command: ["cdk-mintd"] + depends_on: + postgres: + condition: service_healthy + + # PostgreSQL database service + postgres: + image: postgres:16-alpine + container_name: mint_postgres + restart: unless-stopped + environment: + - POSTGRES_USER=cdk_user + - POSTGRES_PASSWORD=cdk_password + - POSTGRES_DB=cdk_mint + - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U cdk_user -d cdk_mint"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + +volumes: + postgres_data: + driver: local diff --git a/docker-compose.yaml b/docker-compose.yaml index c30a6970f..b364ad633 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,40 @@ +version: '3.8' + services: + # CDK Mint service + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./misc/provisioning/prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + - '--enable-feature=otlp-write-receiver' + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - cdk + + # Grafana for visualization + grafana: + image: grafana/grafana:latest + ports: + - "3011:3000" + volumes: + - ./misc/provisioning/datasources:/etc/grafana/provisioning/datasources + - ./misc/provisioning/dashboards:/etc/grafana/provisioning/dashboards + environment: + - GF_DASHBOARDS_JSON_ENABLED=true + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_PROVISIONING_PATHS=/etc/grafana/provisioning + networks: + - cdk mintd: build: context: . @@ -12,22 +48,85 @@ services: - CDK_MINTD_LISTEN_HOST=0.0.0.0 - CDK_MINTD_LISTEN_PORT=8085 - CDK_MINTD_MNEMONIC= - - CDK_MINTD_DATABASE=redb + # Database configuration - choose one: + # Option 1: SQLite (embedded, no additional setup needed) + - CDK_MINTD_DATABASE=sqlite + # Option 2: ReDB (embedded, no additional setup needed) + # - CDK_MINTD_DATABASE=redb + # Option 3: PostgreSQL (requires postgres service, enable with: docker-compose --profile postgres up) + # - CDK_MINTD_DATABASE=postgres + # - CDK_MINTD_DATABASE_URL=postgresql://cdk_user:cdk_password@postgres:5432/cdk_mint + # Cache configuration - CDK_MINTD_CACHE_BACKEND=memory - # - CDK_MINTD_CACHE_REDIS_URL=redis://redis:6379 + # For Redis cache (requires redis service, enable with: docker-compose --profile redis up): + # - CDK_MINTD_CACHE_REDIS_URL=redis://redis:6379 # - CDK_MINTD_CACHE_REDIS_KEY_PREFIX=cdk-mintd + - CDK_MINTD_PROMETHEUS_ENABLED=true + - CDK_MINTD_PROMETHEUS_ADDRESS=0.0.0.0 + - CDK_MINTD_PROMETHEUS_PORT=9000 command: ["cdk-mintd"] + depends_on: + - prometheus + - grafana + networks: + - cdk + # Uncomment when using PostgreSQL: # depends_on: - # - redis + # - postgres + + # PostgreSQL database service + # Enable with: docker-compose --profile postgres up + # postgres: + # image: postgres:16-alpine + # container_name: mint_postgres + # restart: unless-stopped + # profiles: + # - postgres + # environment: + # - POSTGRES_USER=cdk_user + # - POSTGRES_PASSWORD=cdk_password + # - POSTGRES_DB=cdk_mint + # - POSTGRES_INITDB_ARGS=--encoding=UTF-8 --lc-collate=C --lc-ctype=C + # ports: + # - "5432:5432" + # volumes: + # - postgres_data:/var/lib/postgresql/data + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U cdk_user -d cdk_mint"] + # interval: 10s + # timeout: 5s + # retries: 5 + + # Redis cache service (optional) + # Enable with: docker-compose --profile redis up # redis: # image: redis:7-alpine # container_name: mint_redis +# restart: unless-stopped +# profiles: +# - redis # ports: # - "6379:6379" # volumes: # - redis_data:/data # command: redis-server --save 60 1 --loglevel warning +# healthcheck: +# test: ["CMD", "redis-cli", "ping"] +# interval: 10s +# timeout: 3s +# retries: 5 + +volumes: + postgres_data: + driver: local + ldk_node_data: + driver: local +# redis_data: +# driver: local + + -# volumes: -# redis_data: +networks: + cdk: + driver: bridge diff --git a/flake.lock b/flake.lock index 6de883378..5ce14f10a 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1750266157, - "narHash": "sha256-tL42YoNg9y30u7zAqtoGDNdTyXTi8EALDeCB13FtbQA=", + "lastModified": 1762538466, + "narHash": "sha256-8zrIPl6J+wLm9MH5ksHcW7BUHo7jSNOu0/hA0ohOOaM=", "owner": "ipetkov", "repo": "crane", - "rev": "e37c943371b73ed87faf33f7583860f81f1d5a48", + "rev": "0cea393fffb39575c46b7a0318386467272182fe", "type": "github" }, "original": { @@ -23,11 +23,11 @@ "rust-analyzer-src": [] }, "locked": { - "lastModified": 1750833544, - "narHash": "sha256-e5W27mfPGiM35qr0DjTUzLHP4ET2MbvRc4HJHScw/ko=", + "lastModified": 1762929886, + "narHash": "sha256-TQZ3Ugb1FoHpTSc8KLrzN4njIZU4FemAMHyS4M3mt6s=", "owner": "nix-community", "repo": "fenix", - "rev": "c3940d9ff4d37e965e5841149367234c2aad1ab6", + "rev": "6998514dce2c365142a0a119a95ef95d89b84086", "type": "github" }, "original": { @@ -39,11 +39,11 @@ "flake-compat": { "flake": false, "locked": { - "lastModified": 1696426674, - "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "lastModified": 1747046372, + "narHash": "sha256-CIVLLkVgvHYbgI2UpXvIIBJ12HWgX+fjA8Xf8PUmqCY=", "owner": "edolstra", "repo": "flake-compat", - "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "rev": "9100a0f413b0c601e0533d1d94ffd501ce2e7885", "type": "github" }, "original": { @@ -93,11 +93,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1750622754, - "narHash": "sha256-kMhs+YzV4vPGfuTpD3mwzibWUE6jotw5Al2wczI0Pv8=", + "lastModified": 1762756533, + "narHash": "sha256-HiRDeUOD1VLklHeOmaKDzf+8Hb7vSWPVFcWwaTrpm+U=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "c7ab75210cb8cb16ddd8f290755d9558edde7ee1", + "rev": "c2448301fb856e351aab33e64c33a3fc8bcf637d", "type": "github" }, "original": { @@ -109,11 +109,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1730768919, - "narHash": "sha256-8AKquNnnSaJRXZxc5YmF/WfmxiHX6MMZZasRP6RRQkE=", + "lastModified": 1759417375, + "narHash": "sha256-O7eHcgkQXJNygY6AypkF9tFhsoDQjpNEojw3eFs73Ow=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "a04d33c0c3f1a59a2c1cb0c6e34cd24500e5a1dc", + "rev": "dc704e6102e76aad573f63b74c742cd96f8f1e6c", "type": "github" }, "original": { @@ -130,11 +130,11 @@ "nixpkgs": "nixpkgs_2" }, "locked": { - "lastModified": 1750779888, - "narHash": "sha256-wibppH3g/E2lxU43ZQHC5yA/7kIKLGxVEnsnVK1BtRg=", + "lastModified": 1762868777, + "narHash": "sha256-QqS72GvguP56oKDNUckWUPNJHjsdeuXh5RyoKz0wJ+E=", "owner": "cachix", "repo": "pre-commit-hooks.nix", - "rev": "16ec914f6fb6f599ce988427d9d94efddf25fe6d", + "rev": "c5c3147730384576196fb5da048a6e45dee10d56", "type": "github" }, "original": { @@ -160,11 +160,11 @@ ] }, "locked": { - "lastModified": 1750819193, - "narHash": "sha256-XvkupGPZqD54HuKhN/2WhbKjAHeTl1UEnWspzUzRFfA=", + "lastModified": 1762915112, + "narHash": "sha256-d9j1g8nKmYDHy+/bIOPQTh9IwjRliqaTM0QLHMV92Ic=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "1ba3b9c59b68a4b00156827ad46393127b51b808", + "rev": "aa1e85921cfa04de7b6914982a94621fbec5cc02", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 311f5307e..346d0e3af 100644 --- a/flake.nix +++ b/flake.nix @@ -21,147 +21,109 @@ crane = { url = "github:ipetkov/crane"; - inputs.nixpkgs.follows = "nixpkgs"; }; pre-commit-hooks.url = "github:cachix/pre-commit-hooks.nix"; }; - outputs = { self, nixpkgs, rust-overlay, flake-utils, pre-commit-hooks, crane, fenix, ... }: - flake-utils.lib.eachDefaultSystem (system: + outputs = + { self + , nixpkgs + , rust-overlay + , flake-utils + , pre-commit-hooks + , ... + }@inputs: + flake-utils.lib.eachDefaultSystem ( + system: let overlays = [ (import rust-overlay) ]; lib = pkgs.lib; stdenv = pkgs.stdenv; isDarwin = stdenv.isDarwin; - libsDarwin = with pkgs; lib.optionals isDarwin [ - # Additional darwin specific inputs can be set here - darwin.apple_sdk.frameworks.Security - darwin.apple_sdk.frameworks.SystemConfiguration - ]; + libsDarwin = + with pkgs; + lib.optionals isDarwin [ + # Additional darwin specific inputs can be set here + darwin.apple_sdk.frameworks.Security + darwin.apple_sdk.frameworks.SystemConfiguration + ]; # Dependencies pkgs = import nixpkgs { inherit system overlays; }; - - # Toolchains # latest stable - stable_toolchain = pkgs.rust-bin.stable."1.86.0".default.override { + stable_toolchain = pkgs.rust-bin.stable."1.91.1".default.override { targets = [ "wasm32-unknown-unknown" ]; # wasm - extensions = [ "rustfmt" "clippy" "rust-analyzer" ]; + extensions = [ + "rustfmt" + "clippy" + "rust-analyzer" + ]; }; # MSRV stable - msrv_toolchain = pkgs.rust-bin.stable."1.75.0".default.override { + msrv_toolchain = pkgs.rust-bin.stable."1.85.0".default.override { targets = [ "wasm32-unknown-unknown" ]; # wasm + extensions = [ + "rustfmt" + "clippy" + "rust-analyzer" + ]; }; # Nightly used for formatting - nightly_toolchain = pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override { - extensions = [ "rustfmt" "clippy" "rust-analyzer" "rust-src" ]; - targets = [ "wasm32-unknown-unknown" ]; # wasm - }); + nightly_toolchain = pkgs.rust-bin.selectLatestNightlyWith ( + toolchain: + toolchain.default.override { + extensions = [ + "rustfmt" + "clippy" + "rust-analyzer" + "rust-src" + ]; + targets = [ "wasm32-unknown-unknown" ]; # wasm + } + ); # Common inputs - envVars = { }; - buildInputs = with pkgs; [ - # Add additional build inputs here - git - pkg-config - curl - just - protobuf - nixpkgs-fmt - typos - lnd - clightning - bitcoind - sqlx-cli - cargo-outdated - - # Needed for github ci - libz - ] ++ libsDarwin; - - # WASM deps - WASMInputs = with pkgs; [ - ]; - - - - craneLib = crane.mkLib pkgs; - src = craneLib.cleanCargoSource ./.; - - # Common arguments can be set here to avoid repeating them later - commonArgs = { - inherit src; - strictDeps = true; - - buildInputs = [ - # Add additional build inputs here - pkgs.protobuf - pkgs.pkg-config - ] ++ lib.optionals pkgs.stdenv.isDarwin [ - # Additional darwin specific inputs can be set here - pkgs.libiconv - ]; - - # Additional environment variables can be set directly - # MY_CUSTOM_VAR = "some value"; - PROTOC = "${pkgs.protobuf}/bin/protoc"; - PROTOC_INCLUDE = "${pkgs.protobuf}/include"; - }; - - - craneLibLLvmTools = craneLib.overrideToolchain - (fenix.packages.${system}.complete.withComponents [ - "cargo" - "llvm-tools" - "rustc" - ]); - - cargoArtifacts = craneLib.buildDepsOnly commonArgs; - - individualCrateArgs = commonArgs // { - inherit cargoArtifacts; - inherit (craneLib.crateNameFromCargoToml { inherit src; }) version; - # NB: we disable tests since we'll run them all via cargo-nextest - doCheck = false; - }; - - fileSetForCrate = crate: lib.fileset.toSource { - root = ./.; - fileset = lib.fileset.unions [ - ./Cargo.toml - ./Cargo.lock - (craneLib.fileset.commonCargoSources ./crates/cdk) - (craneLib.fileset.commonCargoSources ./crates/cdk-axum) - (craneLib.fileset.commonCargoSources ./crates/cdk-cln) - (craneLib.fileset.commonCargoSources ./crates/cdk-lnd) - (craneLib.fileset.commonCargoSources ./crates/cdk-fake-wallet) - (craneLib.fileset.commonCargoSources ./crates/cdk-lnbits) - (craneLib.fileset.commonCargoSources ./crates/cdk-redb) - (craneLib.fileset.commonCargoSources ./crates/cdk-sqlite) - ./crates/cdk-sqlite/src/mint/migrations - ./crates/cdk-sqlite/src/wallet/migrations - (craneLib.fileset.commonCargoSources crate) - ]; + envVars = { + # rust analyzer needs NIX_PATH for some reason. + NIX_PATH = "nixpkgs=${inputs.nixpkgs}"; }; + buildInputs = + with pkgs; + [ + # Add additional build inputs here + git + pkg-config + curl + just + protobuf + nixpkgs-fmt + typos + lnd + clightning + bitcoind + sqlx-cli + mprocs + + cargo-outdated + cargo-mutants + + # Needed for github ci + libz + ] + ++ libsDarwin; - cdk-mintd = craneLib.buildPackage (individualCrateArgs // { - pname = "cdk-mintd"; - name = "cdk-mintd-${individualCrateArgs.version}"; - cargoExtraArgs = "-p cdk-mintd"; - src = fileSetForCrate ./crates/cdk-mintd; - }); - - - nativeBuildInputs = with pkgs; [ + # Common arguments can be set here to avoid repeating them later + nativeBuildInputs = [ #Add additional build inputs here - ] ++ lib.optionals isDarwin [ + ] + ++ lib.optionals isDarwin [ # Additional darwin specific native inputs can be set here ]; in @@ -178,7 +140,10 @@ inherit (_rust) meta; buildInputs = [ pkgs.makeWrapper ]; paths = [ _rust ]; - pathsToLink = [ "/" "/bin" ]; + pathsToLink = [ + "/" + "/bin" + ]; postBuild = '' for i in $out/bin/*; do wrapProgram "$i" --prefix PATH : "$out/bin" @@ -200,90 +165,92 @@ }; }; - - packages = { - inherit cdk-mintd; - default = cdk-mintd; - } // lib.optionalAttrs (!pkgs.stdenv.isDarwin) { - my-workspace-llvm-coverage = craneLibLLvmTools.cargoLlvmCov (commonArgs // { - inherit cargoArtifacts; - }); - }; - - apps = { - cdk-mintd = flake-utils.lib.mkApp { - drv = cdk-mintd; - }; - }; - devShells = let # pre-commit-checks _shellHook = (self.checks.${system}.pre-commit-check.shellHook or ""); # devShells - msrv = pkgs.mkShell ({ - shellHook = " + msrv = pkgs.mkShell ( + { + shellHook = " + cargo update + cargo update home --precise 0.5.11 ${_shellHook} - cargo update - # cargo update -p async-compression --precise 0.4.3 - cargo update -p home --precise 0.5.9 - cargo update -p zerofrom --precise 0.1.5 - cargo update -p half --precise 2.4.1 - cargo update -p base64ct --precise 1.7.3 - cargo update -p url --precise 2.5.2 - cargo update -p pest_derive --precise 2.8.0 - cargo update -p pest_generator --precise 2.8.0 - cargo update -p pest_meta --precise 2.8.0 - cargo update -p pest --precise 2.8.0 "; - buildInputs = buildInputs ++ WASMInputs ++ [ msrv_toolchain ]; - inherit nativeBuildInputs; - } // envVars); - - stable = pkgs.mkShell ({ - shellHook = ''${_shellHook}''; - buildInputs = buildInputs ++ WASMInputs ++ [ stable_toolchain ]; - inherit nativeBuildInputs; - } // envVars); - - - nightly = pkgs.mkShell ({ - shellHook = '' - ${_shellHook} - # Needed for github ci - export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath [ - pkgs.zlib - ]}:$LD_LIBRARY_PATH - export RUST_SRC_PATH=${nightly_toolchain}/lib/rustlib/src/rust/library - ''; - buildInputs = buildInputs ++ [ nightly_toolchain ]; - inherit nativeBuildInputs; - } // envVars); + buildInputs = buildInputs ++ [ msrv_toolchain ]; + inherit nativeBuildInputs; + } + // envVars + ); + + stable = pkgs.mkShell ( + { + shellHook = '' + ${_shellHook} + # Needed for github ci + export LD_LIBRARY_PATH=${ + pkgs.lib.makeLibraryPath [ + pkgs.zlib + ] + }:$LD_LIBRARY_PATH + ''; + buildInputs = buildInputs ++ [ stable_toolchain ]; + inherit nativeBuildInputs; + + } + // envVars + ); + + nightly = pkgs.mkShell ( + { + shellHook = '' + ${_shellHook} + # Needed for github ci + export LD_LIBRARY_PATH=${ + pkgs.lib.makeLibraryPath [ + pkgs.zlib + ] + }:$LD_LIBRARY_PATH + ''; + buildInputs = buildInputs ++ [ nightly_toolchain ]; + inherit nativeBuildInputs; + } + // envVars + ); # Shell with Docker for integration tests - integration = pkgs.mkShell ({ - shellHook = '' - ${_shellHook} - # Ensure Docker is available - if ! command -v docker &> /dev/null; then - echo "Docker is not installed or not in PATH" - echo "Please install Docker to run integration tests" - exit 1 - fi - echo "Docker is available at $(which docker)" - echo "Docker version: $(docker --version)" - ''; - buildInputs = buildInputs ++ [ - stable_toolchain - pkgs.docker-client - ]; - inherit nativeBuildInputs; - } // envVars); + integration = pkgs.mkShell ( + { + shellHook = '' + ${_shellHook} + # Ensure Docker is available + if ! command -v docker &> /dev/null; then + echo "Docker is not installed or not in PATH" + echo "Please install Docker to run integration tests" + exit 1 + fi + echo "Docker is available at $(which docker)" + echo "Docker version: $(docker --version)" + ''; + buildInputs = buildInputs ++ [ + stable_toolchain + pkgs.docker-client + pkgs.python311 + ]; + inherit nativeBuildInputs; + } + // envVars + ); in { - inherit msrv stable nightly integration; + inherit + msrv + stable + nightly + integration + ; default = stable; }; } diff --git a/justfile b/justfile index fbf3b9c13..f350b924f 100644 --- a/justfile +++ b/justfile @@ -8,13 +8,14 @@ default: # Create a new SQL migration file new-migration target name: #!/usr/bin/env bash + set -euo pipefail if [ "{{target}}" != "mint" ] && [ "{{target}}" != "wallet" ]; then echo "Error: target must be either 'mint' or 'wallet'" exit 1 fi timestamp=$(date +%Y%m%d%H%M%S) - migration_path="./crates/cdk-sqlite/src/{{target}}/migrations/${timestamp}_{{name}}.sql" + migration_path="./crates/cdk-sql-common/src/{{target}}/migrations/${timestamp}_{{name}}.sql" # Create the file mkdir -p "$(dirname "$migration_path")" @@ -52,40 +53,138 @@ format: nixpkgs-fmt $(echo **.nix) # run doc tests -test: build +test: #!/usr/bin/env bash set -euo pipefail if [ ! -f Cargo.toml ]; then cd {{invocation_directory()}} fi - cargo test --lib + cargo test --lib --workspace --exclude cdk-postgres # Run pure integration tests cargo test -p cdk-integration-tests --test mint # run doc tests -test-pure db="memory": build +test-pure db="memory": #!/usr/bin/env bash set -euo pipefail if [ ! -f Cargo.toml ]; then cd {{invocation_directory()}} fi - # Run pure integration tests + # 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 + set -euo pipefail just test {{db}} ./misc/itests.sh "{{db}}" ./misc/fake_itests.sh "{{db}}" external_signatory ./misc/fake_itests.sh "{{db}}" +# Mutation Testing Commands + +# Run mutation tests on a specific crate +# Usage: just mutants +# Example: just mutants cashu +mutants CRATE: + #!/usr/bin/env bash + set -euo pipefail + echo "Running mutation tests on crate: {{CRATE}}" + cargo mutants --package {{CRATE}} -vV + +# Run mutation tests on the cashu crate +mutants-cashu: + #!/usr/bin/env bash + set -euo pipefail + echo "Running mutation tests on cashu crate..." + cargo mutants --package cashu -vV + +# Run mutation tests on the cdk crate +mutants-cdk: + #!/usr/bin/env bash + set -euo pipefail + echo "Running mutation tests on cdk crate..." + cargo mutants --package cdk -vV + +# Run mutation tests on entire workspace (WARNING: very slow) +mutants-all: + #!/usr/bin/env bash + set -euo pipefail + echo "Running mutation tests on entire workspace..." + echo "WARNING: This may take a very long time!" + cargo mutants -vV + +# Quick mutation test for current work (alias for mutants-diff) +mutants-quick: + #!/usr/bin/env bash + set -euo pipefail + echo "Running mutations on changed files since HEAD..." + cargo mutants --in-diff HEAD -vV + +# Run mutation tests only on changed code since HEAD +mutants-diff: + #!/usr/bin/env bash + set -euo pipefail + echo "Running mutation tests on changed code..." + cargo mutants --in-diff HEAD -vV + +# Run mutation tests and save output to log file +# Usage: just mutants-log +# Example: just mutants-log cashu baseline +mutants-log CRATE SUFFIX: + #!/usr/bin/env bash + set -euo pipefail + if [ ! -f Cargo.toml ]; then + cd {{invocation_directory()}} + fi + LOG_FILE="mutants-{{CRATE}}-{{SUFFIX}}.log" + echo "Running mutation tests on {{CRATE}}, saving to $LOG_FILE..." + cargo mutants --package {{CRATE}} -vV 2>&1 | tee "$LOG_FILE" + echo "Results saved to $LOG_FILE" + +# Mutation test with baseline comparison +# Usage: just mutants-check +# Example: just mutants-check cashu +mutants-check CRATE: + #!/usr/bin/env bash + set -euo pipefail + BASELINE="mutants-{{CRATE}}-baseline.log" + if [ ! -f "$BASELINE" ]; then + echo "ERROR: No baseline found at $BASELINE" + echo "Run: just mutants-log {{CRATE}} baseline" + exit 1 + fi + cargo mutants --package {{CRATE}} -vV | tee mutants-{{CRATE}}-current.log + # Compare results + echo "=== Baseline vs Current ===" + diff <(grep "^CAUGHT\|^MISSED" "$BASELINE" | wc -l) \ + <(grep "^CAUGHT\|^MISSED" mutants-{{CRATE}}-current.log | wc -l) || true + test-nutshell: #!/usr/bin/env bash + set -euo pipefail + + # Function to cleanup docker containers + cleanup() { + echo "Cleaning up docker containers..." + docker stop nutshell 2>/dev/null || true + docker rm nutshell 2>/dev/null || true + unset CDK_ITESTS_DIR + } + + # Trap to ensure cleanup happens on exit (success or failure) + trap cleanup EXIT + docker run -d -p 3338:3338 --name nutshell -e MINT_LIGHTNING_BACKEND=FakeWallet -e MINT_LISTEN_HOST=0.0.0.0 -e MINT_LISTEN_PORT=3338 -e MINT_PRIVATE_KEY=TEST_PRIVATE_KEY -e MINT_INPUT_FEE_PPK=100 cashubtc/nutshell:latest poetry run mint + export CDK_ITESTS_DIR=$(mktemp -d) + # Wait for the Nutshell service to be ready echo "Waiting for Nutshell to start..." max_attempts=30 @@ -94,8 +193,6 @@ test-nutshell: attempt=$((attempt+1)) if [ $attempt -ge $max_attempts ]; then echo "Nutshell failed to start after $max_attempts attempts" - docker stop nutshell - docker rm nutshell exit 1 fi echo "Waiting for Nutshell to start (attempt $attempt/$max_attempts)..." @@ -103,22 +200,45 @@ test-nutshell: done echo "Nutshell is ready!" + # Set environment variables and run tests export CDK_TEST_MINT_URL=http://127.0.0.1:3338 export LN_BACKEND=FAKEWALLET - cargo test -p cdk-integration-tests --test happy_path_mint_wallet - cargo test -p cdk-integration-tests --test test_fees + + # Track test results + test_exit_code=0 + + # Run first test and capture exit code + echo "Running happy_path_mint_wallet test..." + if ! cargo test -p cdk-integration-tests --test happy_path_mint_wallet; then + echo "ERROR: happy_path_mint_wallet test failed" + test_exit_code=1 + fi + + # Run second test and capture exit code + echo "Running test_fees test..." + if ! cargo test -p cdk-integration-tests --test test_fees; then + echo "ERROR: test_fees test failed" + test_exit_code=1 + fi + unset CDK_TEST_MINT_URL unset LN_BACKEND - docker stop nutshell - docker rm nutshell + + # Exit with error code if any test failed + if [ $test_exit_code -ne 0 ]; then + echo "One or more tests failed" + exit $test_exit_code + fi + + echo "All tests passed successfully" # run `cargo clippy` on everything -clippy *ARGS="--locked --offline --workspace --all-targets": - cargo clippy {{ARGS}} +clippy *ARGS="--workspace --all-targets": + cargo clippy {{ARGS}} -- -D warnings # run `cargo clippy --fix` on everything -clippy-fix *ARGS="--locked --offline --workspace --all-targets": +clippy-fix *ARGS="--workspace --all-targets": cargo clippy {{ARGS}} --fix typos: @@ -129,30 +249,127 @@ typos: typos-fix: just typos -w +# Goose AI Recipe Commands + +# Update changelog from staged changes using Goose AI +goose-git-msg: + #!/usr/bin/env bash + set -euo pipefail + goose run --recipe ./misc/recipes/git-commit-message.yaml --interactive + +# Create git message from staged changes using Goose AI +goose-changelog-staged: + #!/usr/bin/env bash + set -euo pipefail + goose run --recipe ./misc/recipes/changelog-update.yaml --interactive + +# Update changelog from recent commits using Goose AI +# Usage: just goose-changelog-commits [number_of_commits] +goose-changelog-commits *COMMITS="5": + #!/usr/bin/env bash + set -euo pipefail + COMMITS={{COMMITS}} goose run --recipe ./misc/recipes/changelog-from-commits.yaml --interactive + itest db: #!/usr/bin/env bash + set -euo pipefail ./misc/itests.sh "{{db}}" - fake-mint-itest db: #!/usr/bin/env bash - ./misc/fake_itests.sh "{{db}}" external_signatory + set -euo pipefail ./misc/fake_itests.sh "{{db}}" + ./misc/fake_itests.sh "{{db}}" external_signatory - itest-payment-processor ln: #!/usr/bin/env bash + set -euo pipefail ./misc/mintd_payment_processor.sh "{{ln}}" - fake-auth-mint-itest db openid_discovery: #!/usr/bin/env bash + set -euo pipefail ./misc/fake_auth_itests.sh "{{db}}" "{{openid_discovery}}" nutshell-wallet-itest: #!/usr/bin/env bash + set -euo pipefail ./misc/nutshell_wallet_itest.sh +# Start interactive regtest environment (Bitcoin + 4 LN nodes + 2 CDK mints) +regtest db="sqlite": + #!/usr/bin/env bash + set -euo pipefail + ./misc/interactive_regtest_mprocs.sh {{db}} + +# Lightning Network Commands (require regtest environment to be running) + +# Get CLN node 1 info +ln-cln1 *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh ln-cln1 {{ARGS}} + +# Get CLN node 2 info +ln-cln2 *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh ln-cln2 {{ARGS}} + +# Get LND node 1 info +ln-lnd1 *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh ln-lnd1 {{ARGS}} + +# Get LND node 2 info +ln-lnd2 *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh ln-lnd2 {{ARGS}} + +# Bitcoin regtest commands +btc *ARGS: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh btc {{ARGS}} + +# Mine blocks in regtest +btc-mine blocks="10": + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh btc-mine {{blocks}} + +# Show mint information +mint-info: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh mint-info + +# Run integration tests against regtest environment +mint-test: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh mint-test + +# Restart mints after recompiling (useful for development) +restart-mints: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh restart-mints + +# Show regtest environment status +regtest-status: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh show-status + +# Show regtest environment logs +regtest-logs: + #!/usr/bin/env bash + set -euo pipefail + ./misc/regtest_helper.sh show-logs + run-examples: cargo r --example p2pk cargo r --example mint-token @@ -187,18 +404,22 @@ release m="": args=( "-p cashu" + "-p cdk-prometheus" "-p cdk-common" + "-p cdk-sql-common" "-p cdk-sqlite" + "-p cdk-postgres" "-p cdk-redb" "-p cdk-signatory" + "-p cdk-fake-wallet" "-p cdk" - "-p cdk-rexie" + "-p cdk-ffi" "-p cdk-axum" "-p cdk-mint-rpc" "-p cdk-cln" "-p cdk-lnd" "-p cdk-lnbits" - "-p cdk-fake-wallet" + "-p cdk-ldk-node" "-p cdk-payment-processor" "-p cdk-cli" "-p cdk-mintd" @@ -211,22 +432,30 @@ release m="": echo done + # Extract version from the cdk-ffi crate + VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name == "cdk-ffi") | .version') + + # Trigger Swift package release after Rust crates are published + echo "📦 Triggering Swift package release for version $VERSION..." + just ffi-release-swift $VERSION + check-docs: #!/usr/bin/env bash set -euo pipefail args=( "-p cashu" "-p cdk-common" + "-p cdk-sql-common" "-p cdk" "-p cdk-redb" "-p cdk-sqlite" "-p cdk-axum" - "-p cdk-rexie" "-p cdk-cln" "-p cdk-lnd" "-p cdk-lnbits" "-p cdk-fake-wallet" "-p cdk-mint-rpc" + "-p cdk-payment-processor" "-p cdk-signatory" "-p cdk-cli" "-p cdk-mintd" @@ -245,17 +474,18 @@ docs-strict: args=( "-p cashu" "-p cdk-common" + "-p cdk-sql-common" "-p cdk" "-p cdk-redb" "-p cdk-sqlite" "-p cdk-axum" - "-p cdk-rexie" "-p cdk-cln" "-p cdk-lnd" "-p cdk-lnbits" "-p cdk-fake-wallet" "-p cdk-mint-rpc" "-p cdk-payment-processor" + "-p cdk-signatory" "-p cdk-cli" "-p cdk-mintd" ) @@ -265,3 +495,155 @@ docs-strict: RUSTDOCFLAGS="-D warnings" cargo doc $arg --all-features --no-deps echo done + +# ============================================================================= +# FFI Commands - CDK Foreign Function Interface bindings +# ============================================================================= + +# Helper function to get library extension based on platform +_ffi-lib-ext: + #!/usr/bin/env bash + if [[ "$OSTYPE" == "darwin"* ]]; then + echo "dylib" + else + echo "so" + fi + +# Build the FFI library +ffi-build *ARGS="--release": + cargo build {{ARGS}} --package cdk-ffi --features postgres + +# Generate bindings for a specific language +ffi-generate LANGUAGE *ARGS="--release": ffi-build + #!/usr/bin/env bash + set -euo pipefail + LANG="{{LANGUAGE}}" + + # Validate language + case "$LANG" in + python|swift|kotlin) + ;; + *) + echo "❌ Unsupported language: $LANG" + echo "Supported languages: python, swift, kotlin" + exit 1 + ;; + esac + + # Set emoji and build type + case "$LANG" in + python) EMOJI="🐍" ;; + swift) EMOJI="🍎" ;; + kotlin) EMOJI="🎯" ;; + esac + + # Determine build type and library path + if [[ "{{ARGS}}" == *"--release"* ]] || [[ "{{ARGS}}" == "" ]]; then + BUILD_TYPE="release" + else + BUILD_TYPE="debug" + cargo build --package cdk-ffi --features postgres + fi + + LIB_EXT=$(just _ffi-lib-ext) + + echo "$EMOJI Generating $LANG bindings..." + mkdir -p target/bindings/$LANG + + cargo run --bin uniffi-bindgen generate \ + --library target/$BUILD_TYPE/libcdk_ffi.$LIB_EXT \ + --language $LANG \ + --out-dir target/bindings/$LANG + + echo "✅ $LANG bindings generated in target/bindings/$LANG/" + +# Generate Python bindings (shorthand) +ffi-generate-python *ARGS="--release": + just ffi-generate python {{ARGS}} + +# Generate Swift bindings (shorthand) +ffi-generate-swift *ARGS="--release": + just ffi-generate swift {{ARGS}} + +# Generate Kotlin bindings (shorthand) +ffi-generate-kotlin *ARGS="--release": + just ffi-generate kotlin {{ARGS}} + +# Generate bindings for all supported languages +ffi-generate-all *ARGS="--release": ffi-build + @echo "🔧 Generating UniFFI bindings for all languages..." + just ffi-generate python {{ARGS}} + just ffi-generate swift {{ARGS}} + just ffi-generate kotlin {{ARGS}} + @echo "✅ All bindings generated successfully!" + +# Run Python FFI tests +ffi-test: ffi-generate-python + #!/usr/bin/env bash + set -euo pipefail + echo "🧪 Running Python FFI tests..." + python3 crates/cdk-ffi/tests/test_transactions.py + echo "✅ Tests completed!" + +# Build debug version and generate Python bindings quickly (for development) +ffi-dev-python: + #!/usr/bin/env bash + set -euo pipefail + + # Generate Python bindings first + just ffi-generate python --debug + + # Copy library to Python bindings directory + LIB_EXT=$(just _ffi-lib-ext) + echo "📦 Copying library to Python bindings directory..." + cp target/debug/libcdk_ffi.$LIB_EXT target/bindings/python/ + + # Launch Python REPL with CDK FFI loaded + cd target/bindings/python + echo "🐍 Launching Python REPL with CDK FFI library loaded..." + echo "💡 The 'cdk_ffi' module is pre-imported and ready to use!" + python3 -i -c "from cdk_ffi import *; print('✅ CDK FFI library loaded successfully!');" + +# Test language bindings with a simple import +ffi-test-bindings LANGUAGE: (ffi-generate LANGUAGE "--debug") + #!/usr/bin/env bash + set -euo pipefail + LANG="{{LANGUAGE}}" + LIB_EXT=$(just _ffi-lib-ext) + + echo "📦 Copying library to $LANG bindings directory..." + cp target/debug/libcdk_ffi.$LIB_EXT target/bindings/$LANG/ + + cd target/bindings/$LANG + echo "🧪 Testing $LANG bindings..." + + case "$LANG" in + python) + python3 -c "import cdk_ffi; print('✅ Python bindings work!')" + ;; + *) + echo "✅ $LANG bindings generated (manual testing required)" + ;; + esac + +# Test Python bindings (shorthand) +ffi-test-python: + just ffi-test-bindings python + +# Trigger Swift Package release workflow +ffi-release-swift VERSION: + #!/usr/bin/env bash + set -euo pipefail + + echo "🚀 Triggering Publish Swift Package workflow..." + echo " Version: {{VERSION}}" + echo " CDK Ref: v{{VERSION}}" + + # Trigger the workflow using GitHub CLI + gh workflow run "Publish Swift Package" \ + --repo cashubtc/cdk-swift \ + --field version="{{VERSION}}" \ + --field cdk_repo="cashubtc/cdk" \ + --field cdk_ref="v{{VERSION}}" + + echo "✅ Workflow triggered successfully!" diff --git a/meetings/2025-04-02-agenda.md b/meetings/2025-04-02-agenda.md new file mode 100644 index 000000000..f000e21c9 --- /dev/null +++ b/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) + diff --git a/meetings/2025-04-09-agenda.md b/meetings/2025-04-09-agenda.md new file mode 100644 index 000000000..3463b5aee --- /dev/null +++ b/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 + + + diff --git a/meetings/2025-04-23-agenda.md b/meetings/2025-04-23-agenda.md new file mode 100644 index 000000000..c9739d89f --- /dev/null +++ b/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 diff --git a/meetings/2025-06-04-agenda.md b/meetings/2025-06-04-agenda.md new file mode 100644 index 000000000..d910f8b7f --- /dev/null +++ b/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 + + diff --git a/meetings/2025-06-11-agenda.md b/meetings/2025-06-11-agenda.md new file mode 100644 index 000000000..810b05a66 --- /dev/null +++ b/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 diff --git a/meetings/2025-06-25-agenda.md b/meetings/2025-06-25-agenda.md new file mode 100644 index 000000000..b86a73ff3 --- /dev/null +++ b/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 diff --git a/meetings/2025-07-02-agenda.md b/meetings/2025-07-02-agenda.md new file mode 100644 index 000000000..332f1f298 --- /dev/null +++ b/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? + diff --git a/meetings/2025-07-09-agenda.md b/meetings/2025-07-09-agenda.md new file mode 100644 index 000000000..a9e678503 --- /dev/null +++ b/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? diff --git a/meetings/2025-07-23-agenda.md b/meetings/2025-07-23-agenda.md new file mode 100644 index 000000000..3921a50b0 --- /dev/null +++ b/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? diff --git a/meetings/2025-07-30-agenda.md b/meetings/2025-07-30-agenda.md new file mode 100644 index 000000000..1e53e8701 --- /dev/null +++ b/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) diff --git a/meetings/2025-08-05-agenda.md b/meetings/2025-08-05-agenda.md new file mode 100644 index 000000000..4cfaaab0d --- /dev/null +++ b/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) diff --git a/meetings/2025-08-13-agenda.md b/meetings/2025-08-13-agenda.md new file mode 100644 index 000000000..d9f6c9642 --- /dev/null +++ b/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) diff --git a/meetings/2025-08-20-agenda.md b/meetings/2025-08-20-agenda.md new file mode 100644 index 000000000..8ed7dc7d3 --- /dev/null +++ b/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 diff --git a/meetings/2025-08-27-agenda.md b/meetings/2025-08-27-agenda.md new file mode 100644 index 000000000..a35d76284 --- /dev/null +++ b/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> with just Vec +- [#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 + + diff --git a/meetings/2025-09-03-agenda.md b/meetings/2025-09-03-agenda.md new file mode 100644 index 000000000..936c5e4ed --- /dev/null +++ b/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> with just Vec +- [#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 + + diff --git a/meetings/2025-09-10-agenda.md b/meetings/2025-09-10-agenda.md new file mode 100644 index 000000000..8cc2379c0 --- /dev/null +++ b/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 diff --git a/meetings/2025-09-17-agenda.md b/meetings/2025-09-17-agenda.md new file mode 100644 index 000000000..304419fbc --- /dev/null +++ b/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 diff --git a/meetings/2025-09-24-agenda.md b/meetings/2025-09-24-agenda.md new file mode 100644 index 000000000..22194604b --- /dev/null +++ b/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 + diff --git a/meetings/2025-10-08-agenda.md b/meetings/2025-10-08-agenda.md new file mode 100644 index 000000000..335c38713 --- /dev/null +++ b/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 + diff --git a/meetings/2025-10-15-agenda.md b/meetings/2025-10-15-agenda.md new file mode 100644 index 000000000..6b61ce2f7 --- /dev/null +++ b/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 + + diff --git a/meetings/2025-10-27-agenda.md b/meetings/2025-10-27-agenda.md new file mode 100644 index 000000000..b1c7f029c --- /dev/null +++ b/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 diff --git a/meetings/2025-10-29-agenda.md b/meetings/2025-10-29-agenda.md new file mode 100644 index 000000000..495338053 --- /dev/null +++ b/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 diff --git a/meetings/2025-11-05-agenda.md b/meetings/2025-11-05-agenda.md new file mode 100644 index 000000000..559505f40 --- /dev/null +++ b/meetings/2025-11-05-agenda.md @@ -0,0 +1,59 @@ +# CDK Development Meeting + +Nov 05 2025 15:00 UTC + +Meeting Link: https://meet.fulmo.org/cdk-dev + +## Merged + +- [#1250](https://github.com/cashubtc/cdk/pull/1250) - fix: add proof recovery mechanism for failed wallet operations +- [#1246](https://github.com/cashubtc/cdk/pull/1246) - Fix websocket issues and mint quotes +- [#1245](https://github.com/cashubtc/cdk/pull/1245) - [Backport v0.13.x] fix: lnbits fee calc +- [#1244](https://github.com/cashubtc/cdk/pull/1244) - PreMintSecrets: fix `into_iter()` +- [#1243](https://github.com/cashubtc/cdk/pull/1243) - fix: lnbits fee calc +- [#1242](https://github.com/cashubtc/cdk/pull/1242) - Ldk compose setup +- [#1241](https://github.com/cashubtc/cdk/pull/1241) - Include cargo config for cdk-ffi to enforce Android page sizes +- [#1239](https://github.com/cashubtc/cdk/pull/1239) - Weekly Meeting Agenda - 2025-10-29 +- [#1186](https://github.com/cashubtc/cdk/pull/1186) - Melt saga + +## Ongoing + +- [#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 +- [#1200](https://github.com/cashubtc/cdk/pull/1200) - feat: optimize pending mint quotes query performance +- [#1198](https://github.com/cashubtc/cdk/pull/1198) - fix: check the removed_ys argument before creating the delete query +- [#1196](https://github.com/cashubtc/cdk/pull/1196) - feat(ci): add merge queue workflow and simplify clippy checks +- [#1190](https://github.com/cashubtc/cdk/pull/1190) - feat(cashu): add NUT-26 bech32m encoding for payment requests +- [#1182](https://github.com/cashubtc/cdk/pull/1182) - ehash: add support for mining share mint quotes +- [#1181](https://github.com/cashubtc/cdk/pull/1181) - Deterministic Currency Unit Derivation Paths +- [#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 +- [#1011](https://github.com/cashubtc/cdk/pull/1011) - fix: migrate check_mint_quote_paid fn from ln.rs to mod.rs + +## New + +### Issues + +- [#1249](https://github.com/cashubtc/cdk/issues/1249) - [feature] support CLN remote gRPC and/or REST (not just socket) + +### PRs + +- [#1253](https://github.com/cashubtc/cdk/pull/1253) - feat: P2BK +- [#1252](https://github.com/cashubtc/cdk/pull/1252) - Fix race condition when concurrent payments are processed for the same payment_id +- [#1251](https://github.com/cashubtc/cdk/pull/1251) - feat: custom axum router +- [#1247](https://github.com/cashubtc/cdk/pull/1247) - feat: add keyset_amounts table to track issued and redeemed amounts +- [#1240](https://github.com/cashubtc/cdk/pull/1240) - Introduce KeyManager for the wallet diff --git a/meetings/2025-11-12-agenda.md b/meetings/2025-11-12-agenda.md new file mode 100644 index 000000000..3fd8494f6 --- /dev/null +++ b/meetings/2025-11-12-agenda.md @@ -0,0 +1,54 @@ +# CDK Development Meeting + +Nov 12 2025 15:00 UTC + +Meeting Link: https://meet.fulmo.org/cdk-dev + +## Merged + +- [#1258](https://github.com/cashubtc/cdk/pull/1258) - mint async melt +- [#1256](https://github.com/cashubtc/cdk/pull/1256) - refactor: replace proof swap with state check in error recovery +- [#1254](https://github.com/cashubtc/cdk/pull/1254) - Weekly Meeting Agenda - 2025-11-05 +- [#1247](https://github.com/cashubtc/cdk/pull/1247) - feat: add keyset_amounts table to track issued and redeemed amounts + +## Ongoing + +- [#1253](https://github.com/cashubtc/cdk/pull/1253) - feat: P2BK +- [#1252](https://github.com/cashubtc/cdk/pull/1252) - Fix race condition when concurrent payments are processed for the same payment_id +- [#1251](https://github.com/cashubtc/cdk/pull/1251) - feat: custom axum router +- [#1240](https://github.com/cashubtc/cdk/pull/1240) - Introduce MintMetadataCache for efficient key and metadata management +- [#1214](https://github.com/cashubtc/cdk/pull/1214) - feat(cdk-payment-processor): add currency unit parameter to make_payment +- [#1212](https://github.com/cashubtc/cdk/pull/1212) - Various mint bugfixes for swap and melt. SIG_INPUTS+SIG_ALL, locktimes, P2PK+HTLC. Also updates the SIG_ALL message for amount-switching +- [#1211](https://github.com/cashubtc/cdk/pull/1211) - Regtest setup +- [#1210](https://github.com/cashubtc/cdk/pull/1210) - test: add mutation testing infrastructure +- [#1208](https://github.com/cashubtc/cdk/pull/1208) - Sig all fixes +- [#1204](https://github.com/cashubtc/cdk/pull/1204) - Add database transaction trait for cdk wallet +- [#1202](https://github.com/cashubtc/cdk/pull/1202) - Onchain +- [#1201](https://github.com/cashubtc/cdk/pull/1201) - feat: npubcash +- [#1200](https://github.com/cashubtc/cdk/pull/1200) - feat: optimize pending mint quotes query performance +- [#1198](https://github.com/cashubtc/cdk/pull/1198) - fix: check the removed_ys argument before creating the delete query +- [#1196](https://github.com/cashubtc/cdk/pull/1196) - feat(ci): add merge queue workflow and simplify clippy checks +- [#1190](https://github.com/cashubtc/cdk/pull/1190) - feat(cashu): add NUT-26 bech32m encoding for payment requests +- [#1182](https://github.com/cashubtc/cdk/pull/1182) - ehash: add support for mining share mint quotes +- [#1181](https://github.com/cashubtc/cdk/pull/1181) - Deterministic Currency Unit Derivation Paths +- [#1171](https://github.com/cashubtc/cdk/pull/1171) - Add Dart Bindings Support +- [#1153](https://github.com/cashubtc/cdk/pull/1153) - Add cdk-mintd module and package +- [#1132](https://github.com/cashubtc/cdk/pull/1132) - Quote id as lookup +- [#1127](https://github.com/cashubtc/cdk/pull/1127) - rename ln settings in toml configuration +- [#1118](https://github.com/cashubtc/cdk/pull/1118) - feat: uniffi bindings for golang +- [#1100](https://github.com/cashubtc/cdk/pull/1100) - NUT-XX: Cairo Spending Conditions implementation +- [#1067](https://github.com/cashubtc/cdk/pull/1067) - Nutxx ohttp +- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive +- [#1049](https://github.com/cashubtc/cdk/pull/1049) - feat: ldk-node run mintd +- [#1011](https://github.com/cashubtc/cdk/pull/1011) - fix: migrate check_mint_quote_paid fn from ln.rs to mod.rs + +## New + +### Issues + +- [#1259](https://github.com/cashubtc/cdk/issues/1259) - Suggested changes in the payment_processor.proto declaration + +### PRs + +- [#1260](https://github.com/cashubtc/cdk/pull/1260) - feat(ci): add nightly rustfmt automation with flexible formatting policy +- [#1257](https://github.com/cashubtc/cdk/pull/1257) - bring signatory up to date with the remote signer spec diff --git a/meetings/2025-11-19-agenda.md b/meetings/2025-11-19-agenda.md new file mode 100644 index 000000000..076ed1561 --- /dev/null +++ b/meetings/2025-11-19-agenda.md @@ -0,0 +1,77 @@ +# CDK Development Meeting + +Nov 19 2025 15:00 UTC + +Meeting Link: https://meet.fulmo.org/cdk-dev + +## New + +### Issues + +- [#1276](https://github.com/cashubtc/cdk/issues/1276) - Add wallet-side validation to prevent impossible multisig configurations in `Conditions::new()` +- [#1273](https://github.com/cashubtc/cdk/issues/1273) - Add KV store wallet like we have for the mint +- [#1267](https://github.com/cashubtc/cdk/issues/1267) - Async melt for wallet + +### PRs + +- [#1303](https://github.com/cashubtc/cdk/pull/1303) - New get pending +- [#1302](https://github.com/cashubtc/cdk/pull/1302) - release v0.14.0 +- [#1301](https://github.com/cashubtc/cdk/pull/1301) - docs: Update cdk-mintd README to include prerequisites and system-wide install + +## Recently Active + +- [#1257](https://github.com/cashubtc/cdk/pull/1257) - bring signatory up to date with the remote signer spec +- [#1253](https://github.com/cashubtc/cdk/pull/1253) - feat: P2BK +- [#1251](https://github.com/cashubtc/cdk/pull/1251) - feat: custom axum router +- [#1211](https://github.com/cashubtc/cdk/pull/1211) - Regtest setup +- [#1204](https://github.com/cashubtc/cdk/pull/1204) - Add database transaction trait for cdk wallet +- [#1201](https://github.com/cashubtc/cdk/pull/1201) - feat: npubcash +- [#1181](https://github.com/cashubtc/cdk/pull/1181) - Deterministic Currency Unit Derivation Paths +- [#1171](https://github.com/cashubtc/cdk/pull/1171) - Add Dart Bindings Support +- [#1053](https://github.com/cashubtc/cdk/pull/1053) - feat: P2PK key storage and auto-sign on receive +- [#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 + +## Merged + +- [#1304](https://github.com/cashubtc/cdk/pull/1304) - Fix race condition when concurrent payments are processed for the same payment_id +- [#1300](https://github.com/cashubtc/cdk/pull/1300) - Prevent database contention in metadata cache load operations +- [#1299](https://github.com/cashubtc/cdk/pull/1299) - fix: Enable pure environment variable configuration for Lightning backends +- [#1298](https://github.com/cashubtc/cdk/pull/1298) - fix: nightly ci +- [#1297](https://github.com/cashubtc/cdk/pull/1297) - fix: allow starting insecure man server +- [#1296](https://github.com/cashubtc/cdk/pull/1296) - refactor(cdk/wallet): extract keyset key loading into helper method +- [#1295](https://github.com/cashubtc/cdk/pull/1295) - feat(cdk): add Lightning address support with BIP353 fallback +- [#1294](https://github.com/cashubtc/cdk/pull/1294) - feat(cdk): add invoice decoding for bolt11 and bolt12 +- [#1293](https://github.com/cashubtc/cdk/pull/1293) - feat: add test coverage for mutants caught in https://github.com/cash… +- [#1292](https://github.com/cashubtc/cdk/pull/1292) - fix: flaky test by using wait and pay +- [#1291](https://github.com/cashubtc/cdk/pull/1291) - fix: load keyset keys from database to prevent duplicate insertions +- [#1289](https://github.com/cashubtc/cdk/pull/1289) - chore: change mutation testing ci time +- [#1288](https://github.com/cashubtc/cdk/pull/1288) - fix: we use the nightly flake so we don't need +nightly +- [#1287](https://github.com/cashubtc/cdk/pull/1287) - chore: fix some minor issues in comments +- [#1284](https://github.com/cashubtc/cdk/pull/1284) - ci: reduce ci jobs +- [#1280](https://github.com/cashubtc/cdk/pull/1280) - Don't read keys from the database +- [#1278](https://github.com/cashubtc/cdk/pull/1278) - Fix missing try_proof_operation_or_reclaim wrapping of a swap +- [#1277](https://github.com/cashubtc/cdk/pull/1277) - Update Wallet::fetch_mint_info +- [#1275](https://github.com/cashubtc/cdk/pull/1275) - fix: require 0 signatures for HTLC with no pubkeys specified +- [#1274](https://github.com/cashubtc/cdk/pull/1274) - fix: return actual error from get_payment_quote +- [#1269](https://github.com/cashubtc/cdk/pull/1269) - fix: nut14 disabled in info +- [#1268](https://github.com/cashubtc/cdk/pull/1268) - Metadata follow up +- [#1266](https://github.com/cashubtc/cdk/pull/1266) - chore: meeting agenda fmt +- [#1265](https://github.com/cashubtc/cdk/pull/1265) - chore: update stable rust to 1.91.1 +- [#1262](https://github.com/cashubtc/cdk/pull/1262) - chore: rust version workflow +- [#1261](https://github.com/cashubtc/cdk/pull/1261) - Weekly Meeting Agenda - 2025-11-12 +- [#1260](https://github.com/cashubtc/cdk/pull/1260) - feat(ci): add nightly rustfmt automation with flexible formatting policy +- [#1240](https://github.com/cashubtc/cdk/pull/1240) - Introduce MintMetadataCache for efficient key and metadata management +- [#1212](https://github.com/cashubtc/cdk/pull/1212) - Various mint bugfixes for swap and melt. SIG_INPUTS+SIG_ALL, locktimes, P2PK+HTLC. Also updates the SIG_ALL message for amount-switching +- [#1210](https://github.com/cashubtc/cdk/pull/1210) - test: add mutation testing infrastructure + +## Release + +- [#1302](https://github.com/cashubtc/cdk/pull/1302) - release v0.14.0 + +## Discussion + +- Mint management RPC +- Cashu Spillman channels +- Self hosted CI defined in nix - https://github.com/thesimplekid/cdk-infra + diff --git a/meetings/2025-11-26-agenda.md b/meetings/2025-11-26-agenda.md new file mode 100644 index 000000000..f92b7127b --- /dev/null +++ b/meetings/2025-11-26-agenda.md @@ -0,0 +1,68 @@ +# CDK Development Meeting + +Nov 26 2025 15:00 UTC + +Meeting Link: https://meet.fulmo.org/cdk-dev + +## Merged + +- [#1343](https://github.com/cashubtc/cdk/pull/1343) - fix: use the client id from mint configuration +- [#1341](https://github.com/cashubtc/cdk/pull/1341) - [Backport v0.14.x] fix: check melt quote in ffi +- [#1340](https://github.com/cashubtc/cdk/pull/1340) - fix: check melt quote in ffi +- [#1339](https://github.com/cashubtc/cdk/pull/1339) - [Backport v0.14.x] feat: cdk-ffi get wallets +- [#1335](https://github.com/cashubtc/cdk/pull/1335) - [Backport v0.14.x] feat: multimint_ffi melt with mint +- [#1334](https://github.com/cashubtc/cdk/pull/1334) - feat: remove deprecated paid field +- [#1333](https://github.com/cashubtc/cdk/pull/1333) - feat: cdk-ffi get wallets +- [#1332](https://github.com/cashubtc/cdk/pull/1332) - feat: multimint_ffi melt with mint +- [#1330](https://github.com/cashubtc/cdk/pull/1330) - Batch websocket notification reads in integration tests +- [#1329](https://github.com/cashubtc/cdk/pull/1329) - Remove max_order from keyset database schema +- [#1328](https://github.com/cashubtc/cdk/pull/1328) - [Backport v0.14.x] feat(multi-mint-wallet): add human-readable address melt quote support +- [#1326](https://github.com/cashubtc/cdk/pull/1326) - [Backport v0.14.x] Load mint info +- [#1325](https://github.com/cashubtc/cdk/pull/1325) - feat(multi-mint-wallet): add human-readable address melt quote support +- [#1324](https://github.com/cashubtc/cdk/pull/1324) - [Backport v0.14.x] Cdk ffi psgl +- [#1323](https://github.com/cashubtc/cdk/pull/1323) - Load mint info +- [#1322](https://github.com/cashubtc/cdk/pull/1322) - [Backport v0.14.x] Revert "Prevent database contention in metadata cache load operations… +- [#1321](https://github.com/cashubtc/cdk/pull/1321) - Cdk ffi psgl +- [#1320](https://github.com/cashubtc/cdk/pull/1320) - Revert "Prevent database contention in metadata cache load operations… +- [#1318](https://github.com/cashubtc/cdk/pull/1318) - [Backport v0.14.x] chore: release justfile +- [#1317](https://github.com/cashubtc/cdk/pull/1317) - chore: release justfile +- [#1314](https://github.com/cashubtc/cdk/pull/1314) - Deduplicate mint quote database queries +- [#1312](https://github.com/cashubtc/cdk/pull/1312) - fix: ldk-node account for ln fee +- [#1309](https://github.com/cashubtc/cdk/pull/1309) - fix: remove use of unwrap or default in melt saga +- [#1306](https://github.com/cashubtc/cdk/pull/1306) - fix: add missing comma in get_mint_quotes +- [#1305](https://github.com/cashubtc/cdk/pull/1305) - Weekly Meeting Agenda - 2025-11-19 +- [#1302](https://github.com/cashubtc/cdk/pull/1302) - release v0.14.0 + +## New + +### Issues + +- [#1338](https://github.com/cashubtc/cdk/issues/1338) - Update error codes to spec +- [#1336](https://github.com/cashubtc/cdk/issues/1336) - Expose fn to get quote state updates to bindings +- [#1331](https://github.com/cashubtc/cdk/issues/1331) - DLEQ deserialization error stops token from being decoded at all +- [#1319](https://github.com/cashubtc/cdk/issues/1319) - Error message when insufficient fee is unclear that its a fee issue +- [#1316](https://github.com/cashubtc/cdk/issues/1316) - 🧬 Weekly Mutation Testing Report - 2025-11-21 +- [#1310](https://github.com/cashubtc/cdk/issues/1310) - Consider removing clone from Mint and Wallet structs +- [#1308](https://github.com/cashubtc/cdk/issues/1308) - PaymentRequest missing from the FFI crate +- [#1307](https://github.com/cashubtc/cdk/issues/1307) - Add transaction tracking to the mint + +### PRs + +- [#1347](https://github.com/cashubtc/cdk/pull/1347) - [Backport v0.14.x] fix: use the client id from mint configuration +- [#1345](https://github.com/cashubtc/cdk/pull/1345) - melt fees +- [#1337](https://github.com/cashubtc/cdk/pull/1337) - Simplify increment_issued_quote implementation +- [#1327](https://github.com/cashubtc/cdk/pull/1327) - Get proofs for tx +- [#1311](https://github.com/cashubtc/cdk/pull/1311) - feat: add operations table + +## Recently Active + +- [#1303](https://github.com/cashubtc/cdk/pull/1303) - New get pending +- [#1251](https://github.com/cashubtc/cdk/pull/1251) - feat: custom axum router +- [#1204](https://github.com/cashubtc/cdk/pull/1204) - Add database transaction trait for cdk wallet +- [#1100](https://github.com/cashubtc/cdk/pull/1100) - NUT-XX: Cairo Spending Conditions implementation +- [#1010](https://github.com/cashubtc/cdk/pull/1010) - adding more LDK configuration settings +- [#1003](https://github.com/cashubtc/cdk/pull/1003) - feat: LDK Lightning KVStore support with PostgreSQL integration + +## Discussion + +- None diff --git a/misc/fake_auth_itests.sh b/misc/fake_auth_itests.sh index cd70345bf..025311047 100755 --- a/misc/fake_auth_itests.sh +++ b/misc/fake_auth_itests.sh @@ -1,31 +1,37 @@ - #!/usr/bin/env bash # Function to perform cleanup cleanup() { echo "Cleaning up..." - echo "Killing the cdk mintd" - kill -2 $cdk_mintd_pid - wait $cdk_mintd_pid + if [ -n "$FAKE_AUTH_MINT_PID" ]; then + echo "Killing the fake auth mint process" + kill -2 $FAKE_AUTH_MINT_PID 2>/dev/null || true + wait $FAKE_AUTH_MINT_PID 2>/dev/null || true + fi echo "Mint binary terminated" # Remove the temporary directory - rm -rf "$CDK_ITESTS_DIR" - echo "Temp directory removed: $CDK_ITESTS_DIR" + if [ -n "$CDK_ITESTS_DIR" ] && [ -d "$CDK_ITESTS_DIR" ]; then + rm -rf "$CDK_ITESTS_DIR" + echo "Temp directory removed: $CDK_ITESTS_DIR" + fi + + # Unset all environment variables unset CDK_ITESTS_DIR unset CDK_ITESTS_MINT_ADDR unset CDK_ITESTS_MINT_PORT + unset FAKE_AUTH_MINT_PID } # Set up trap to call cleanup on script exit -trap cleanup EXIT +trap cleanup EXIT INT TERM # Create a temporary directory export CDK_ITESTS_DIR=$(mktemp -d) -export CDK_ITESTS_MINT_ADDR="127.0.0.1"; -export CDK_ITESTS_MINT_PORT=8087; +export CDK_ITESTS_MINT_ADDR="127.0.0.1" +export CDK_ITESTS_MINT_PORT=8087 # Check if the temporary directory was created successfully if [[ ! -d "$CDK_ITESTS_DIR" ]]; then @@ -34,72 +40,41 @@ if [[ ! -d "$CDK_ITESTS_DIR" ]]; then fi echo "Temp directory created: $CDK_ITESTS_DIR" -export MINT_DATABASE="$1"; -export OPENID_DISCOVERY="$2"; -cargo build -p cdk-integration-tests +# Check if a database type was provided as first argument, default to sqlite +export MINT_DATABASE="${1:-sqlite}" + +# Check if OPENID_DISCOVERY was provided as second argument, default to a test value +export OPENID_DISCOVERY="${2:-http://127.0.0.1:8080/realms/cdk-test-realm/.well-known/openid-configuration}" -export CDK_MINTD_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT"; -export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR"; -export CDK_MINTD_LISTEN_HOST=$CDK_ITESTS_MINT_ADDR; -export CDK_MINTD_LISTEN_PORT=$CDK_ITESTS_MINT_PORT; -export CDK_MINTD_LN_BACKEND="fakewallet"; -export CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS="sat"; -export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal"; -export CDK_MINTD_FAKE_WALLET_FEE_PERCENT="0"; -export CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN="1"; -export CDK_MINTD_DATABASE=$MINT_DATABASE; +# Build the project +cargo build -p cdk-integration-tests # Auth configuration -export CDK_TEST_OIDC_USER="cdk-test"; -export CDK_TEST_OIDC_PASSWORD="cdkpassword"; - -export CDK_MINTD_AUTH_OPENID_DISCOVERY=$OPENID_DISCOVERY; -export CDK_MINTD_AUTH_OPENID_CLIENT_ID="cashu-client"; -export CDK_MINTD_AUTH_MINT_MAX_BAT="50"; -export CDK_MINTD_AUTH_ENABLED_MINT="true"; -export CDK_MINTD_AUTH_ENABLED_MELT="true"; -export CDK_MINTD_AUTH_ENABLED_SWAP="true"; -export CDK_MINTD_AUTH_ENABLED_CHECK_MINT_QUOTE="true"; -export CDK_MINTD_AUTH_ENABLED_CHECK_MELT_QUOTE="true"; -export CDK_MINTD_AUTH_ENABLED_RESTORE="true"; -export CDK_MINTD_AUTH_ENABLED_CHECK_PROOF_STATE="true"; - -echo "Starting auth mintd"; -cargo run --bin cdk-mintd --features redb & -cdk_mintd_pid=$! - -URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT/v1/info" -TIMEOUT=100 -START_TIME=$(date +%s) -# Loop until the endpoint returns a 200 OK status or timeout is reached -while true; do - # Get the current time - CURRENT_TIME=$(date +%s) - - # Calculate the elapsed time - ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) +export CDK_TEST_OIDC_USER="cdk-test" +export CDK_TEST_OIDC_PASSWORD="cdkpassword" - # Check if the elapsed time exceeds the timeout - if [ $ELAPSED_TIME -ge $TIMEOUT ]; then - echo "Timeout of $TIMEOUT seconds reached. Exiting..." - exit 1 - fi +# Start the fake auth mint in the background +echo "Starting fake auth mint with discovery URL: $OPENID_DISCOVERY" +echo "Using temp directory: $CDK_ITESTS_DIR" +cargo run -p cdk-integration-tests --bin start_fake_auth_mint -- --enable-logging "$MINT_DATABASE" "$CDK_ITESTS_DIR" "$OPENID_DISCOVERY" "$CDK_ITESTS_MINT_PORT" & - # Make a request to the endpoint and capture the HTTP status code - HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" $URL) +# Store the PID of the mint process +FAKE_AUTH_MINT_PID=$! - # Check if the HTTP status is 200 OK - if [ "$HTTP_STATUS" -eq 200 ]; then - echo "Received 200 OK from $URL" - break - else - echo "Waiting for 200 OK response, current status: $HTTP_STATUS" - sleep 2 # Wait for 2 seconds before retrying - fi -done +# Wait a moment for the mint to start +sleep 5 + +# Check if the mint is running +if ! kill -0 $FAKE_AUTH_MINT_PID 2>/dev/null; then + echo "Failed to start fake auth mint" + exit 1 +fi + +echo "Fake auth mint started with PID: $FAKE_AUTH_MINT_PID" # Run cargo test +echo "Running fake auth integration tests..." cargo test -p cdk-integration-tests --test fake_auth # Capture the exit status of cargo test diff --git a/misc/fake_itests.sh b/misc/fake_itests.sh index 9ff24284c..a9caab5d0 100755 --- a/misc/fake_itests.sh +++ b/misc/fake_itests.sh @@ -1,46 +1,49 @@ #!/usr/bin/env bash +# Script to run fake mint tests with proper handling of race conditions +# This script ensures the .env file is properly created and available +# before running tests + # Function to perform cleanup cleanup() { echo "Cleaning up..." - echo "Killing the cdk mintd" - kill -2 $CDK_MINTD_PID - wait $CDK_MINTD_PID - kill -9 $CDK_SIGNATORY_PID - wait $CDK_SIGNATORY_PID + if [ -n "$FAKE_MINT_PID" ]; then + echo "Killing the fake mint process" + kill -2 $FAKE_MINT_PID 2>/dev/null || true + wait $FAKE_MINT_PID 2>/dev/null || true + fi + + if [ -n "$CDK_SIGNATORY_PID" ]; then + echo "Killing the signatory process" + kill -9 $CDK_SIGNATORY_PID 2>/dev/null || true + wait $CDK_SIGNATORY_PID 2>/dev/null || true + fi echo "Mint binary terminated" # Remove the temporary directory - rm -rf "$CDK_ITESTS_DIR" - echo "Temp directory removed: $CDK_ITESTS_DIR" + if [ -n "$CDK_ITESTS_DIR" ] && [ -d "$CDK_ITESTS_DIR" ]; then + rm -rf "$CDK_ITESTS_DIR" + echo "Temp directory removed: $CDK_ITESTS_DIR" + fi + + if [ -n "$CONTAINER_NAME" ]; then + docker rm "${CONTAINER_NAME}" -f + fi # Unset all environment variables unset CDK_ITESTS_DIR - unset CDK_ITESTS_MINT_ADDR - unset CDK_ITESTS_MINT_PORT - unset CDK_MINTD_DATABASE unset CDK_TEST_MINT_URL - unset CDK_MINTD_URL - unset CDK_MINTD_WORK_DIR - unset CDK_MINTD_LISTEN_HOST - unset CDK_MINTD_LISTEN_PORT - unset CDK_MINTD_LN_BACKEND - unset CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS - unset CDK_MINTD_MNEMONIC - unset CDK_MINTD_FAKE_WALLET_FEE_PERCENT - unset CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN - unset CDK_MINTD_PID + unset FAKE_MINT_PID + unset CDK_SIGNATORY_PID } # Set up trap to call cleanup on script exit -trap cleanup EXIT +trap cleanup EXIT INT TERM # Create a temporary directory export CDK_ITESTS_DIR=$(mktemp -d) -export CDK_ITESTS_MINT_ADDR="127.0.0.1" -export CDK_ITESTS_MINT_PORT=8086 # Check if the temporary directory was created successfully if [[ ! -d "$CDK_ITESTS_DIR" ]]; then @@ -49,36 +52,99 @@ if [[ ! -d "$CDK_ITESTS_DIR" ]]; then fi echo "Temp directory created: $CDK_ITESTS_DIR" -export CDK_MINTD_DATABASE="$1" -cargo build -p cdk-integration-tests +# Check if a database type was provided as first argument, default to sqlite +export CDK_MINTD_DATABASE="${1:-sqlite}" +cargo build -p cdk-integration-tests -export CDK_MINTD_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT" -export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR" -export CDK_MINTD_LISTEN_HOST=$CDK_ITESTS_MINT_ADDR -export CDK_MINTD_LISTEN_PORT=$CDK_ITESTS_MINT_PORT -export CDK_MINTD_LN_BACKEND="fakewallet" -export CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS="sat,usd" -export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal" -export CDK_MINTD_FAKE_WALLET_FEE_PERCENT="0" -export CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN="1" +# Start the fake mint binary with the new Rust-based approach +echo "Starting fake mint using Rust binary..." + +if [ "${CDK_MINTD_DATABASE}" = "POSTGRES" ]; then + export CONTAINER_NAME="rust-fake-test-pg" + DB_USER="test" + DB_PASS="test" + DB_NAME="testdb" + DB_PORT="15433" + + docker run -d --rm \ + --name "${CONTAINER_NAME}" \ + -e POSTGRES_USER="${DB_USER}" \ + -e POSTGRES_PASSWORD="${DB_PASS}" \ + -e POSTGRES_DB="${DB_NAME}" \ + -p ${DB_PORT}:5432 \ + postgres:16 + export CDK_MINTD_DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:${DB_PORT}/${DB_NAME}" + + echo "Waiting for PostgreSQL to be ready and database '${DB_NAME}' to exist..." + until docker exec -e PGPASSWORD="${DB_PASS}" "${CONTAINER_NAME}" \ + psql -U "${DB_USER}" -d "${DB_NAME}" -c "SELECT 1;" >/dev/null 2>&1; do + sleep 0.5 + done + echo "PostgreSQL container is ready" +fi if [ "$2" = "external_signatory" ]; then - export CDK_MINTD_SIGNATORY_URL="https://127.0.0.1:15060" - export CDK_MINTD_SIGNATORY_CERTS="$CDK_ITESTS_DIR" + echo "Starting with external signatory support" + bash -x `dirname $0`/../crates/cdk-signatory/generate_certs.sh $CDK_ITESTS_DIR + cargo build --bin signatory cargo run --bin signatory -- -w $CDK_ITESTS_DIR -u "sat" -u "usd" & export CDK_SIGNATORY_PID=$! sleep 5 + + cargo run --bin start_fake_mint -- --enable-logging --external-signatory "$CDK_MINTD_DATABASE" "$CDK_ITESTS_DIR" & +else + cargo run --bin start_fake_mint -- --enable-logging "$CDK_MINTD_DATABASE" "$CDK_ITESTS_DIR" & +fi +export FAKE_MINT_PID=$! + +# Give the mint a moment to start +sleep 3 + +# Look for the .env file in the temp directory +ENV_FILE_PATH="$CDK_ITESTS_DIR/.env" + +# Wait for the .env file to be created (with longer timeout) +max_wait=200 +wait_count=0 +while [ $wait_count -lt $max_wait ]; do + if [ -f "$ENV_FILE_PATH" ]; then + echo ".env file found at: $ENV_FILE_PATH" + break + fi + echo "Waiting for .env file to be created... ($wait_count/$max_wait)" + wait_count=$((wait_count + 1)) + sleep 1 +done + +# Check if we found the .env file +if [ ! -f "$ENV_FILE_PATH" ]; then + echo "ERROR: Could not find .env file at $ENV_FILE_PATH after $max_wait seconds" + exit 1 +fi + +# Source the environment variables from the .env file +echo "Sourcing environment variables from $ENV_FILE_PATH" +source "$ENV_FILE_PATH" + +echo "Sourced environment variables:" +echo "CDK_TEST_MINT_URL=$CDK_TEST_MINT_URL" +echo "CDK_ITESTS_DIR=$CDK_ITESTS_DIR" + +# Validate that we sourced the variables +if [ -z "$CDK_TEST_MINT_URL" ] || [ -z "$CDK_ITESTS_DIR" ]; then + echo "ERROR: Failed to source environment variables from the .env file" + exit 1 fi -echo "Starting fake mintd" -cargo run --bin cdk-mintd & -export CDK_MINTD_PID=$! +# Export all variables so they're available to the tests +export CDK_TEST_MINT_URL -URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT/v1/info" -TIMEOUT=100 +URL="$CDK_TEST_MINT_URL/v1/info" + +TIMEOUT=120 START_TIME=$(date +%s) # Loop until the endpoint returns a 200 OK status or timeout is reached while true; do @@ -107,11 +173,9 @@ while true; do fi done - -export CDK_TEST_MINT_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT" - # Run first test -cargo test -p cdk-integration-tests --test fake_wallet +echo "Running fake_wallet test" +cargo test -p cdk-integration-tests --test fake_wallet -- --nocapture status1=$? # Exit immediately if the first test failed @@ -121,15 +185,27 @@ if [ $status1 -ne 0 ]; then fi # Run second test only if the first one succeeded -cargo test -p cdk-integration-tests --test happy_path_mint_wallet +echo "Running happy_path_mint_wallet test" +cargo test -p cdk-integration-tests --test happy_path_mint_wallet -- --nocapture status2=$? -# Exit with the status of the second test +# Exit if the second test failed if [ $status2 -ne 0 ]; then echo "Second test failed with status $status2, exiting" exit $status2 fi -# Both tests passed +# Run third test (async_melt) only if previous tests succeeded +echo "Running async_melt test" +cargo test -p cdk-integration-tests --test async_melt +status3=$? + +# Exit with the status of the third test +if [ $status3 -ne 0 ]; then + echo "Third test (async_melt) failed with status $status3, exiting" + exit $status3 +fi + +# All tests passed echo "All tests passed successfully" exit 0 diff --git a/misc/interactive_regtest_mprocs.sh b/misc/interactive_regtest_mprocs.sh new file mode 100755 index 000000000..e86215c42 --- /dev/null +++ b/misc/interactive_regtest_mprocs.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env bash + +# Interactive Regtest Environment for CDK with Direct Process Management +# This script sets up mprocs to manage the mint processes directly + +set -e + +# Function to wait for HTTP endpoint +wait_for_endpoint() { + local url=$1 + local timeout=${2:-60} + local start_time=$(date +%s) + + while true; do + local current_time=$(date +%s) + local elapsed_time=$((current_time - start_time)) + + if [ $elapsed_time -ge $timeout ]; then + echo "❌ Timeout waiting for $url" + return 1 + fi + + local http_status=$(curl -o /dev/null -s -w "%{http_code}" "$url" 2>/dev/null || echo "000") + + if [ "$http_status" -eq 200 ]; then + echo "✓ $url is ready" + return 0 + fi + + sleep 2 + done +} + +# Function to perform cleanup +cleanup() { + echo "Cleaning up..." + + # Remove state file for other sessions + rm -f "/tmp/cdk_regtest_env" + + if [ ! -z "$CDK_REGTEST_PID" ] && kill -0 $CDK_REGTEST_PID 2>/dev/null; then + echo "Killing the cdk regtest" + kill -2 $CDK_REGTEST_PID + wait $CDK_REGTEST_PID + fi + + echo "Environment terminated" + + # Remove the temporary directory + if [ ! -z "$CDK_ITESTS_DIR" ]; then + rm -rf "$CDK_ITESTS_DIR" + echo "Temp directory removed: $CDK_ITESTS_DIR" + fi + + # Unset all environment variables + unset CDK_ITESTS_DIR + unset CDK_ITESTS_MINT_ADDR + unset CDK_ITESTS_MINT_PORT_0 + unset CDK_ITESTS_MINT_PORT_1 + unset CDK_MINTD_DATABASE + unset CDK_TEST_MINT_URL + unset CDK_TEST_MINT_URL_2 + unset CDK_REGTEST_PID + unset RUST_BACKTRACE + unset CDK_TEST_REGTEST +} + +# Set up trap to call cleanup on script exit +trap cleanup EXIT + +export CDK_TEST_REGTEST=1 + +# Check for mprocs and offer to install if missing +if ! command -v mprocs >/dev/null 2>&1; then + echo "⚠️ mprocs not found - this tool is required for direct process management" + echo "Install it with: cargo install mprocs" + echo + read -p "Would you like to install mprocs now? (y/n): " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + echo "Installing mprocs..." + cargo install mprocs + if [ $? -eq 0 ]; then + echo "✓ mprocs installed successfully" + else + echo "❌ Failed to install mprocs." + exit 1 + fi + else + echo "❌ mprocs is required for this mode. Exiting." + exit 1 + fi + echo +fi + +# Parse command line arguments +CDK_MINTD_DATABASE=${1:-"sqlite"} # Default to sqlite if not specified + +# Create a temporary directory +export CDK_ITESTS_DIR=$(mktemp -d) +export CDK_ITESTS_MINT_ADDR="127.0.0.1" +export CDK_ITESTS_MINT_PORT_0=8085 +export CDK_ITESTS_MINT_PORT_1=8087 +export CDK_ITESTS_MINT_PORT_2=8089 + +# Check if the temporary directory was created successfully +if [[ ! -d "$CDK_ITESTS_DIR" ]]; then + echo "Failed to create temp directory" + exit 1 +fi + +echo "==============================================" +echo "Starting CDK Regtest with Direct Process Management" +echo "==============================================" +echo "Temp directory: $CDK_ITESTS_DIR" +echo "Database type: $CDK_MINTD_DATABASE" +echo + +export CDK_MINTD_DATABASE="$CDK_MINTD_DATABASE" + +# Build the necessary binaries +echo "Building binaries..." +cargo build -p cdk-integration-tests --bin start_regtest +cargo build --bin cdk-mintd + +echo "Starting regtest network (Bitcoin + Lightning nodes)..." +cargo run --bin start_regtest -- --enable-logging "$CDK_ITESTS_DIR" & +export CDK_REGTEST_PID=$! + +# Create named pipe for progress tracking +mkfifo "$CDK_ITESTS_DIR/progress_pipe" +rm -f "$CDK_ITESTS_DIR/signal_received" + +# Start reading from pipe in background +(while read line; do + case "$line" in + "checkpoint1") + echo "✓ Regtest network is ready" + touch "$CDK_ITESTS_DIR/signal_received" + exit 0 + ;; + esac +done < "$CDK_ITESTS_DIR/progress_pipe") & + +# Wait for regtest setup (up to 120 seconds) +echo "Waiting for regtest network to be ready..." +for ((i=0; i<220; i++)); do + if [ -f "$CDK_ITESTS_DIR/signal_received" ]; then + break + fi + sleep 1 +done + +if [ ! -f "$CDK_ITESTS_DIR/signal_received" ]; then + echo "❌ Timeout waiting for regtest network" + exit 1 +fi + +# Create work directories for mints +mkdir -p "$CDK_ITESTS_DIR/cln_mint" +mkdir -p "$CDK_ITESTS_DIR/lnd_mint" +mkdir -p "$CDK_ITESTS_DIR/ldk_node_mint" + +# Set environment variables for easy access +export CDK_TEST_MINT_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_0" +export CDK_TEST_MINT_URL_2="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_1" +export CDK_TEST_MINT_URL_3="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_2" + +# Create state file for other terminal sessions +ENV_FILE="/tmp/cdk_regtest_env" +echo "export CDK_ITESTS_DIR=\"$CDK_ITESTS_DIR\"" > "$ENV_FILE" +echo "export CDK_TEST_MINT_URL=\"$CDK_TEST_MINT_URL\"" >> "$ENV_FILE" +echo "export CDK_TEST_MINT_URL_2=\"$CDK_TEST_MINT_URL_2\"" >> "$ENV_FILE" +echo "export CDK_TEST_MINT_URL_3=\"$CDK_TEST_MINT_URL_3\"" >> "$ENV_FILE" +echo "export CDK_REGTEST_PID=\"$CDK_REGTEST_PID\"" >> "$ENV_FILE" + +# Get the project root directory (where justfile is located) +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Create environment setup scripts for mprocs to use +cat > "$CDK_ITESTS_DIR/start_cln_mint.sh" << EOF +#!/usr/bin/env bash +cd "$PROJECT_ROOT" +export CDK_MINTD_CLN_RPC_PATH="$CDK_ITESTS_DIR/cln/one/regtest/lightning-rpc" +export CDK_MINTD_URL="http://127.0.0.1:8085" +export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR/cln_mint" +export CDK_MINTD_LISTEN_HOST="127.0.0.1" +export CDK_MINTD_LISTEN_PORT=8085 +export CDK_MINTD_LN_BACKEND="cln" +export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal" +export CDK_MINTD_LOGGING_OUTPUT="both" +export CDK_MINTD_LOGGING_CONSOLE_LEVEL="debug" +export CDK_MINTD_LOGGING_FILE_LEVEL="debug" +export RUST_BACKTRACE=1 +export CDK_MINTD_DATABASE="$CDK_MINTD_DATABASE" + +echo "Starting CLN Mint on port 8085..." +echo "Project root: $PROJECT_ROOT" +echo "Working directory: \$CDK_MINTD_WORK_DIR" +echo "CLN RPC path: \$CDK_MINTD_CLN_RPC_PATH" +echo "Database type: \$CDK_MINTD_DATABASE" +echo "Logging: \$CDK_MINTD_LOGGING_OUTPUT (console: \$CDK_MINTD_LOGGING_CONSOLE_LEVEL, file: \$CDK_MINTD_LOGGING_FILE_LEVEL)" +echo "---" + +exec cargo run --bin cdk-mintd +EOF + +cat > "$CDK_ITESTS_DIR/start_lnd_mint.sh" << EOF +#!/usr/bin/env bash +cd "$PROJECT_ROOT" +export CDK_MINTD_LND_ADDRESS="https://localhost:10010" +export CDK_MINTD_LND_CERT_FILE="$CDK_ITESTS_DIR/lnd/two/tls.cert" +export CDK_MINTD_LND_MACAROON_FILE="$CDK_ITESTS_DIR/lnd/two/data/chain/bitcoin/regtest/admin.macaroon" +export CDK_MINTD_URL="http://127.0.0.1:8087" +export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR/lnd_mint" +export CDK_MINTD_LISTEN_HOST="127.0.0.1" +export CDK_MINTD_LISTEN_PORT=8087 +export CDK_MINTD_LN_BACKEND="lnd" +export CDK_MINTD_MNEMONIC="cattle gold bind busy sound reduce tone addict baby spend february strategy" +export CDK_MINTD_LOGGING_OUTPUT="both" +export CDK_MINTD_LOGGING_CONSOLE_LEVEL="debug" +export CDK_MINTD_LOGGING_FILE_LEVEL="debug" +export RUST_BACKTRACE=1 +export CDK_MINTD_DATABASE="$CDK_MINTD_DATABASE" + +echo "Starting LND Mint on port 8087..." +echo "Project root: $PROJECT_ROOT" +echo "Working directory: \$CDK_MINTD_WORK_DIR" +echo "LND address: \$CDK_MINTD_LND_ADDRESS" +echo "Database type: \$CDK_MINTD_DATABASE" +echo "Logging: \$CDK_MINTD_LOGGING_OUTPUT (console: \$CDK_MINTD_LOGGING_CONSOLE_LEVEL, file: \$CDK_MINTD_LOGGING_FILE_LEVEL)" +echo "---" + +exec cargo run --bin cdk-mintd +EOF + +cat > "$CDK_ITESTS_DIR/start_ldk_node_mint.sh" << EOF +#!/usr/bin/env bash +cd "$PROJECT_ROOT" +export CDK_MINTD_URL="http://127.0.0.1:8089" +export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR/ldk_node_mint" +export CDK_MINTD_LISTEN_HOST="127.0.0.1" +export CDK_MINTD_LISTEN_PORT=8089 +export CDK_MINTD_LN_BACKEND="ldk-node" +export CDK_MINTD_LOGGING_CONSOLE_LEVEL="debug" +export CDK_MINTD_LOGGING_FILE_LEVEL="debug" +export CDK_MINTD_MNEMONIC="abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +export RUST_BACKTRACE=1 +export CDK_MINTD_DATABASE="$CDK_MINTD_DATABASE" + +# LDK Node specific environment variables +export CDK_MINTD_LDK_NODE_BITCOIN_NETWORK="regtest" +export CDK_MINTD_LDK_NODE_CHAIN_SOURCE_TYPE="bitcoinrpc" +export CDK_MINTD_LDK_NODE_BITCOIND_RPC_HOST="127.0.0.1" +export CDK_MINTD_LDK_NODE_BITCOIND_RPC_PORT=18443 +export CDK_MINTD_LDK_NODE_BITCOIND_RPC_USER="testuser" +export CDK_MINTD_LDK_NODE_BITCOIND_RPC_PASSWORD="testpass" +export CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH="$CDK_ITESTS_DIR/ldk_mint" +export CDK_MINTD_LDK_NODE_LDK_NODE_HOST="127.0.0.1" +export CDK_MINTD_LDK_NODE_LDK_NODE_PORT=8090 +export CDK_MINTD_LDK_NODE_GOSSIP_SOURCE_TYPE="p2p" +export CDK_MINTD_LDK_NODE_FEE_PERCENT=0.02 +export CDK_MINTD_LDK_NODE_RESERVE_FEE_MIN=2 + +echo "Starting LDK Node Mint on port 8089..." +echo "Project root: $PROJECT_ROOT" +echo "Working directory: \$CDK_MINTD_WORK_DIR" +echo "Bitcoin RPC: 127.0.0.1:18443 (testuser/testpass)" +echo "LDK Node listen: 127.0.0.1:8090" +echo "Storage directory: \$CDK_MINTD_LDK_NODE_STORAGE_DIR_PATH" +echo "Database type: \$CDK_MINTD_DATABASE" +echo "---" + +exec cargo run --bin cdk-mintd --features ldk-node +EOF + +# Make scripts executable +chmod +x "$CDK_ITESTS_DIR/start_cln_mint.sh" +chmod +x "$CDK_ITESTS_DIR/start_lnd_mint.sh" +chmod +x "$CDK_ITESTS_DIR/start_ldk_node_mint.sh" + +echo +echo "==============================================" +echo "🎉 CDK Regtest Environment is Ready!" +echo "==============================================" +echo +echo "Network Information:" +echo " • Bitcoin RPC: 127.0.0.1:18443 (user: testuser, pass: testpass)" +echo " • CLN Node 1: $CDK_ITESTS_DIR/cln/one/regtest/lightning-rpc" +echo " • CLN Node 2: $CDK_ITESTS_DIR/cln/two/regtest/lightning-rpc" +echo " • LND Node 1: https://localhost:10009" +echo " • LND Node 2: https://localhost:10010" +echo +echo "CDK Mints (will be managed by mprocs):" +echo " • CLN Mint: $CDK_TEST_MINT_URL" +echo " • LND Mint: $CDK_TEST_MINT_URL_2" +echo " • LDK Node Mint: $CDK_TEST_MINT_URL_3" +echo +echo "Files and Directories:" +echo " • Working Directory: $CDK_ITESTS_DIR" +echo " • Start Scripts: $CDK_ITESTS_DIR/start_{cln,lnd,ldk_node}_mint.sh" +echo +echo "Environment Variables (available in other terminals):" +echo " • CDK_TEST_MINT_URL=\"$CDK_TEST_MINT_URL\"" +echo " • CDK_TEST_MINT_URL_2=\"$CDK_TEST_MINT_URL_2\"" +echo " • CDK_TEST_MINT_URL_3=\"$CDK_TEST_MINT_URL_3\"" +echo " • CDK_ITESTS_DIR=\"$CDK_ITESTS_DIR\"" +echo +echo "Starting mprocs with direct process management..." +echo +echo "In mprocs you can:" +echo " • 's' to start a process" +echo " • 'k' to kill a process" +echo " • 'r' to restart a process" +echo " • 'Enter' to focus on a process" +echo " • 'q' to quit and stop the environment" +echo "==============================================" + +# Wait a moment for everything to settle +sleep 2 + +# Create mprocs configuration with direct process management +MPROCS_CONFIG="$CDK_ITESTS_DIR/mprocs.yaml" +cat > "$MPROCS_CONFIG" << EOF +procs: + cln-mint: + shell: "$CDK_ITESTS_DIR/start_cln_mint.sh" + autostart: true + env: + CDK_ITESTS_DIR: "$CDK_ITESTS_DIR" + CDK_MINTD_DATABASE: "$CDK_MINTD_DATABASE" + + lnd-mint: + shell: "$CDK_ITESTS_DIR/start_lnd_mint.sh" + autostart: true + env: + CDK_ITESTS_DIR: "$CDK_ITESTS_DIR" + CDK_MINTD_DATABASE: "$CDK_MINTD_DATABASE" + + ldk-node-mint: + shell: "$CDK_ITESTS_DIR/start_ldk_node_mint.sh" + autostart: true + env: + CDK_ITESTS_DIR: "$CDK_ITESTS_DIR" + CDK_MINTD_DATABASE: "$CDK_MINTD_DATABASE" + + bitcoind: + shell: "while [ ! -f $CDK_ITESTS_DIR/bitcoin/regtest/debug.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/bitcoin/regtest/debug.log" + autostart: true + + cln-one: + shell: "while [ ! -f $CDK_ITESTS_DIR/cln/one/debug.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/cln/one/debug.log" + autostart: true + + cln-two: + shell: "while [ ! -f $CDK_ITESTS_DIR/cln/two/debug.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/cln/two/debug.log" + autostart: true + + lnd-one: + shell: "while [ ! -f $CDK_ITESTS_DIR/lnd/one/logs/bitcoin/regtest/lnd.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/lnd/one/logs/bitcoin/regtest/lnd.log" + autostart: true + + lnd-two: + shell: "while [ ! -f $CDK_ITESTS_DIR/lnd/two/logs/bitcoin/regtest/lnd.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/lnd/two/logs/bitcoin/regtest/lnd.log" + autostart: true + + ldk-node: + shell: "while [ ! -f $CDK_ITESTS_DIR/ldk_mint/ldk_node.log ]; do sleep 1; done && $PROJECT_ROOT/misc/scripts/filtered_ldk_node_log.sh $CDK_ITESTS_DIR/ldk_mint/ldk_node.log" + autostart: true + +settings: + mouse_scroll_speed: 3 + proc_list_width: 20 + hide_keymap_window: false + keymap_procs: + toggle_process: 's' + kill_process: 'k' + restart_process: 'r' + focus_process: 'Enter' + show_keymap: '?' +EOF + +# Start mprocs with direct process management +echo "Starting mprocs..." +cd "$CDK_ITESTS_DIR" +mprocs --config "$MPROCS_CONFIG" diff --git a/misc/itests.sh b/misc/itests.sh index cb8de230e..dd5f01e81 100755 --- a/misc/itests.sh +++ b/misc/itests.sh @@ -4,25 +4,29 @@ cleanup() { echo "Cleaning up..." - echo "Killing the cdk mintd" - kill -2 $CDK_MINTD_PID - wait $CDK_MINTD_PID - - - echo "Killing the cdk lnd mintd" - kill -2 $CDK_MINTD_LND_PID - wait $CDK_MINTD_LND_PID - - echo "Killing the cdk regtest" - kill -2 $CDK_REGTEST_PID - wait $CDK_REGTEST_PID - + echo "Killing the cdk regtest and mints" + if [ ! -z "$CDK_REGTEST_PID" ]; then + # First try graceful shutdown with SIGTERM + kill -15 $CDK_REGTEST_PID 2>/dev/null + sleep 2 + + # Check if process is still running, if so force kill with SIGKILL + if ps -p $CDK_REGTEST_PID > /dev/null 2>&1; then + echo "Process still running, force killing..." + kill -9 $CDK_REGTEST_PID 2>/dev/null + fi + + # Wait for process to terminate + wait $CDK_REGTEST_PID 2>/dev/null || true + fi echo "Mint binary terminated" - # Remove the temporary directory - rm -rf "$CDK_ITESTS_DIR" - echo "Temp directory removed: $CDK_ITESTS_DIR" + # # Remove the temporary directory + # if [ ! -z "$CDK_ITESTS_DIR" ] && [ -d "$CDK_ITESTS_DIR" ]; then + # rm -rf "$CDK_ITESTS_DIR" + # echo "Temp directory removed: $CDK_ITESTS_DIR" + # fi # Unset all environment variables unset CDK_ITESTS_DIR @@ -32,21 +36,10 @@ cleanup() { unset CDK_MINTD_DATABASE unset CDK_TEST_MINT_URL unset CDK_TEST_MINT_URL_2 - unset CDK_MINTD_URL - unset CDK_MINTD_WORK_DIR - unset CDK_MINTD_LISTEN_HOST - unset CDK_MINTD_LISTEN_PORT - unset CDK_MINTD_LN_BACKEND - unset CDK_MINTD_MNEMONIC - unset CDK_MINTD_CLN_RPC_PATH - unset CDK_MINTD_LND_ADDRESS - unset CDK_MINTD_LND_CERT_FILE - unset CDK_MINTD_LND_MACAROON_FILE - unset CDK_MINTD_PID - unset CDK_MINTD_LND_PID unset CDK_REGTEST_PID unset RUST_BACKTRACE unset CDK_TEST_REGTEST + unset CDK_TEST_LIGHTNING_CLIENT } # Set up trap to call cleanup on script exit @@ -69,55 +62,60 @@ fi echo "Temp directory created: $CDK_ITESTS_DIR" export CDK_MINTD_DATABASE="$1" -cargo build -p cdk-integration-tests - -cargo run --bin start_regtest & +cargo build --bin start_regtest_mints +echo "Starting regtest and mints" +# Run the binary in background +cargo r --bin start_regtest_mints -- --enable-logging "$CDK_MINTD_DATABASE" "$CDK_ITESTS_DIR" "$CDK_ITESTS_MINT_ADDR" "$CDK_ITESTS_MINT_PORT_0" "$CDK_ITESTS_MINT_PORT_1" & export CDK_REGTEST_PID=$! -mkfifo "$CDK_ITESTS_DIR/progress_pipe" -rm -f "$CDK_ITESTS_DIR/signal_received" # Ensure clean state -# Start reading from pipe in background -(while read line; do - case "$line" in - "checkpoint1") - echo "Reached first checkpoint" - touch "$CDK_ITESTS_DIR/signal_received" - exit 0 - ;; - esac -done < "$CDK_ITESTS_DIR/progress_pipe") & -# Wait for up to 120 seconds -for ((i=0; i<120; i++)); do - if [ -f "$CDK_ITESTS_DIR/signal_received" ]; then - echo "break signal received" + +# Give it a moment to start - reduced from 5 to 2 seconds since we have better waiting mechanisms now +sleep 2 + +# Look for the .env file in the current directory +ENV_FILE_PATH="$CDK_ITESTS_DIR/.env" + +# Wait for the .env file to be created in the current directory +max_wait=120 +wait_count=0 +while [ $wait_count -lt $max_wait ]; do + if [ -f "$ENV_FILE_PATH" ]; then + echo ".env file found at: $ENV_FILE_PATH" break fi + wait_count=$((wait_count + 1)) sleep 1 done -echo "Regtest set up continuing" -echo "Starting regtest mint" -# cargo run --bin regtest_mint & +# Check if we found the .env file +if [ ! -f "$ENV_FILE_PATH" ]; then + echo "ERROR: Could not find .env file at $ENV_FILE_PATH" + exit 1 +fi -export CDK_MINTD_CLN_RPC_PATH="$CDK_ITESTS_DIR/cln/one/regtest/lightning-rpc" -export CDK_MINTD_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_0" -export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR" -export CDK_MINTD_LISTEN_HOST=$CDK_ITESTS_MINT_ADDR -export CDK_MINTD_LISTEN_PORT=$CDK_ITESTS_MINT_PORT_0 -export CDK_MINTD_LN_BACKEND="cln" -export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal" -export RUST_BACKTRACE=1 +# Source the environment variables from the .env file +echo "Sourcing environment variables from $ENV_FILE_PATH" +source "$ENV_FILE_PATH" -echo "Starting cln mintd" -cargo run --bin cdk-mintd --features "redb" & -export CDK_MINTD_PID=$! +echo "Sourced environment variables:" +echo "CDK_TEST_MINT_URL=$CDK_TEST_MINT_URL" +echo "CDK_TEST_MINT_URL_2=$CDK_TEST_MINT_URL_2" +echo "CDK_ITESTS_DIR=$CDK_ITESTS_DIR" + +# Validate that we sourced the variables +if [ -z "$CDK_TEST_MINT_URL" ] || [ -z "$CDK_TEST_MINT_URL_2" ] || [ -z "$CDK_ITESTS_DIR" ]; then + echo "ERROR: Failed to source environment variables from the .env file" + exit 1 +fi +# Export all variables so they're available to the tests +export CDK_TEST_MINT_URL +export CDK_TEST_MINT_URL_2 -echo $CDK_ITESTS_DIR +URL="$CDK_TEST_MINT_URL/v1/info" -URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_0/v1/info" -TIMEOUT=100 +TIMEOUT=500 START_TIME=$(date +%s) # Loop until the endpoint returns a 200 OK status or timeout is reached while true; do @@ -146,24 +144,8 @@ while true; do fi done +URL="$CDK_TEST_MINT_URL_2/v1/info" -export CDK_MINTD_LND_ADDRESS="https://localhost:10010" -export CDK_MINTD_LND_CERT_FILE="$CDK_ITESTS_DIR/lnd/two/tls.cert" -export CDK_MINTD_LND_MACAROON_FILE="$CDK_ITESTS_DIR/lnd/two/data/chain/bitcoin/regtest/admin.macaroon" - -export CDK_MINTD_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_1" -mkdir -p "$CDK_ITESTS_DIR/lnd_mint" -export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR/lnd_mint" -export CDK_MINTD_LISTEN_HOST=$CDK_ITESTS_MINT_ADDR -export CDK_MINTD_LISTEN_PORT=$CDK_ITESTS_MINT_PORT_1 -export CDK_MINTD_LN_BACKEND="lnd" -export CDK_MINTD_MNEMONIC="cattle gold bind busy sound reduce tone addict baby spend february strategy" - -echo "Starting lnd mintd" -cargo run --bin cdk-mintd --features "redb" & -export CDK_MINTD_LND_PID=$! - -URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_1/v1/info" TIMEOUT=100 START_TIME=$(date +%s) @@ -194,56 +176,107 @@ while true; do fi done - - -export CDK_TEST_MINT_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_0" -export CDK_TEST_MINT_URL_2="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_1" - -# Run tests and exit immediately on failure - # Run cargo test -echo "Running regtest test with CLN mint" +echo "Running regtest test with CLN mint and CLN client" +export CDK_TEST_LIGHTNING_CLIENT="lnd" cargo test -p cdk-integration-tests --test regtest if [ $? -ne 0 ]; then - echo "regtest test failed, exiting" + echo "regtest test with cln mint failed, exiting" exit 1 fi -echo "Running happy_path_mint_wallet test with CLN mint" +echo "Running happy_path_mint_wallet test with CLN mint and CLN client" cargo test -p cdk-integration-tests --test happy_path_mint_wallet if [ $? -ne 0 ]; then - echo "happy_path_mint_wallet test failed, exiting" + echo "happy_path_mint_wallet with cln mint test failed, exiting" exit 1 fi -# # Run cargo test with the http_subscription feature -echo "Running regtest test with http_subscription feature" +# Run cargo test with the http_subscription feature +echo "Running regtest test with http_subscription feature (CLN client)" cargo test -p cdk-integration-tests --test regtest --features http_subscription if [ $? -ne 0 ]; then echo "regtest test with http_subscription failed, exiting" exit 1 fi +echo "Running regtest test with cln mint for bolt12 (CLN client)" +cargo test -p cdk-integration-tests --test bolt12 +if [ $? -ne 0 ]; then + echo "regtest test failed, exiting" + exit 1 +fi + # Switch Mints: Run tests with LND mint echo "Switching to LND mint for tests" -export CDK_ITESTS_MINT_PORT_0=8087 -export CDK_ITESTS_MINT_PORT_1=8085 -export CDK_TEST_MINT_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_0" -export CDK_TEST_MINT_URL_2="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_1" -echo "Running regtest test with LND mint" -cargo test -p cdk-integration-tests --test regtest +echo "Running regtest test with LND mint and LND client" +CDK_TEST_MINT_URL_SWITCHED=$CDK_TEST_MINT_URL_2 +CDK_TEST_MINT_URL_2_SWITCHED=$CDK_TEST_MINT_URL +export CDK_TEST_MINT_URL=$CDK_TEST_MINT_URL_SWITCHED +export CDK_TEST_MINT_URL_2=$CDK_TEST_MINT_URL_2_SWITCHED + + cargo test -p cdk-integration-tests --test regtest + if [ $? -ne 0 ]; then + echo "regtest test with LND mint failed, exiting" + exit 1 + fi + + echo "Running happy_path_mint_wallet test with LND mint and LND client" + cargo test -p cdk-integration-tests --test happy_path_mint_wallet + if [ $? -ne 0 ]; then + echo "happy_path_mint_wallet test with LND mint failed, exiting" + exit 1 + fi + + +export CDK_TEST_MINT_URL="http://127.0.0.1:8089" + +TIMEOUT=100 +START_TIME=$(date +%s) +# Loop until the endpoint returns a 200 OK status or timeout is reached +while true; do + # Get the current time + CURRENT_TIME=$(date +%s) + + # Calculate the elapsed time + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + + # Check if the elapsed time exceeds the timeout + if [ $ELAPSED_TIME -ge $TIMEOUT ]; then + echo "Timeout of $TIMEOUT seconds reached. Exiting..." + exit 1 + fi + + # Make a request to the endpoint and capture the HTTP status code + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" $CDK_TEST_MINT_URL/v1/info) + + # Check if the HTTP status is 200 OK + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "Received 200 OK from $CDK_TEST_MINT_URL" + break + else + echo "Waiting for 200 OK response, current status: $HTTP_STATUS" + sleep 2 # Wait for 2 seconds before retrying + fi +done + + +echo "Running happy_path_mint_wallet test with LDK mint and CLN client" +export CDK_TEST_LIGHTNING_CLIENT="cln" # Use CLN client for LDK tests +cargo test -p cdk-integration-tests --test happy_path_mint_wallet if [ $? -ne 0 ]; then - echo "regtest test with LND mint failed, exiting" + echo "happy_path_mint_wallet test with LDK mint failed, exiting" exit 1 fi -echo "Running happy_path_mint_wallet test with LND mint" -cargo test -p cdk-integration-tests --test happy_path_mint_wallet +echo "Running regtest test with LDK mint and CLN client" +cargo test -p cdk-integration-tests --test regtest if [ $? -ne 0 ]; then - echo "happy_path_mint_wallet test with LND mint failed, exiting" + echo "regtest test LDK mint failed, exiting" exit 1 fi + echo "All tests passed successfully" exit 0 diff --git a/misc/justfile.custom.just b/misc/justfile.custom.just index f86eb180e..c085c1ea6 100644 --- a/misc/justfile.custom.just +++ b/misc/justfile.custom.just @@ -72,7 +72,6 @@ release m="": "-p cdk" "-p cdk-redb" "-p cdk-sqlite" - "-p cdk-rexie" "-p cdk-axum" "-p cdk-mint-rpc" "-p cdk-cln" @@ -100,7 +99,6 @@ check-docs: "-p cdk-redb" "-p cdk-sqlite" "-p cdk-axum" - "-p cdk-rexie" "-p cdk-cln" "-p cdk-lnd" "-p cdk-lnbits" diff --git a/misc/mintd_payment_processor.sh b/misc/mintd_payment_processor.sh index 376978da8..04afe6490 100755 --- a/misc/mintd_payment_processor.sh +++ b/misc/mintd_payment_processor.sh @@ -50,6 +50,7 @@ cleanup() { unset CDK_MINTD_GRPC_PAYMENT_PROCESSOR_SUPPORTED_UNITS unset CDK_MINTD_MNEMONIC unset CDK_MINTD_PID + unset CDK_PAYMENT_PROCESSOR_CLN_BOLT12 } # Set up trap to call cleanup on script exit @@ -79,7 +80,7 @@ cargo build -p cdk-integration-tests export CDK_TEST_REGTEST=0 if [ "$LN_BACKEND" != "FAKEWALLET" ]; then export CDK_TEST_REGTEST=1 - cargo run --bin start_regtest & + cargo run --bin start_regtest "$CDK_ITESTS_DIR" & CDK_REGTEST_PID=$! mkfifo "$CDK_ITESTS_DIR/progress_pipe" rm -f "$CDK_ITESTS_DIR/signal_received" # Ensure clean state @@ -102,6 +103,7 @@ if [ "$LN_BACKEND" != "FAKEWALLET" ]; then sleep 1 done echo "Regtest set up continuing" + export CDK_PAYMENT_PROCESSOR_CLN_BOLT12=true fi # Start payment processor @@ -121,11 +123,12 @@ export CDK_PAYMENT_PROCESSOR_LISTEN_PORT="8090"; echo "$CDK_PAYMENT_PROCESSOR_CLN_RPC_PATH" +cargo b --bin cdk-payment-processor + cargo run --bin cdk-payment-processor & CDK_PAYMENT_PROCESSOR_PID=$! -sleep 10; export CDK_MINTD_URL="http://$CDK_ITESTS_MINT_ADDR:$CDK_ITESTS_MINT_PORT_0"; export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR"; @@ -137,12 +140,14 @@ export CDK_MINTD_GRPC_PAYMENT_PROCESSOR_PORT="8090"; export CDK_MINTD_GRPC_PAYMENT_PROCESSOR_SUPPORTED_UNITS="sat"; export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal"; +cargo build --bin cdk-mintd --no-default-features --features grpc-processor + cargo run --bin cdk-mintd --no-default-features --features grpc-processor & CDK_MINTD_PID=$! echo $CDK_ITESTS_DIR -TIMEOUT=100 +TIMEOUT=300 START_TIME=$(date +%s) # Loop until the endpoint returns a 200 OK status or timeout is reached while true; do @@ -177,5 +182,17 @@ cargo test -p cdk-integration-tests --test happy_path_mint_wallet # Capture the exit status of cargo test test_status=$? +if [ "$LN_BACKEND" = "CLN" ]; then + echo "Running bolt12 tests for CLN backend" + cargo test -p cdk-integration-tests --test bolt12 + bolt12_test_status=$? + + # Exit with non-zero status if either test failed + if [ $test_status -ne 0 ] || [ $bolt12_test_status -ne 0 ]; then + echo "Tests failed - happy_path_mint_wallet: $test_status, bolt12: $bolt12_test_status" + exit 1 + fi +fi + # Exit with the status of the tests exit $test_status diff --git a/misc/nutshell_wallet_itest.sh b/misc/nutshell_wallet_itest.sh index e54717d69..bcc0f65a2 100755 --- a/misc/nutshell_wallet_itest.sh +++ b/misc/nutshell_wallet_itest.sh @@ -58,6 +58,9 @@ export CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN="1" export CDK_MINTD_INPUT_FEE_PPK="100" +export CDK_ITESTS_DIR="$CDK_ITESTS" + + echo "Starting fake mintd" cargo run --bin cdk-mintd & CDK_MINTD_PID=$! diff --git a/misc/provisioning/dashboards/dashboard.json b/misc/provisioning/dashboards/dashboard.json new file mode 100644 index 000000000..1f1d66c4a --- /dev/null +++ b/misc/provisioning/dashboards/dashboard.json @@ -0,0 +1,1983 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 7, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 6, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "process_cpu_usage_percent", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "CPU Usage", + "refId": "A" + } + ], + "title": "Process CPU Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 7, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "process_memory_bytes", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Memory (Bytes)", + "refId": "A" + } + ], + "title": "Process Memory Usage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 13, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "process_memory_percent", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Memory (%)", + "refId": "A" + } + ], + "title": "Memory Percentage", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(cdk_http_requests_total[1m])", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{endpoint}} - {{status}}", + "range": true, + "refId": "A" + } + ], + "title": "HTTP Request Rate by Endpoint", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 50 + } + ] + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 12, + "y": 8 + }, + "id": 2, + "options": { + "minVizHeight": 75, + "minVizWidth": 75, + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "sizing": "auto" + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum(rate(cdk_http_requests_total{status!=\"200\"}[$__range])) / sum(rate(cdk_http_requests_total[$__range])) * 100", + "format": "time_series", + "instant": false, + "intervalFactor": 1, + "legendFormat": "Error Rate", + "range": true, + "refId": "A" + } + ], + "title": "HTTP Error Rate", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + } + }, + "mappings": [] + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "/v1/keys/{keyset_id} (200)", + "/v1/mint/quote/bolt11 (200)", + "/v1/swap (400)", + "/v1/mint/bolt11 (200)" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": false, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 6, + "x": 18, + "y": 8 + }, + "id": 9, + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true, + "values": [ + "value" + ] + }, + "pieType": "pie", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "cdk_http_requests_total", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{endpoint}} ({{status}})", + "refId": "A" + } + ], + "title": "HTTP Requests Distribution", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [ + { + "__systemRef": "hideSeriesFrom", + "matcher": { + "id": "byNames", + "options": { + "mode": "exclude", + "names": [ + "p50 - /v1/mint/bolt11" + ], + "prefix": "All except:", + "readOnly": true + } + }, + "properties": [ + { + "id": "custom.hideFrom", + "value": { + "legend": false, + "tooltip": false, + "viz": true + } + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cdk_http_request_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p50 - {{endpoint}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(cdk_http_request_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p95 - {{endpoint}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cdk_http_request_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p99 - {{endpoint}}", + "range": true, + "refId": "C" + } + ], + "title": "HTTP Request Duration Percentiles", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(cdk_mint_operations_total[1m])", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{operation}} - {{status}}", + "range": true, + "refId": "A" + } + ], + "title": "Mint Operations Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 5, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "cdk_mint_in_flight_requests", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{operation}}", + "refId": "A" + } + ], + "title": "In-Flight Mint Requests", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "sats" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 32 + }, + "id": 11, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.50, rate(cdk_lightning_payment_amount_sats_bucket[5m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p50 Payment Amount", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, rate(cdk_lightning_payment_amount_sats_bucket[5m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p95 Payment Amount", + "refId": "B" + } + ], + "title": "Lightning Payment Amounts", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 32 + }, + "id": 8, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "cdk_db_connections_active", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Active Connections", + "refId": "A" + } + ], + "title": "Database Connections", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "sats" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 40 + }, + "id": 12, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.50, rate(cdk_lightning_payment_fees_sats_bucket[5m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p50 Payment Fees", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "histogram_quantile(0.95, rate(cdk_lightning_payment_fees_sats_bucket[5m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p95 Payment Fees", + "refId": "B" + } + ], + "title": "Lightning Payment Fees", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 1000 + }, + { + "color": "red", + "value": 5000 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 40 + }, + "id": 10, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "cdk_http_requests_total{status=\"500\"}", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "{{endpoint}}", + "refId": "A" + } + ], + "title": "Failed Requests (500 Status)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 48 + }, + "id": 14, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(cdk_auth_attempts_total[1m])", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Auth Attempts", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(cdk_auth_successes_total[1m])", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Auth Successes", + "refId": "B" + } + ], + "title": "Authentication Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "yellow", + "value": 1 + }, + { + "color": "red", + "value": 10 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 48 + }, + "id": 15, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "textMode": "auto", + "wideLayout": true + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "cdk_errors_total", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Total Errors", + "refId": "A" + } + ], + "title": "Total Errors", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 56 + }, + "id": 16, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cdk_db_operation_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p50 - {{operation}}", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(cdk_db_operation_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p95 - {{operation}}", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cdk_db_operation_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p99 - {{operation}}", + "range": true, + "refId": "C" + } + ], + "title": "Database Operation Duration Percentiles", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 24, + "x": 0, + "y": 64 + }, + "id": 17, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.50, rate(cdk_mint_operation_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p50 - {{operation}} ({{status}})", + "range": true, + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.95, rate(cdk_mint_operation_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p95 - {{operation}} ({{status}})", + "range": true, + "refId": "B" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "histogram_quantile(0.99, rate(cdk_mint_operation_duration_seconds_bucket[1m]))", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "p99 - {{operation}} ({{status}})", + "range": true, + "refId": "C" + } + ], + "title": "Mint Operation Duration Percentiles", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 72 + }, + "id": 18, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "expr": "rate(cdk_db_operations_total[1m])", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "DB Operations Rate", + "range": true, + "refId": "A" + } + ], + "title": "Database Operations Rate", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "vis": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 72 + }, + "id": 19, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.0.2", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(cdk_wallet_operations_total[1m])", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Wallet Operations Rate", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "rate(cdk_lightning_payments_total[1m])", + "format": "time_series", + "intervalFactor": 1, + "legendFormat": "Lightning Payments Rate", + "refId": "B" + } + ], + "title": "Wallet & Lightning Operations Rate", + "type": "timeseries" + } + ], + "preload": false, + "refresh": "30s", + "schemaVersion": 41, + "tags": [ + "cashu", + "cdk", + "mint" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "CDK Mint Dashboard", + "uid": "cdk-mint-dashboard", + "version": 8 +} \ No newline at end of file diff --git a/misc/provisioning/dashboards/dashboard.yaml b/misc/provisioning/dashboards/dashboard.yaml new file mode 100644 index 000000000..5cf7ca332 --- /dev/null +++ b/misc/provisioning/dashboards/dashboard.yaml @@ -0,0 +1,8 @@ +apiVersion: 1 +providers: + - name: 'default' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + options: + path: /etc/grafana/provisioning/dashboards \ No newline at end of file diff --git a/misc/provisioning/datasources/datasource.yml b/misc/provisioning/datasources/datasource.yml new file mode 100644 index 000000000..bcbdc3cd1 --- /dev/null +++ b/misc/provisioning/datasources/datasource.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true \ No newline at end of file diff --git a/misc/provisioning/prometheus.yml b/misc/provisioning/prometheus.yml new file mode 100644 index 000000000..6cc833466 --- /dev/null +++ b/misc/provisioning/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['host.docker.internal:9000','mintd:9000'] diff --git a/misc/recipes/changelog-from-commits.yaml b/misc/recipes/changelog-from-commits.yaml new file mode 100644 index 000000000..a95889197 --- /dev/null +++ b/misc/recipes/changelog-from-commits.yaml @@ -0,0 +1,76 @@ +version: 1.0.0 +title: Update Changelog from last commits +description: A custom recipe to update changelog from the last X commits +instructions: Analyze recent Git commits and automatically update project changelogs following Keep a Changelog format. Read commit history using git log, understand the nature of code changes (API refactoring, new features, bug fixes, etc.), and add appropriately categorized entries to the Unreleased section. Use the git author's username for attribution and follow established changelog patterns including proper formatting with contributor links. Requires git command line tools and file editing capabilities. +prompt: | + You are tasked with updating the CHANGELOG.md file based on recent Git commits. Follow these instructions: + + ## Analysis Process: + 1. **Get commit count**: Check if a COMMITS environment variable was provided (default to 5 if not specified) + 2. **Read recent commits**: Use `git log -n X` to analyze the last X commits (where X is the number specified by user or default) + 3. **Get git author**: Use `git config --get user.name` to get the author name for attribution + 4. **Analyze change types**: Categorize changes into: + - **Added**: New features, new functions, new files, new functionality + - **Changed**: Modifications to existing functionality, refactoring, API changes, dependency updates + - **Fixed**: Bug fixes, error handling improvements, corrections + - **Removed**: Deleted files, deprecated functions, removed dependencies + + ## CHANGELOG.md Update Requirements: + 1. **Preserve existing format**: Keep the exact formatting style of the existing CHANGELOG.md + 2. **Update Unreleased section**: Add new entries to the "## [Unreleased]" section only + 3. **Use proper categories**: Add entries under the appropriate subsections (Added, Changed, Fixed, Removed) + 4. **Follow format pattern**: Each entry should follow this exact format: + ``` + - component: Description of change ([author]). + ``` + 5. **Author attribution**: Use the git author name in square brackets at the end of each entry + 6. **Component identification**: Identify the relevant component (e.g., "cdk", "cashu", "cdk-cli", etc.) from file paths + 7. **Maintain formatting**: + - Use proper bullet points with hyphens + - Maintain consistent spacing + - Keep entries concise but descriptive + - End each entry with period before author attribution + + ## Example Entry Format: + ```markdown + ### Added + - cdk: New keyset refresh functionality with improved error handling ([thesimplekid]). + + ### Changed + - cdk: Refactored wallet keyset management for better performance ([thesimplekid]). + + ### Fixed + - cdk-cli: Fixed token parsing error for malformed inputs ([thesimplekid]). + ``` + + ## Important Guidelines: + - **DO NOT** modify any existing changelog entries + - **DO NOT** change the structure or format of the changelog + - **ONLY** add new entries to the Unreleased section + - **DO NOT** add duplicate entries + - **BE SPECIFIC** about what changed, not just which files + - **FOCUS** on user-facing changes and important internal improvements + - **IGNORE** trivial changes like whitespace, comments, or formatting unless they're significant + + ## Steps to Execute: + 1. Check for COMMITS environment variable or ask user for the number of recent commits to analyze (default to 5) + 2. Read current CHANGELOG.md file to understand format + 3. Get git author name + 4. Analyze recent commits with `git log -n X --pretty=format:"%h %s"` + 5. For each commit, examine detailed changes with `git show COMMIT_HASH` + 6. Categorize and write appropriate changelog entries + 7. Update the CHANGELOG.md file preserving all existing content +extensions: +- type: builtin + name: developer + display_name: Developer + timeout: 300 + bundled: true +activities: +- Update changelog from recent commits +- Analyze API refactoring commits +- Add new feature entries +- Categorize bug fix changes +- Format contributor attributions +author: + contact: thesimplekid diff --git a/misc/recipes/changelog-update.yaml b/misc/recipes/changelog-update.yaml new file mode 100644 index 000000000..cf2b14de4 --- /dev/null +++ b/misc/recipes/changelog-update.yaml @@ -0,0 +1,78 @@ +version: 1.0.0 +title: Update Changelog from staged +description: a custom recipe instance from this chat session +instructions: Analyze staged Git changes and automatically update project changelogs following Keep a Changelog format. Read staged files using git diff, understand the nature of code changes (API refactoring, new features, bug fixes, etc.), and add appropriately categorized entries to the Unreleased section. Use the git author's username for attribution and follow established changelog patterns including proper formatting with contributor links. Requires git command line tools and file editing capabilities. +prompt: | + You are tasked with updating the CHANGELOG.md file based on staged Git changes. Follow these instructions: + + ## Analysis Process: + 1. **Read staged changes**: Use `git diff --staged` to analyze what files and code changes are currently staged for commit + 2. **Get git author**: Use `git config --get user.name` to get the author name for attribution + 3. **Analyze change types**: Categorize changes into: + - **Added**: New features, new functions, new files, new functionality + - **Changed**: Modifications to existing functionality, refactoring, API changes, dependency updates + - **Fixed**: Bug fixes, error handling improvements, corrections + - **Removed**: Deleted files, deprecated functions, removed dependencies + + ## CHANGELOG.md Update Requirements: + 1. **Preserve existing format**: Keep the exact formatting style of the existing CHANGELOG.md + 2. **Update Unreleased section**: Add new entries to the "## [Unreleased]" section only + 3. **Use proper categories**: Add entries under the appropriate subsections (Added, Changed, Fixed, Removed) + 4. **Follow format pattern**: Each entry should follow this exact format: + ``` + - component: Description of change ([author]). + ``` + 5. **Author attribution**: Use the git author name in square brackets at the end of each entry + 6. **Component identification**: Identify the relevant component (e.g., "cdk", "cashu", "cdk-cli", etc.) from file paths + 7. **Maintain formatting**: + - Use proper bullet points with hyphens + - Maintain consistent spacing + - Keep entries concise but descriptive + - End each entry with period before author attribution + + ## Example Entry Format: + ```markdown + ### Added + - cdk: New keyset refresh functionality with improved error handling ([thesimplekid]). + + ### Changed + - cdk: Refactored wallet keyset management for better performance ([thesimplekid]). + + ### Fixed + - cdk-cli: Fixed token parsing error for malformed inputs ([thesimplekid]). + ``` + + ## Important Guidelines: + - **DO NOT** modify any existing changelog entries + - **DO NOT** change the structure or format of the changelog + - **ONLY** add new entries to the Unreleased section + - **DO NOT** add duplicate entries + - **BE SPECIFIC** about what changed, not just which files + - **FOCUS** on user-facing changes and important internal improvements + - **IGNORE** trivial changes like whitespace, comments, or formatting unless they're significant + + ## Steps to Execute: + 1. Read current CHANGELOG.md file to understand format + 2. Get git author name + 3. Analyze staged changes with `git diff --staged` + 4. Categorize and write appropriate changelog entries + 5. Update the CHANGELOG.md file preserving all existing content + + ## Alternative Recipe: + If you want to generate changelog entries from recent commits instead of staged changes, see the + `changelog-from-commits.yaml` recipe which analyzes the last X commits instead of staged changes. + +extensions: +- type: builtin + name: developer + display_name: Developer + timeout: 300 + bundled: true +activities: +- Update changelog from staged changes +- Analyze API refactoring commits +- Add new feature entries +- Categorize bug fix changes +- Format contributor attributions +author: + contact: thesimplekid diff --git a/misc/recipes/git-commit-message.yaml b/misc/recipes/git-commit-message.yaml new file mode 100644 index 000000000..c0ceea316 --- /dev/null +++ b/misc/recipes/git-commit-message.yaml @@ -0,0 +1,83 @@ +version: 1.0.0 +title: Create git commit message from staged changes +description: Generate meaningful git commit messages by analyzing staged changes +prompt: | + You are tasked with creating a clear and descriptive git commit message based on staged Git changes. Follow these instructions: + + ## Analysis Process: + 1. **Read staged changes**: Use `git diff --staged` to analyze what files and code changes are currently staged for commit + 2. **Analyze change scope**: Identify which components/modules are affected (e.g., cdk, cashu, cdk-cli, etc.) + 3. **Determine change type**: Categorize the primary change as: + - **feat**: New features or functionality + - **fix**: Bug fixes + - **refactor**: Code refactoring without functional changes + - **docs**: Documentation changes + - **style**: Code style/formatting changes + - **test**: Adding or updating tests + - **chore**: Maintenance tasks, dependency updates, build changes + + ## Commit Message Format: + Follow conventional commit format: + ``` + type(scope): description + + Optional body with more details if needed + ``` + + ### Examples: + ``` + feat(cdk): add keyset refresh functionality with improved error handling + + refactor(wallet): improve keyset management for better performance + + fix(cdk-cli): resolve token parsing error for malformed inputs + + docs(README): update installation instructions + + chore(deps): update rust dependencies to latest versions + ``` + + ## Commit Message Guidelines: + 1. **Subject line (first line)**: + - Start with conventional commit type and scope + - Use imperative mood ("add", "fix", "update", not "added", "fixed", "updated") + - Keep under 72 characters + - Don't end with a period + - Be specific and descriptive + + 2. **Body (optional)**: + - Add if the change needs more explanation + - Wrap at 72 characters + - Explain **what** and **why**, not **how** + - Separate from subject with blank line + + 3. **Scope identification**: + - Use component names from file paths (cdk, cashu, cdk-cli, etc.) + - Use general scopes like "deps", "ci", "docs" for broad changes + - Omit scope if change affects multiple unrelated areas + + ## Analysis Priority: + 1. **Focus on the main change**: If multiple types of changes, pick the most significant one + 2. **Combine related changes**: Group similar modifications into one cohesive message + 3. **Ignore trivial changes**: Don't mention minor formatting, whitespace, or comment changes unless that's the primary purpose + 4. **Be user/developer focused**: Describe impact rather than implementation details + + ## Steps to Execute: + 1. Analyze staged changes with `git diff --staged` + 2. Identify primary change type and affected components + 3. Write a clear, descriptive commit message following conventional format + 4. Output ONLY the commit message (no additional explanation unless asked) +extensions: +- type: builtin + name: developer + display_name: Developer + timeout: 300 + bundled: true +activities: +- Analyze staged git changes +- Generate conventional commit messages +- Identify change types and scopes +- Create clear and descriptive commit subjects +- Format commit messages properly +author: + contact: thesimplekid diff --git a/misc/regtest_helper.sh b/misc/regtest_helper.sh new file mode 100755 index 000000000..6d858ccbe --- /dev/null +++ b/misc/regtest_helper.sh @@ -0,0 +1,455 @@ +#!/usr/bin/env bash + +# Helper script for interacting with CDK regtest environment +# Run this after starting interactive_regtest_mprocs.sh + +# Check for environment state file first, then environment variable +ENV_FILE="/tmp/cdk_regtest_env" +if [ -f "$ENV_FILE" ]; then + source "$ENV_FILE" +elif [ ! -z "$CDK_ITESTS_DIR" ]; then + # Environment variable is set, create state file for other sessions + echo "export CDK_ITESTS_DIR=\"$CDK_ITESTS_DIR\"" > "$ENV_FILE" + echo "export CDK_TEST_MINT_URL=\"$CDK_TEST_MINT_URL\"" >> "$ENV_FILE" + echo "export CDK_TEST_MINT_URL_2=\"$CDK_TEST_MINT_URL_2\"" >> "$ENV_FILE" + echo "export CDK_TEST_MINT_URL_3=\"$CDK_TEST_MINT_URL_3\"" >> "$ENV_FILE" + echo "export CDK_MINTD_PID=\"$CDK_MINTD_PID\"" >> "$ENV_FILE" + echo "export CDK_MINTD_LND_PID=\"$CDK_MINTD_LND_PID\"" >> "$ENV_FILE" + echo "export CDK_REGTEST_PID=\"$CDK_REGTEST_PID\"" >> "$ENV_FILE" +else + echo "❌ CDK regtest environment not found!" + echo "Please run './misc/interactive_regtest_mprocs.sh' or 'just regtest' first" + exit 1 +fi + +# Validate that the environment is actually running +if [ -z "$CDK_ITESTS_DIR" ] || [ ! -d "$CDK_ITESTS_DIR" ]; then + echo "❌ CDK regtest environment not found or directory missing!" + echo "Please run './misc/interactive_regtest_mprocs.sh' or 'just regtest' first" + [ -f "$ENV_FILE" ] && rm "$ENV_FILE" # Clean up stale state file + exit 1 +fi + +show_help() { + echo "CDK Regtest Environment Helper" + echo "=============================" + echo + echo "Lightning Node Commands:" + echo " ln-cln1 - Execute command on CLN node 1" + echo " ln-cln2 - Execute command on CLN node 2" + echo " ln-lnd1 - Execute command on LND node 1" + echo " ln-lnd2 - Execute command on LND node 2" + echo + echo "Bitcoin Commands:" + echo " btc - Execute bitcoin-cli command" + echo " btc-mine [blocks] - Mine blocks (default: 10)" + echo + echo "CDK Mint Commands:" + echo " mint-info - Show mint information" + echo " mint-test - Run integration tests" + echo " restart-mints - Stop, recompile, and restart both mints (log mode)" + echo + echo "Environment Commands:" + echo " show-env - Show environment variables" + echo " show-logs - Show recent mint logs" + echo " show-status - Show status of all components" + echo " logs - Start mprocs TUI (adapts to current mode)" + echo + echo "Environment Modes:" + echo " just regtest - Log tailing mode (mints auto-start, logs to files)" + echo " just regtest-mprocs - Direct management (mprocs controls mint processes)" + echo + echo "Examples:" + echo " $0 ln-cln1 getinfo" + echo " $0 ln-lnd1 getinfo" + echo " $0 btc getblockcount" + echo " $0 btc-mine 5" + echo " $0 mint-info" + echo " $0 restart-mints # Only works in log tailing mode" + echo " $0 logs # Start mprocs viewer" +} + +# Bitcoin commands +btc_command() { + bitcoin-cli -regtest -rpcuser=testuser -rpcpassword=testpass -rpcport=18443 "$@" +} + +btc_mine() { + local blocks=${1:-10} + local address=$(btc_command getnewaddress) + btc_command generatetoaddress "$blocks" "$address" + echo "Mined $blocks blocks" +} + +# CLN commands +cln_command() { + local node=$1 + shift + lightning-cli --rpc-file="$CDK_ITESTS_DIR/cln/$node/regtest/lightning-rpc" "$@" +} + +# LND commands +lnd_command() { + local node=$1 + shift + local port + case $node in + "one") port=10009 ;; + "two") port=10010 ;; + *) echo "Unknown LND node: $node"; return 1 ;; + esac + + lncli --rpcserver=localhost:$port \ + --tlscertpath="$CDK_ITESTS_DIR/lnd/$node/tls.cert" \ + --macaroonpath="$CDK_ITESTS_DIR/lnd/$node/data/chain/bitcoin/regtest/admin.macaroon" \ + "$@" +} + +# Mint commands +mint_info() { + echo "CLN Mint (Port 8085):" + curl -s "$CDK_TEST_MINT_URL/v1/info" | jq . 2>/dev/null || curl -s "$CDK_TEST_MINT_URL/v1/info" + echo + echo "LND Mint (Port 8087):" + curl -s "$CDK_TEST_MINT_URL_2/v1/info" | jq . 2>/dev/null || curl -s "$CDK_TEST_MINT_URL_2/v1/info" + echo + if [ ! -z "$CDK_TEST_MINT_URL_3" ]; then + echo "LDK Node Mint (Port 8089):" + curl -s "$CDK_TEST_MINT_URL_3/v1/info" | jq . 2>/dev/null || curl -s "$CDK_TEST_MINT_URL_3/v1/info" + fi +} + +mint_test() { + echo "Running integration tests..." + cargo test -p cdk-integration-tests +} + +# Environment info +show_env() { + echo "CDK Regtest Environment Variables:" + echo "=================================" + echo "CDK_ITESTS_DIR=$CDK_ITESTS_DIR" + echo "CDK_TEST_MINT_URL=$CDK_TEST_MINT_URL" + echo "CDK_TEST_MINT_URL_2=$CDK_TEST_MINT_URL_2" + if [ ! -z "$CDK_TEST_MINT_URL_3" ]; then + echo "CDK_TEST_MINT_URL_3=$CDK_TEST_MINT_URL_3" + fi + echo "CDK_MINTD_PID=$CDK_MINTD_PID" + echo "CDK_MINTD_LND_PID=$CDK_MINTD_LND_PID" + echo "CDK_REGTEST_PID=$CDK_REGTEST_PID" +} + +show_logs() { + echo "=== Recent CLN Mint Logs ===" + if [ -f "$CDK_ITESTS_DIR/cln_mint/mintd.log" ]; then + tail -10 "$CDK_ITESTS_DIR/cln_mint/mintd.log" + else + echo "Log file not found" + fi + echo + echo "=== Recent LND Mint Logs ===" + if [ -f "$CDK_ITESTS_DIR/lnd_mint/mintd.log" ]; then + tail -10 "$CDK_ITESTS_DIR/lnd_mint/mintd.log" + else + echo "Log file not found" + fi + echo + if [ ! -z "$CDK_TEST_MINT_URL_3" ]; then + echo "=== Recent LDK Node Mint Logs ===" + if [ -f "$CDK_ITESTS_DIR/ldk_node_mint/mintd.log" ]; then + tail -10 "$CDK_ITESTS_DIR/ldk_node_mint/mintd.log" + else + echo "Log file not found" + fi + fi +} + +start_mprocs() { + echo "Starting mprocs log viewer..." + + if ! command -v mprocs >/dev/null 2>&1; then + echo "❌ mprocs not found! Please install it with:" + echo " cargo install mprocs" + echo " or your package manager" + return 1 + fi + + # Check if we have the direct process management config + DIRECT_MPROCS_CONFIG="$CDK_ITESTS_DIR/mprocs.yaml" + FALLBACK_MPROCS_CONFIG="$CDK_ITESTS_DIR/mprocs_fallback.yaml" + + if [ -f "$DIRECT_MPROCS_CONFIG" ]; then + echo "Using direct process management mode..." + echo "In mprocs: 's' to start, 'k' to kill, 'r' to restart processes" + cd "$CDK_ITESTS_DIR" + mprocs --config "$DIRECT_MPROCS_CONFIG" + return + fi + + # Create fallback mprocs configuration for log tailing + cat > "$FALLBACK_MPROCS_CONFIG" << EOF +procs: + cln-mint: + shell: "touch $CDK_ITESTS_DIR/cln_mint/mintd.log && tail -f $CDK_ITESTS_DIR/cln_mint/mintd.log" + autostart: true + + lnd-mint: + shell: "touch $CDK_ITESTS_DIR/lnd_mint/mintd.log && tail -f $CDK_ITESTS_DIR/lnd_mint/mintd.log" + autostart: true + + ldk-node-mint: + shell: "touch $CDK_ITESTS_DIR/ldk_node_mint/mintd.log && tail -f $CDK_ITESTS_DIR/ldk_node_mint/mintd.log" + autostart: true + + bitcoind: + shell: "touch $CDK_ITESTS_DIR/bitcoin/regtest/debug.log && tail -f $CDK_ITESTS_DIR/bitcoin/regtest/debug.log" + autostart: true + + cln-one: + shell: "while [ ! -f $CDK_ITESTS_DIR/cln/one/regtest/log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/cln/one/regtest/log" + autostart: true + + cln-two: + shell: "while [ ! -f $CDK_ITESTS_DIR/cln/two/regtest/log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/cln/two/regtest/log" + autostart: true + + lnd-one: + shell: "while [ ! -f $CDK_ITESTS_DIR/lnd/one/logs/bitcoin/regtest/lnd.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/lnd/one/logs/bitcoin/regtest/lnd.log" + autostart: true + + lnd-two: + shell: "while [ ! -f $CDK_ITESTS_DIR/lnd/two/logs/bitcoin/regtest/lnd.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/lnd/two/logs/bitcoin/regtest/lnd.log" + autostart: true + + ldk-node: + shell: "while [ ! -f $CDK_ITESTS_DIR/ldk_node_mint/ldk_storage/ldk_node.log ]; do sleep 1; done && tail -f $CDK_ITESTS_DIR/ldk_node_mint/ldk_storage/ldk_node.log" + autostart: true + +settings: + mouse_scroll_speed: 3 + proc_list_width: 20 + hide_keymap_window: false +EOF + + echo "Using log tailing mode..." + echo "Use 'q' to quit the log viewer" + cd "$CDK_ITESTS_DIR" + mprocs --config "$FALLBACK_MPROCS_CONFIG" +} + +show_status() { + echo "CDK Regtest Environment Status:" + echo "===============================" + + # Check processes + echo "Processes:" + if [ ! -z "$CDK_REGTEST_PID" ] && kill -0 $CDK_REGTEST_PID 2>/dev/null; then + echo " ✓ Regtest network (PID: $CDK_REGTEST_PID)" + else + echo " ❌ Regtest network" + fi + + if [ ! -z "$CDK_MINTD_PID" ] && kill -0 $CDK_MINTD_PID 2>/dev/null; then + echo " ✓ CLN Mint (PID: $CDK_MINTD_PID)" + else + echo " ❌ CLN Mint" + fi + + if [ ! -z "$CDK_MINTD_LND_PID" ] && kill -0 $CDK_MINTD_LND_PID 2>/dev/null; then + echo " ✓ LND Mint (PID: $CDK_MINTD_LND_PID)" + else + echo " ❌ LND Mint" + fi + + echo + echo "Network connectivity:" + if curl -s "$CDK_TEST_MINT_URL/v1/info" >/dev/null 2>&1; then + echo " ✓ CLN Mint responding" + else + echo " ❌ CLN Mint not responding" + fi + + if curl -s "$CDK_TEST_MINT_URL_2/v1/info" >/dev/null 2>&1; then + echo " ✓ LND Mint responding" + else + echo " ❌ LND Mint not responding" + fi + + if [ ! -z "$CDK_TEST_MINT_URL_3" ]; then + if curl -s "$CDK_TEST_MINT_URL_3/v1/info" >/dev/null 2>&1; then + echo " ✓ LDK Node Mint responding" + else + echo " ❌ LDK Node Mint not responding" + fi + fi +} + +restart_mints() { + echo "===============================" + echo "Restarting CDK Mints" + echo "===============================" + + # Stop existing mints + echo "Stopping existing mints..." + if [ ! -z "$CDK_MINTD_PID" ] && kill -0 $CDK_MINTD_PID 2>/dev/null; then + echo " Stopping CLN Mint (PID: $CDK_MINTD_PID)" + kill -2 $CDK_MINTD_PID + wait $CDK_MINTD_PID 2>/dev/null || true + fi + + if [ ! -z "$CDK_MINTD_LND_PID" ] && kill -0 $CDK_MINTD_LND_PID 2>/dev/null; then + echo " Stopping LND Mint (PID: $CDK_MINTD_LND_PID)" + kill -2 $CDK_MINTD_LND_PID + wait $CDK_MINTD_LND_PID 2>/dev/null || true + fi + + # Recompile + echo "Recompiling cdk-mintd..." + if ! cargo build --bin cdk-mintd; then + echo "❌ Compilation failed" + return 1 + fi + echo "✓ Compilation successful" + + # Restart CLN mint + echo "Starting CLN Mint..." + export CDK_MINTD_CLN_RPC_PATH="$CDK_ITESTS_DIR/cln/one/regtest/lightning-rpc" + export CDK_MINTD_URL="http://127.0.0.1:8085" + export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR/cln_mint" + export CDK_MINTD_LISTEN_HOST="127.0.0.1" + export CDK_MINTD_LISTEN_PORT=8085 + export CDK_MINTD_LN_BACKEND="cln" + export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal" + export RUST_BACKTRACE=1 + + cargo run --bin cdk-mintd > "$CDK_MINTD_WORK_DIR/mintd.log" 2>&1 & + NEW_CLN_PID=$! + + # Wait for CLN mint to be ready + echo "Waiting for CLN mint to start..." + local start_time=$(date +%s) + while true; do + local current_time=$(date +%s) + local elapsed_time=$((current_time - start_time)) + + if [ $elapsed_time -ge 30 ]; then + echo "❌ Timeout waiting for CLN mint" + return 1 + fi + + if curl -s "http://127.0.0.1:8085/v1/info" >/dev/null 2>&1; then + echo "✓ CLN Mint ready" + break + fi + sleep 1 + done + + # Restart LND mint + echo "Starting LND Mint..." + export CDK_MINTD_LND_ADDRESS="https://localhost:10010" + export CDK_MINTD_LND_CERT_FILE="$CDK_ITESTS_DIR/lnd/two/tls.cert" + export CDK_MINTD_LND_MACAROON_FILE="$CDK_ITESTS_DIR/lnd/two/data/chain/bitcoin/regtest/admin.macaroon" + export CDK_MINTD_URL="http://127.0.0.1:8087" + export CDK_MINTD_WORK_DIR="$CDK_ITESTS_DIR/lnd_mint" + export CDK_MINTD_LISTEN_HOST="127.0.0.1" + export CDK_MINTD_LISTEN_PORT=8087 + export CDK_MINTD_LN_BACKEND="lnd" + export CDK_MINTD_MNEMONIC="cattle gold bind busy sound reduce tone addict baby spend february strategy" + + cargo run --bin cdk-mintd > "$CDK_MINTD_WORK_DIR/mintd.log" 2>&1 & + NEW_LND_PID=$! + + # Wait for LND mint to be ready + echo "Waiting for LND mint to start..." + start_time=$(date +%s) + while true; do + current_time=$(date +%s) + elapsed_time=$((current_time - start_time)) + + if [ $elapsed_time -ge 30 ]; then + echo "❌ Timeout waiting for LND mint" + return 1 + fi + + if curl -s "http://127.0.0.1:8087/v1/info" >/dev/null 2>&1; then + echo "✓ LND Mint ready" + break + fi + sleep 1 + done + + # Update PIDs in state file + CDK_MINTD_PID=$NEW_CLN_PID + CDK_MINTD_LND_PID=$NEW_LND_PID + + # Update state file + echo "export CDK_ITESTS_DIR=\"$CDK_ITESTS_DIR\"" > "$ENV_FILE" + echo "export CDK_TEST_MINT_URL=\"$CDK_TEST_MINT_URL\"" >> "$ENV_FILE" + echo "export CDK_TEST_MINT_URL_2=\"$CDK_TEST_MINT_URL_2\"" >> "$ENV_FILE" + echo "export CDK_MINTD_PID=\"$CDK_MINTD_PID\"" >> "$ENV_FILE" + echo "export CDK_MINTD_LND_PID=\"$CDK_MINTD_LND_PID\"" >> "$ENV_FILE" + echo "export CDK_REGTEST_PID=\"$CDK_REGTEST_PID\"" >> "$ENV_FILE" + + echo + echo "✅ Mints restarted successfully!" + echo " CLN Mint: http://127.0.0.1:8085 (PID: $CDK_MINTD_PID)" + echo " LND Mint: http://127.0.0.1:8087 (PID: $CDK_MINTD_LND_PID)" + echo "===============================" +} + +# Main command dispatcher +case "$1" in + "ln-cln1") + shift + cln_command "one" "$@" + ;; + "ln-cln2") + shift + cln_command "two" "$@" + ;; + "ln-lnd1") + shift + lnd_command "one" "$@" + ;; + "ln-lnd2") + shift + lnd_command "two" "$@" + ;; + "btc") + shift + btc_command "$@" + ;; + "btc-mine") + shift + btc_mine "$@" + ;; + "mint-info") + mint_info + ;; + "mint-test") + mint_test + ;; + "restart-mints") + restart_mints + ;; + "show-env") + show_env + ;; + "show-logs") + show_logs + ;; + "show-status") + show_status + ;; + "logs") + start_mprocs + ;; + "help"|"-h"|"--help"|"") + show_help + ;; + *) + echo "Unknown command: $1" + echo "Run '$0 help' for available commands" + exit 1 + ;; +esac diff --git a/misc/scripts/filtered_ldk_node_log.sh b/misc/scripts/filtered_ldk_node_log.sh new file mode 100755 index 000000000..10ee9541e --- /dev/null +++ b/misc/scripts/filtered_ldk_node_log.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# Filtered log viewer for LDK Node that excludes "falling back to default fee rate" messages +# Usage: ./misc/scripts/filtered_ldk_node_log.sh [log_file_path] + +LOG_FILE="$1" + +# If no log file specified, use the default pattern +if [ -z "$LOG_FILE" ]; then + LOG_FILE="$CDK_ITESTS_DIR/ldk_mint/ldk_node.log" +fi + +# Wait for log file to exist, then tail it with filtering +while [ ! -f "$LOG_FILE" ]; do + sleep 1 +done + +# Tail the log file and filter out fee rate fallback messages +tail -f "$LOG_FILE" | grep -v -E "Falling back to default of 1 sat/vb|Failed to retrieve fee rate estimates" diff --git a/portal-cmds.txt b/portal-cmds.txt new file mode 100644 index 000000000..f892e971d --- /dev/null +++ b/portal-cmds.txt @@ -0,0 +1,15 @@ + + +cargo run -p cdk-cli -- --unit casa balance --unit casa + +cargo run -p cdk-cli -- --unit casa mint "http://127.0.0.1:8085" 100 "my real mint" + +cargo run -p cdk-cli -- --unit casa send + + + +curl -v -X POST "http://localhost:8085/v1/mint/quote/bolt11" -H "Content-Type: application/json" -H "Clear-auth: my-static-token" -d '{ "amount": 1000,"unit": "casa" }' + + + +curl -v -X POST "http://localhost:8085/v1/mint/quote/bolt11" -H "Content-Type: application/json" -H "Clear-auth: no-token" -d '{ "amount": 1000,"unit": "casa" }' diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b557f5a45..b7b8acf2f 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel="1.86.0" +channel="1.91.1" components = ["rustfmt", "clippy", "rust-analyzer"]