From 256cd8aac05ca2071272663357513948eba94eee Mon Sep 17 00:00:00 2001 From: 9qeklajc <9qeklajc> Date: Wed, 15 Jul 2026 01:37:44 +0200 Subject: [PATCH] Add mint fallback for Cashu topups --- package.json | 2 +- src/daemon/http/index.ts | 24 +++++- src/daemon/index.ts | 18 ++++ src/daemon/wallet/auto-refill.ts | 16 ++-- src/daemon/wallet/index.ts | 31 +++++-- src/daemon/wallet/mint-fallback.ts | 89 +++++++++++++++++++ src/daemon/wallet/sdk-mint-fallback.ts | 114 +++++++++++++++++++++++++ tests/mint-fallback.test.ts | 85 ++++++++++++++++++ tests/sdk-mint-fallback.test.ts | 86 +++++++++++++++++++ 9 files changed, 448 insertions(+), 17 deletions(-) create mode 100644 src/daemon/wallet/mint-fallback.ts create mode 100644 src/daemon/wallet/sdk-mint-fallback.ts create mode 100644 tests/mint-fallback.test.ts create mode 100644 tests/sdk-mint-fallback.test.ts diff --git a/package.json b/package.json index c5b40d1..b4b1e49 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "routstrd", - "version": "0.3.10", + "version": "0.3.11", "module": "src/index.ts", "type": "module", "private": false, diff --git a/src/daemon/http/index.ts b/src/daemon/http/index.ts index 96b4d59..7f701b6 100644 --- a/src/daemon/http/index.ts +++ b/src/daemon/http/index.ts @@ -16,6 +16,7 @@ import { type CocodState, } from "../wallet/cocod-client"; import { decodeCashuTokenAmount } from "../wallet"; +import { receiveBolt11WithMintFallback } from "../wallet/mint-fallback"; import { getClientsFromStore } from "../../utils/clients"; import { getUsageSummary } from "./usage-summary"; @@ -47,6 +48,7 @@ type DaemonDeps = { /** Nostr hex pubkey for routstr review/model events (kind 38425/38423). */ routstrPubkey?: string; providerManager: ProviderManager; + routeClient: any; refundClient: any; requestResponseLogSink?: RequestResponseLogSink; }; @@ -302,6 +304,7 @@ export function createDaemonRequestHandler(deps: { routstrPubkey?: string; usageTrackingDriver: UsageTrackingDriver; providerManager: ProviderManager; + routeClient: any; refundClient: any; requestResponseLogSink?: RequestResponseLogSink; }) { @@ -373,8 +376,24 @@ export function createDaemonRequestHandler(deps: { await respond(res, async () => { const body = await readJsonBody(req); const amount = getRequiredPositiveNumberField(body, "amount"); - const mintUrl = optionalStringField(body, "mintUrl"); - const invoice = await deps.walletClient.receiveBolt11(amount, mintUrl); + const requestedMintUrl = optionalStringField(body, "mintUrl"); + let invoice: string; + let mintUrl: string | undefined = requestedMintUrl; + + if (requestedMintUrl) { + invoice = await deps.walletClient.receiveBolt11(amount, requestedMintUrl); + } else { + const mints = await deps.walletClient.listMints(); + const result = await receiveBolt11WithMintFallback( + deps.walletClient, + amount, + mints, + "[wallet]", + ); + invoice = result.invoice; + mintUrl = result.mintUrl; + } + return { output: { invoice, amount, mintUrl } }; }); return; @@ -1273,6 +1292,7 @@ export function createDaemonRequestHandler(deps: { usageTrackingDriver: deps.usageTrackingDriver, sdkStore: deps.store, providerManager: deps.providerManager, + client: deps.routeClient, logger: reqLogger, ...(deps.requestResponseLogSink ? { requestResponseLogSink: deps.requestResponseLogSink } diff --git a/src/daemon/index.ts b/src/daemon/index.ts index c44e605..d26cda0 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -43,6 +43,7 @@ import { import { createWalletAdapter } from "./wallet"; import type { AutoRefillConfig } from "./wallet/auto-refill"; import { createCocodClient } from "./wallet/cocod-client"; +import { installMintFallbackTopUp } from "./wallet/sdk-mint-fallback"; import { createModelService } from "./models"; import { createDaemonRequestHandler } from "./http"; import { FileRequestResponseLogSink } from "./request-response-log-sink"; @@ -129,6 +130,22 @@ async function main(): Promise { nwcConnectionString: config.nwc?.connectionString, }); + const routeClient = new RoutstrClient( + walletAdapter, + storageAdapter, + discoveryAdapter, + "min", + config.mode || "apikeys", + { + usageTrackingDriver, + sdkStore: store, + providerManager, + logger: daemonSdkLogger, + requestResponseLogSink, + }, + ); + installMintFallbackTopUp(routeClient, walletClient, walletAdapter, daemonSdkLogger); + const refundClient = new RoutstrClient( walletAdapter, storageAdapter, @@ -158,6 +175,7 @@ async function main(): Promise { routstrPubkey: config.routstrPubkey, usageTrackingDriver, providerManager, + routeClient, refundClient, requestResponseLogSink, }), diff --git a/src/daemon/wallet/auto-refill.ts b/src/daemon/wallet/auto-refill.ts index e7eb1b7..e828acd 100644 --- a/src/daemon/wallet/auto-refill.ts +++ b/src/daemon/wallet/auto-refill.ts @@ -7,6 +7,7 @@ import type { CocodClient } from "./cocod-client"; import type { WalletConnect } from "applesauce-wallet-connect"; import { logger } from "../../utils/logger"; +import { receiveBolt11WithMintFallback } from "./mint-fallback"; export interface AutoRefillConfig { /** Minimum sats balance before triggering a refill */ @@ -94,19 +95,24 @@ export function startAutoRefillLoop( `[auto-refill] Balance ${totalBalance} sats < threshold ${config.threshold}. Refilling ${config.amount} sats...`, ); - // Get active mint + // Get configured mints. If the active mint is unreachable, fall back to + // later configured mints before giving up. const mints = await cocod.listMints(); - const mintUrl = mints[0]; - if (!mintUrl) { + if (mints.length === 0) { logger.error("[auto-refill] No active mint configured"); return; } // Step 1: Create a BOLT-11 invoice via cocod to fund the Cashu wallet logger.log( - `[auto-refill] Creating BOLT-11 invoice for ${config.amount} sats via ${mintUrl}...`, + `[auto-refill] Creating BOLT-11 invoice for ${config.amount} sats via ${mints[0]}...`, + ); + const { invoice, mintUrl } = await receiveBolt11WithMintFallback( + cocod, + config.amount, + mints, + "[auto-refill]", ); - const invoice = await cocod.receiveBolt11(config.amount, mintUrl); // Step 2: Pay the invoice via NWC (applesauce) logger.log(`[auto-refill] Paying invoice via NWC...`); diff --git a/src/daemon/wallet/index.ts b/src/daemon/wallet/index.ts index 4d2f96f..10c5bdc 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -5,6 +5,7 @@ import { RelayPool } from "applesauce-relay"; import { logger } from "../../utils/logger"; import { createCocodClient, type CocodClient } from "./cocod-client"; import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill"; +import { receiveBolt11WithMintFallback } from "./mint-fallback"; export function decodeCashuTokenAmount(token: string): { amount: number; @@ -151,16 +152,33 @@ export async function createWalletAdapter( return { success: false, invoice: "", error: "NWC not connected" }; } - // Ensure we have an active mint + // Ensure we have configured mints. If the active mint is unreachable, + // invoice creation will fall back to later configured mints. await syncMintState(); - const mintUrl = activeMintUrl; - if (!mintUrl) { + const configuredMints = await client.listMints(); + const mintCandidates = [activeMintUrl, ...configuredMints].filter( + (mintUrl): mintUrl is string => typeof mintUrl === "string" && mintUrl.length > 0, + ); + if (mintCandidates.length === 0) { logger.error("[nwc] No active mint configured"); return { success: false, invoice: "", error: "No active mint configured" }; } try { - // Step 1: Check initial balance + // Step 1: Create a BOLT-11 invoice via cocod + logger.log(`[nwc] Creating ${amount}-sat Lightning invoice via cocod...`); + const { invoice, mintUrl } = await receiveBolt11WithMintFallback( + client, + amount, + mintCandidates, + "[nwc]", + ); + logger.log(`[nwc] Invoice: ${invoice}`); + if (mintUrl !== activeMintUrl) { + logger.log(`[nwc] Using fallback mint: ${mintUrl}`); + } + + // Step 2: Check initial balance logger.log(`[nwc] Checking initial cocod balance on mint ${mintUrl}...`); let initialBalance: number | null = null; try { @@ -171,11 +189,6 @@ export async function createWalletAdapter( logger.log("[nwc] Could not retrieve initial balance"); } - // Step 2: Create a BOLT-11 invoice via cocod - logger.log(`[nwc] Creating ${amount}-sat Lightning invoice via cocod...`); - const invoice = await client.receiveBolt11(amount, mintUrl); - logger.log(`[nwc] Invoice: ${invoice}`); - // Step 3: Pay it via NWC logger.log("[nwc] Paying invoice via NWC..."); const { preimage, fees_paid } = await wallet.payInvoice(invoice); diff --git a/src/daemon/wallet/mint-fallback.ts b/src/daemon/wallet/mint-fallback.ts new file mode 100644 index 0000000..8c3672b --- /dev/null +++ b/src/daemon/wallet/mint-fallback.ts @@ -0,0 +1,89 @@ +import type { CocodClient } from "./cocod-client"; +import { logger } from "../../utils/logger"; + +export interface Bolt11InvoiceResult { + invoice: string; + mintUrl: string; +} + +function uniqueMintUrls(mints: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const mint of mints) { + const trimmed = mint?.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + result.push(trimmed); + } + return result; +} + +function inspectForMintUnreachable(value: unknown): boolean { + if (typeof value === "string") { + const trimmed = value.trim(); + if (/mint[_-]unreachable/i.test(trimmed) || /mint\b.*\bunreachable/i.test(trimmed)) return true; + + if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]"))) { + try { + return inspectForMintUnreachable(JSON.parse(trimmed)); + } catch { + return false; + } + } + + return false; + } + + if (!value || typeof value !== "object") return false; + + if (value instanceof Error) { + return inspectForMintUnreachable(value.message) || + inspectForMintUnreachable((value as Error & { cause?: unknown }).cause); + } + + return Object.values(value).some((entry) => inspectForMintUnreachable(entry)); +} + +export function isMintUnreachableError(error: unknown): boolean { + return inspectForMintUnreachable(error); +} + +export async function receiveBolt11WithMintFallback( + cocod: CocodClient, + amount: number, + mintUrls: string[], + context: string, +): Promise { + const candidates = uniqueMintUrls(mintUrls); + if (candidates.length === 0) { + throw new Error("No active mint configured"); + } + + let lastMintUnreachableError: unknown; + for (const [index, mintUrl] of candidates.entries()) { + try { + if (index > 0) { + logger.log( + `${context} Retrying top-up with fallback mint ${mintUrl} (${index + 1}/${candidates.length})...`, + ); + } + const invoice = await cocod.receiveBolt11(amount, mintUrl); + return { invoice, mintUrl }; + } catch (error) { + if (!isMintUnreachableError(error)) { + throw error; + } + + lastMintUnreachableError = error; + logger.warn( + `${context} Mint unreachable while creating top-up invoice via ${mintUrl}.` + + (index + 1 < candidates.length ? " Trying next configured mint." : " No fallback mints left."), + ); + } + } + + throw lastMintUnreachableError instanceof Error + ? lastMintUnreachableError + : new Error("All configured mints are unreachable"); +} diff --git a/src/daemon/wallet/sdk-mint-fallback.ts b/src/daemon/wallet/sdk-mint-fallback.ts new file mode 100644 index 0000000..eef336c --- /dev/null +++ b/src/daemon/wallet/sdk-mint-fallback.ts @@ -0,0 +1,114 @@ +import type { CocodClient } from "./cocod-client"; +import { isMintUnreachableError } from "./mint-fallback"; + +type LoggerLike = { + log: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; +}; + +type TopUpOptions = { + mintUrl: string; + baseUrl: string; + amount: number; + token?: string; +}; + +type TopUpResult = { + success: boolean; + message?: unknown; + error?: unknown; + [key: string]: unknown; +}; + +type BalanceManagerLike = { + topUp(options: TopUpOptions): Promise; +}; + +type RoutstrClientLike = { + getBalanceManager(): unknown; +}; + +type WalletAdapterLike = { + getBalances(): Promise>; +}; + +const PATCH_MARKER = Symbol.for("routstrd.mintFallbackTopUpPatched"); + +function uniqueMintUrls(mints: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const mint of mints) { + const trimmed = mint?.trim(); + if (!trimmed || seen.has(trimmed)) continue; + seen.add(trimmed); + result.push(trimmed); + } + return result; +} + +function isMintUnreachableTopUpResult(result: TopUpResult): boolean { + return !result.success && + (isMintUnreachableError(result.message) || isMintUnreachableError(result.error)); +} + +async function getTopUpMintCandidates( + initialMintUrl: string, + walletClient: CocodClient, + walletAdapter: WalletAdapterLike, +): Promise { + const [configuredMints, balances] = await Promise.all([ + walletClient.listMints().catch(() => []), + walletAdapter.getBalances().catch(() => ({})), + ]); + + return uniqueMintUrls([ + initialMintUrl, + ...configuredMints, + ...Object.keys(balances), + ]); +} + +export function installMintFallbackTopUp( + client: RoutstrClientLike, + walletClient: CocodClient, + walletAdapter: WalletAdapterLike, + logger: LoggerLike, +): void { + const balanceManager = client.getBalanceManager() as BalanceManagerLike & { + [PATCH_MARKER]?: boolean; + }; + if (balanceManager[PATCH_MARKER]) return; + + const originalTopUp = balanceManager.topUp.bind(balanceManager); + balanceManager.topUp = async (options: TopUpOptions): Promise => { + const candidates = await getTopUpMintCandidates( + options.mintUrl, + walletClient, + walletAdapter, + ); + + let lastMintUnreachableResult: TopUpResult | undefined; + for (const [index, mintUrl] of candidates.entries()) { + if (index > 0) { + logger.log( + `[wallet] Retrying provider top-up with fallback mint ${mintUrl} (${index + 1}/${candidates.length})...`, + ); + } + + const result = await originalTopUp({ ...options, mintUrl }); + if (!isMintUnreachableTopUpResult(result)) { + return result; + } + + lastMintUnreachableResult = result; + logger.warn( + `[wallet] Provider top-up mint unreachable for ${mintUrl}.` + + (index + 1 < candidates.length ? " Trying next configured mint." : " No fallback mints left."), + ); + } + + return lastMintUnreachableResult ?? originalTopUp(options); + }; + + balanceManager[PATCH_MARKER] = true; +} diff --git a/tests/mint-fallback.test.ts b/tests/mint-fallback.test.ts new file mode 100644 index 0000000..cd64ce2 --- /dev/null +++ b/tests/mint-fallback.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import type { CocodClient } from "../src/daemon/wallet/cocod-client"; +import { + isMintUnreachableError, + receiveBolt11WithMintFallback, +} from "../src/daemon/wallet/mint-fallback"; + +function createClient(receiveBolt11: CocodClient["receiveBolt11"]): CocodClient { + return { + ping: async () => true, + getStatus: async () => "UNLOCKED", + unlock: async () => "ok", + getBalances: async () => ({}), + receiveCashu: async () => "ok", + receiveBolt11, + sendCashu: async () => "token", + sendBolt11: async () => "ok", + listMints: async () => [], + addMint: async () => "ok", + getMintInfo: async () => ({}), + }; +} + +describe("mint fallback", () => { + test("detects mint_unreachable in nested error payloads", () => { + expect(isMintUnreachableError(new Error("mint_unreachable"))).toBe(true); + expect( + isMintUnreachableError( + new Error( + "The mint that issued this Cashu token is unreachable; the token cannot be redeemed at another mint", + ), + ), + ).toBe(true); + expect( + isMintUnreachableError( + JSON.stringify({ detail: { error: { type: "mint_unreachable" } } }), + ), + ).toBe(true); + expect(isMintUnreachableError(new Error("invoice expired"))).toBe(false); + }); + + test("tries the next configured mint only for mint_unreachable", async () => { + const attempts: string[] = []; + const client = createClient(async (_amount, mintUrl) => { + attempts.push(mintUrl || ""); + if (mintUrl === "https://mint-a.example") { + throw new Error( + JSON.stringify({ detail: { error: { type: "mint_unreachable" } } }), + ); + } + return "lnbc1fallback"; + }); + + const result = await receiveBolt11WithMintFallback( + client, + 21, + ["https://mint-a.example", "https://mint-b.example"], + "[test]", + ); + + expect(result).toEqual({ + invoice: "lnbc1fallback", + mintUrl: "https://mint-b.example", + }); + expect(attempts).toEqual(["https://mint-a.example", "https://mint-b.example"]); + }); + + test("does not fallback for non-mint-unreachable errors", async () => { + const attempts: string[] = []; + const client = createClient(async (_amount, mintUrl) => { + attempts.push(mintUrl || ""); + throw new Error("insufficient balance"); + }); + + await expect( + receiveBolt11WithMintFallback( + client, + 21, + ["https://mint-a.example", "https://mint-b.example"], + "[test]", + ), + ).rejects.toThrow("insufficient balance"); + expect(attempts).toEqual(["https://mint-a.example"]); + }); +}); diff --git a/tests/sdk-mint-fallback.test.ts b/tests/sdk-mint-fallback.test.ts new file mode 100644 index 0000000..840e3c3 --- /dev/null +++ b/tests/sdk-mint-fallback.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "bun:test"; +import type { CocodClient } from "../src/daemon/wallet/cocod-client"; +import { installMintFallbackTopUp } from "../src/daemon/wallet/sdk-mint-fallback"; + +function createCocodClient(mints: string[]): CocodClient { + return { + ping: async () => true, + getStatus: async () => "UNLOCKED", + unlock: async () => "ok", + getBalances: async () => ({}), + receiveCashu: async () => "ok", + receiveBolt11: async () => "invoice", + sendCashu: async () => "token", + sendBolt11: async () => "ok", + listMints: async () => mints, + addMint: async () => "ok", + getMintInfo: async () => ({}), + }; +} + +describe("SDK top-up mint fallback", () => { + test("retries provider top-up on mint_unreachable result", async () => { + const attempts: string[] = []; + const balanceManager = { + topUp: async (options: { mintUrl: string }) => { + attempts.push(options.mintUrl); + if (options.mintUrl === "https://mint-a.example") { + return { + success: false, + message: { detail: { error: { type: "mint_unreachable" } } }, + }; + } + return { success: true, toppedUpAmount: 21 }; + }, + }; + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = { + getBalances: async () => ({ "https://mint-c.example": 100 }), + }; + + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example", "https://mint-b.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined }, + ); + + const result = await balanceManager.topUp({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + token: "sk-test", + }); + + expect(result.success).toBe(true); + expect(attempts).toEqual(["https://mint-a.example", "https://mint-b.example"]); + }); + + test("does not retry provider top-up on unrelated failure", async () => { + const attempts: string[] = []; + const balanceManager = { + topUp: async (options: { mintUrl: string }) => { + attempts.push(options.mintUrl); + return { success: false, message: "insufficient balance" }; + }, + }; + const client = { getBalanceManager: () => balanceManager }; + const walletAdapter = { getBalances: async () => ({}) }; + + installMintFallbackTopUp( + client, + createCocodClient(["https://mint-a.example", "https://mint-b.example"]), + walletAdapter, + { log: () => undefined, warn: () => undefined }, + ); + + const result = await balanceManager.topUp({ + mintUrl: "https://mint-a.example", + baseUrl: "https://provider.example", + amount: 21, + }); + + expect(result).toEqual({ success: false, message: "insufficient balance" }); + expect(attempts).toEqual(["https://mint-a.example"]); + }); +});