From 412ddc8774959755fc72bc85301da73cb5cfbe7a Mon Sep 17 00:00:00 2001 From: Paperclip Deployment Engineer Date: Fri, 7 Aug 2026 21:11:17 +0000 Subject: [PATCH] fix: force fallback loop to use selected mint by excluding failed mints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/daemon/wallet/sdk-mint-fallback.ts | 20 ++++++++-- tests/sdk-mint-fallback.test.ts | 53 ++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/daemon/wallet/sdk-mint-fallback.ts b/src/daemon/wallet/sdk-mint-fallback.ts index 7895254..dc96fd2 100644 --- a/src/daemon/wallet/sdk-mint-fallback.ts +++ b/src/daemon/wallet/sdk-mint-fallback.ts @@ -13,6 +13,7 @@ type TopUpOptions = { baseUrl: string; amount: number; token?: string; + excludeMints?: string[]; }; type TopUpResult = { @@ -298,11 +299,12 @@ export function installCreateProviderTokenFallback( const originalCreateProviderToken = balanceManager.createProviderToken.bind(balanceManager); balanceManager.createProviderToken = async (options: TopUpOptions): Promise => { - const candidates = await getTopUpMintCandidates( + const inheritedExclusions = new Set(options.excludeMints ?? []); + const candidates = (await getTopUpMintCandidates( options.mintUrl, walletClient, walletAdapter, - ); + )).filter((mintUrl) => !inheritedExclusions.has(mintUrl)); let lastResult: TopUpResult | undefined; for (const [index, mintUrl] of candidates.entries()) { @@ -312,7 +314,19 @@ export function installCreateProviderTokenFallback( ); } - const result = await originalCreateProviderToken({ ...options, mintUrl }); + // The SDK treats mintUrl as a preference, not a requirement: its own + // balance-based candidate can still win and make every outer retry hit + // the same failed mint. Excluding every other known candidate makes this + // attempt use the mint selected by this fallback loop. + const excludeMints = uniqueMintUrls([ + ...inheritedExclusions, + ...candidates.filter((candidate) => candidate !== mintUrl), + ]); + const result = await originalCreateProviderToken({ + ...options, + mintUrl, + excludeMints, + }); if (result.success) { return result; } diff --git a/tests/sdk-mint-fallback.test.ts b/tests/sdk-mint-fallback.test.ts index 585a8d2..eabb80d 100644 --- a/tests/sdk-mint-fallback.test.ts +++ b/tests/sdk-mint-fallback.test.ts @@ -269,6 +269,59 @@ describe("SDK top-up mint fallback", () => { 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 = {