Files
routstrd/tests/sdk-mint-fallback.test.ts
Paperclip Deployment Engineer 412ddc8774 fix: force fallback loop to use selected mint by excluding failed mints
The SDK treats mintUrl as a preference, not a requirement — its own
balance-based candidate could win and make every outer retry hit the
same failed mint. Thread excludeMints through TopUpOptions and, for
each fallback candidate, exclude every other known candidate so the
SDK is forced to use the loop's chosen mint.
2026-08-07 21:11:17 +00:00

642 lines
22 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import type { CocodClient } from "../src/daemon/wallet/cocod-client";
import {
installMintFallbackTopUp,
installMintUnreachableErrorRetry,
} 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, error: () => 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("replaces an API key from a fallback mint before provider failover on mint_unreachable", async () => {
const removedApiKeys: string[] = [];
const spendAttempts: string[] = [];
const retryRequests: Array<{ baseUrl: string; mintUrl: string; token: string; mintFallbackAttempted?: boolean }> = [];
const client = {
mode: "apikeys",
getBalanceManager: () => ({ topUp: async () => ({ success: true }) }),
storageAdapter: {
removeApiKey: (baseUrl: string) => removedApiKeys.push(baseUrl),
},
_spendToken: async (options: { mintUrl: string; baseUrl: string }) => {
spendAttempts.push(options.mintUrl);
return {
token: `token-from-${options.mintUrl}`,
tokenBalance: 42,
tokenBalanceUnit: "sat",
tokenBalanceUnknown: false,
};
},
_withAuthAndTinfoilHeaders: (_headers: unknown, token: string) => ({ authorization: token }),
_makeRequest: async (options: { baseUrl: string; mintUrl: string; token: string }) => {
retryRequests.push(options);
return { ok: true };
},
_handleErrorResponse: async () => {
throw new Error("provider failover should not run");
},
};
const walletAdapter = {
getBalances: async () => ({ "https://mint-b.example": 100 }),
};
installMintUnreachableErrorRetry(
client,
createCocodClient(["https://mint-a.example", "https://mint-b.example"]),
walletAdapter,
{ log: () => undefined, warn: () => undefined, error: () => undefined },
);
const result = await client._handleErrorResponse(
{
baseUrl: "https://provider.example",
mintUrl: "https://mint-a.example",
requiredSats: 21,
baseHeaders: {},
tinfoilEnabled: false,
selectedModel: { id: "glm-5.2" },
},
"old-token",
503,
"request-id",
undefined,
JSON.stringify({ detail: { error: { type: "mint_unreachable" } } }),
);
expect(result).toMatchObject({
ok: true,
initialTokenBalanceInSats: 42,
initialTokenBalanceUnknown: false,
});
expect(removedApiKeys).toEqual(["https://provider.example"]);
expect(spendAttempts).toEqual(["https://mint-b.example"]);
expect(retryRequests).toMatchObject([
{
baseUrl: "https://provider.example",
mintUrl: "https://mint-b.example",
token: "token-from-https://mint-b.example",
mintFallbackAttempted: true,
},
]);
});
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, error: () => 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"]);
});
test("retries on 'Not enough proofs' as wallet-empty fallback", 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: "Send failed: Not enough proofs to send",
};
}
return { success: true, toppedUpAmount: 21 };
},
};
const client = { getBalanceManager: () => balanceManager };
const walletAdapter = {
getBalances: async () => ({ "https://mint-b.example": 100 }),
};
installMintFallbackTopUp(
client,
createCocodClient(["https://mint-a.example", "https://mint-b.example"]),
walletAdapter,
{ log: () => undefined, warn: () => undefined, error: () => undefined },
);
const result = await balanceManager.topUp({
mintUrl: "https://mint-a.example",
baseUrl: "https://provider.example",
amount: 21,
});
expect(result.success).toBe(true);
expect(attempts).toEqual(["https://mint-a.example", "https://mint-b.example"]);
});
test("falls back to lightning invoice when all mints are empty", async () => {
const attempts: string[] = [];
const balanceManager = {
topUp: async (options: { mintUrl: string }) => {
attempts.push(options.mintUrl);
return {
success: false,
message: "Send failed: Not enough proofs to send",
};
},
};
const client = { getBalanceManager: () => balanceManager };
const walletAdapter = {
getBalances: async () => ({}),
getNwcStatus: async () => ({ connected: false }),
};
const cocodClient = createCocodClient([
"https://mint-a.example",
"https://mint-b.example",
]);
installMintFallbackTopUp(
client,
cocodClient,
walletAdapter,
{ log: () => undefined, warn: () => undefined, error: () => undefined },
);
const result = await balanceManager.topUp({
mintUrl: "https://mint-a.example",
baseUrl: "https://provider.example",
amount: 21,
});
// Should have tried both mints, then attempted lightning invoice fallback
expect(attempts).toEqual(["https://mint-a.example", "https://mint-b.example"]);
// Final result is still the last failure (invoice was created but not paid)
expect(result.success).toBe(false);
});
test("createProviderToken retries on 'Not enough proofs' across mints", async () => {
const attempts: string[] = [];
const balanceManager = {
topUp: async (_options: { mintUrl: string }) => ({ success: false, message: "not patched" }),
createProviderToken: async (options: { mintUrl: string }) => {
attempts.push(options.mintUrl);
if (options.mintUrl === "https://mint-a.example") {
return { success: false, error: "Send failed: Not enough proofs to send" };
}
return { success: true, token: "cashu-token", selectedMintUrl: options.mintUrl, amountSpent: 21 };
},
};
const client = { getBalanceManager: () => balanceManager };
const walletAdapter = {
getBalances: async () => ({ "https://mint-b.example": 100 }),
};
installMintFallbackTopUp(
client,
createCocodClient(["https://mint-a.example", "https://mint-b.example"]),
walletAdapter,
{ log: () => undefined, warn: () => undefined, error: () => undefined },
);
const result = await balanceManager.createProviderToken({
mintUrl: "https://mint-a.example",
baseUrl: "https://provider.example",
amount: 21,
});
expect(result.success).toBe(true);
expect(attempts).toEqual(["https://mint-a.example", "https://mint-b.example"]);
});
test("forces the requested fallback mint by excluding previously failed mints", async () => {
const attempts: Array<{ preferred: string; selected: string; excluded: string[] }> = [];
const availableMints = ["https://mint-a.example", "https://mint-b.example"];
const balanceManager = {
topUp: async (_options: { mintUrl: string }) => ({ success: false, message: "not patched" }),
// Reproduce the SDK's selection order: its automatic balance choice wins
// over preferredMintUrl unless the earlier mint is explicitly excluded.
createProviderToken: async (options: { mintUrl: string; excludeMints?: string[] }) => {
const excluded = options.excludeMints ?? [];
const selected = availableMints.find((mint) => !excluded.includes(mint))!;
attempts.push({ preferred: options.mintUrl, selected, excluded });
if (selected === "https://mint-a.example") {
return { success: false, error: "Send failed: Not enough proofs to send" };
}
return { success: true, token: "cashu-token", selectedMintUrl: selected, amountSpent: 21 };
},
};
const client = { getBalanceManager: () => balanceManager };
const walletAdapter = {
getBalances: async () => ({
"https://mint-a.example": 2_916,
"https://mint-b.example": 30_000,
}),
};
installMintFallbackTopUp(
client,
createCocodClient(availableMints),
walletAdapter,
{ log: () => undefined, warn: () => undefined, error: () => undefined },
);
const result = await balanceManager.createProviderToken({
mintUrl: "https://mint-a.example",
baseUrl: "https://provider.example",
amount: 389,
});
expect(result.success).toBe(true);
expect(attempts).toEqual([
{
preferred: "https://mint-a.example",
selected: "https://mint-a.example",
excluded: ["https://mint-b.example"],
},
{
preferred: "https://mint-b.example",
selected: "https://mint-b.example",
excluded: ["https://mint-a.example"],
},
]);
});
test("createProviderToken falls back to routstr-core invoice + NWC payment", async () => {
const createAttempts: string[] = [];
const balanceManager = {
topUp: async (_options: { mintUrl: string }) => ({ success: false, message: "not patched" }),
createProviderToken: async (options: { mintUrl: string }) => {
createAttempts.push(options.mintUrl);
return { success: false, error: "Send failed: Not enough proofs to send" };
},
};
const client = { getBalanceManager: () => balanceManager };
let nwcPaid = false;
const walletAdapter = {
getBalances: async () => ({}),
getNwcStatus: async () => ({ connected: true }),
payBolt11: async (_bolt11: string) => {
nwcPaid = true;
return { success: true, preimage: "abc123" };
},
};
// Mock fetch for routstr-core invoice creation
const originalFetch = globalThis.fetch;
let invoiceCreated = false;
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const urlStr = String(url);
if (urlStr.includes("/lightning/invoice") && init?.method === "POST") {
invoiceCreated = true;
return new Response(JSON.stringify({
invoice_id: "test-invoice-id",
bolt11: "lnbc1testinvoice",
}), { status: 200, headers: { "Content-Type": "application/json" } });
}
if (urlStr.includes("/lightning/invoice/") && urlStr.includes("/status")) {
return new Response(JSON.stringify({ status: "paid" }), {
status: 200, headers: { "Content-Type": "application/json" },
});
}
return new Response("Not found", { status: 404 });
}) as typeof fetch;
try {
installMintFallbackTopUp(
client,
createCocodClient(["https://mint-a.example"]),
walletAdapter,
{ log: () => undefined, warn: () => undefined, error: () => undefined },
"https://provider.example",
);
const result = await balanceManager.createProviderToken({
mintUrl: "https://mint-a.example",
baseUrl: "https://provider.example",
amount: 21,
});
// Should have tried mint-a, then created a routstr-core invoice
// After NWC payment, it retries createProviderToken (mint-a again)
expect(createAttempts).toEqual(["https://mint-a.example", "https://mint-a.example"]);
expect(invoiceCreated).toBe(true);
// NWC should have been used to pay the routstr-core invoice
expect(nwcPaid).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
test("falls back to local NWC invoice when routstr-core returns HTTP error", async () => {
const createAttempts: string[] = [];
const balanceManager = {
topUp: async (_o: { mintUrl: string }) => ({ success: false, message: "not patched" }),
createProviderToken: async (options: { mintUrl: string }) => {
createAttempts.push(options.mintUrl);
return { success: false, error: "Send failed: Not enough proofs to send" };
},
};
const client = { getBalanceManager: () => balanceManager };
let nwcPaidBolt11: string | null = null;
const walletAdapter = {
getBalances: async () => ({}),
getNwcStatus: async () => ({ connected: true }),
payBolt11: async (bolt11: string) => {
nwcPaidBolt11 = bolt11;
return { success: true, preimage: "def456" };
},
};
// Mock fetch: routstr-core returns 500
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
const urlStr = String(url);
if (urlStr.includes("/lightning/invoice") && init?.method === "POST") {
return new Response("Internal server error", { status: 500 });
}
return new Response("Not found", { status: 404 });
}) as typeof fetch;
try {
installMintFallbackTopUp(
client,
createCocodClient(["https://mint-a.example"]),
walletAdapter,
{ log: () => undefined, warn: () => undefined, error: () => undefined },
"https://provider.example",
);
const result = await balanceManager.createProviderToken({
mintUrl: "https://mint-a.example",
baseUrl: "https://provider.example",
amount: 21,
});
// routstr-core failed → fell back to local NWC invoice
expect(nwcPaidBolt11).not.toBeNull();
// local wallet invoice is a static test string from createCocodClient
expect(nwcPaidBolt11).toBe("invoice");
} finally {
globalThis.fetch = originalFetch;
}
});
test("shows manual top-up when both routstr-core and NWC are unavailable", async () => {
const logs: string[] = [];
const logger = {
log: (...args: unknown[]) => logs.push(args.join(" ")),
warn: () => {},
error: () => {},
};
const balanceManager = {
topUp: async (_o: { mintUrl: string }) => ({ success: false, message: "not patched" }),
createProviderToken: async (_options: { mintUrl: string }) => ({
success: false, error: "Send failed: Not enough proofs to send",
}),
};
const client = { getBalanceManager: () => balanceManager };
const walletAdapter = {
getBalances: async () => ({}),
getNwcStatus: async () => ({ connected: false, error: "not configured" }),
};
// Mock fetch: routstr-core unreachable (network error)
const originalFetch = globalThis.fetch;
globalThis.fetch = (async () => {
throw new Error("Network error: connection refused");
}) as typeof fetch;
try {
installMintFallbackTopUp(
client,
createCocodClient(["https://mint-a.example"]),
walletAdapter,
logger,
"https://provider.example",
);
await balanceManager.createProviderToken({
mintUrl: "https://mint-a.example",
amount: 21,
});
// Should have logged the manual top-up message
const manualLog = logs.find((l) => l.includes("Manual top-up"));
expect(manualLog).toBeDefined();
} finally {
globalThis.fetch = originalFetch;
}
});
test("skips routstr-core when upstreamProviderUrl is undefined, uses NWC only", async () => {
const createAttempts: string[] = [];
const balanceManager = {
topUp: async (_o: { mintUrl: string }) => ({ success: false, message: "not patched" }),
createProviderToken: async (options: { mintUrl: string }) => {
createAttempts.push(options.mintUrl);
return { success: false, error: "Send failed: Not enough proofs to send" };
},
};
const client = { getBalanceManager: () => balanceManager };
let nwcCalled = false;
const walletAdapter = {
getBalances: async () => ({}),
getNwcStatus: async () => ({ connected: true }),
payBolt11: async (_bolt11: string) => {
nwcCalled = true;
return { success: true, preimage: "ghi789" };
},
};
// No upstreamProviderUrl passed → should not call fetch at all
installMintFallbackTopUp(
client,
createCocodClient(["https://mint-a.example"]),
walletAdapter,
{ log: () => undefined, warn: () => undefined, error: () => undefined },
);
await balanceManager.createProviderToken({
mintUrl: "https://mint-a.example",
amount: 21,
});
// NWC should have been used (routstr-core was skipped)
expect(nwcCalled).toBe(true);
});
});
describe("provider-side 402 top-up refusal", () => {
const PROVIDER_SIDE_402 = JSON.stringify({
error: {
message: "Insufficient credits. Add more using https://openrouter.ai/settings/credits",
code: 402,
metadata: { limit_source: "openrouter_credits" },
},
});
function createPatchedClient() {
const toppedUp: number[] = [];
const balanceManager = {
topUp: async (options: { amount: number }) => {
toppedUp.push(options.amount);
return { success: true, toppedUpAmount: options.amount };
},
};
// Minimal stand-in for the SDK's _handleErrorResponse signature:
// (params, token, status, requestId, xCashuRefundToken, responseBody, retryCount)
const client = {
getBalanceManager: () => balanceManager,
_handleErrorResponse: async (..._args: unknown[]) => "delegated",
};
installMintFallbackTopUp(
client,
createCocodClient(["https://mint-a.example"]),
{ getBalances: async () => ({ "https://mint-a.example": 50_000 }) },
{ log: () => undefined, warn: () => undefined, error: () => undefined },
);
return { client, balanceManager, toppedUp };
}
test("mints nothing once the provider reports its own credit exhaustion", async () => {
const { client, balanceManager, toppedUp } = createPatchedClient();
// The SDK hands the 402 body through _handleErrorResponse before topping up.
await client._handleErrorResponse(
{ baseUrl: "http://localhost:8011/" },
"sk-test",
402,
"req-1",
undefined,
PROVIDER_SIDE_402,
0,
);
const result = await balanceManager.topUp({
mintUrl: "https://mint-a.example",
baseUrl: "http://localhost:8011/",
amount: 32480,
token: "sk-test",
});
expect(result.success).toBe(false);
expect(toppedUp).toEqual([]); // no proofs spent at all
});
test("refusal message must not contain 'Insufficient balance'", async () => {
// This is the load-bearing contract with the SDK: on a failed top-up it
// throws InsufficientBalanceError when the message contains "Insufficient
// balance", and only otherwise sets tryNextProvider. Getting this wrong
// turns a clean failover into a hard request failure.
const { client, balanceManager } = createPatchedClient();
await client._handleErrorResponse(
{ baseUrl: "http://localhost:8011/" },
"sk-test",
402,
"req-1",
undefined,
PROVIDER_SIDE_402,
0,
);
const result = await balanceManager.topUp({
mintUrl: "https://mint-a.example",
baseUrl: "http://localhost:8011/",
amount: 32480,
token: "sk-test",
});
expect(String(result.message)).not.toContain("Insufficient balance");
});
test("a genuine balance 402 still funds normally", async () => {
const { client, balanceManager, toppedUp } = createPatchedClient();
await client._handleErrorResponse(
{ baseUrl: "https://routstr.otrta.me/" },
"sk-test",
402,
"req-1",
undefined,
JSON.stringify({
detail: { error: { code: "insufficient_balance", message: "Insufficient balance: 100 sats required" } },
}),
0,
);
const result = await balanceManager.topUp({
mintUrl: "https://mint-a.example",
baseUrl: "https://routstr.otrta.me/",
amount: 210,
token: "sk-test",
});
expect(result.success).toBe(true);
expect(toppedUp).toEqual([210]);
});
});