mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-06 04:38:23 +00:00
589 lines
20 KiB
TypeScript
589 lines
20 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("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]);
|
|
});
|
|
});
|