Add mint fallback for Cashu topups

This commit is contained in:
9qeklajc
2026-07-15 01:37:44 +02:00
parent 2a31fef10b
commit 256cd8aac0
9 changed files with 448 additions and 17 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "routstrd",
"version": "0.3.10",
"version": "0.3.11",
"module": "src/index.ts",
"type": "module",
"private": false,
+22 -2
View File
@@ -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 }
+18
View File
@@ -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<void> {
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<void> {
routstrPubkey: config.routstrPubkey,
usageTrackingDriver,
providerManager,
routeClient,
refundClient,
requestResponseLogSink,
}),
+11 -5
View File
@@ -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...`);
+22 -9
View File
@@ -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);
+89
View File
@@ -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 | undefined | null>): string[] {
const seen = new Set<string>();
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<Bolt11InvoiceResult> {
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");
}
+114
View File
@@ -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<TopUpResult>;
};
type RoutstrClientLike = {
getBalanceManager(): unknown;
};
type WalletAdapterLike = {
getBalances(): Promise<Record<string, number>>;
};
const PATCH_MARKER = Symbol.for("routstrd.mintFallbackTopUpPatched");
function uniqueMintUrls(mints: Array<string | undefined | null>): string[] {
const seen = new Set<string>();
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<string[]> {
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<TopUpResult> => {
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;
}
+85
View File
@@ -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"]);
});
});
+86
View File
@@ -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"]);
});
});