From e848f2d521e1c5c3c14e070af136849a63447361 Mon Sep 17 00:00:00 2001 From: redshift <213178690+1ftredsh@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:54:21 +0800 Subject: [PATCH] fix(wallet): give post-finalize diagnostic read its own budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagnostic ops.mint.get that fetches coco's persisted mint error was bound to remaining() — often ~1ms after a slow mint consumed the per-op budget, so the actionable error silently fell back to the generic message. It is a local DB read, not a mint round-trip, so it now gets its own 250ms bound (still hang-safe). Adds a regression test for the nearly-spent-budget case. --- src/daemon/wallet/coco-client.test.ts | 26 ++++++++++++++++++++++++++ src/daemon/wallet/coco-client.ts | 13 +++++++++++-- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/daemon/wallet/coco-client.test.ts b/src/daemon/wallet/coco-client.test.ts index 3711300..e18e7c2 100644 --- a/src/daemon/wallet/coco-client.test.ts +++ b/src/daemon/wallet/coco-client.test.ts @@ -1355,6 +1355,32 @@ describe("runMintQuoteRecovery", () => { expect(result.errors).toEqual([{ operationId: "op-1", error: "original failure" }]); }); + it("surfaces the persisted mint error even when the mint budget is nearly spent", async () => { + const { source } = fakeSource([mintOp()], { + observe: async () => ({ category: "ready" }), + finalize: async () => { + // Consume almost the whole per-op mint budget before throwing: exactly + // the slow-mint case where a diagnostic bound to remaining() would + // starve and silently fall back to the generic message. + await new Promise((resolve) => setTimeout(resolve, 25)); + throw new Error("remains pending"); + }, + }); + source.ops.mint.get = async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + return { + ...mintOp(), + state: "pending", + error: "keyset id inactive.", + }; + }; + const result = await runMintQuoteRecovery(source, { timeoutMs: 30 }); + expect(result).toMatchObject({ retryable: 1, recovered: 0 }); + expect(result.errors).toEqual([ + { operationId: "op-1", error: "keyset id inactive." }, + ]); + }); + it("bounds finalize so one hung mint cannot block recovery", async () => { const { source } = fakeSource([mintOp()], { observe: async () => ({ category: "ready" }), diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index 900088d..970516e 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -1002,6 +1002,14 @@ export function createRunQueue(): (run: () => Promise) => Promise { /** Per-quote budget for the mint round-trip during explicit recovery. */ const MINT_QUOTE_RECOVERY_TIMEOUT_MS = 20_000; +/** + * Bound for the local post-finalize diagnostic read. This is a database + * lookup, not a mint round-trip, so it gets its own small budget: the per-op + * mint budget is often already spent when finalize throws, and a starved + * diagnostic would silently fall back to the generic error message. + */ +const DIAGNOSTIC_LOOKUP_TIMEOUT_MS = 250; + /** * Recover mint quotes whose sats are PAID at the mint but were never claimed. * @@ -1142,7 +1150,8 @@ export async function runMintQuoteRecovery( } result.checked++; // One budget per operation, shared by the mint check and the finalize, so - // a slow mint cannot silently double the documented per-quote wait. + // a slow mint cannot silently double the documented per-quote wait. The + // local post-finalize diagnostic read is exempt (DIAGNOSTIC_LOOKUP_TIMEOUT_MS). const deadlineAt = Date.now() + timeoutMs; const remaining = () => Math.max(1, deadlineAt - Date.now()); @@ -1225,7 +1234,7 @@ export async function runMintQuoteRecovery( // persisted the actionable mint rejection (for example inactive keyset). const current = await withTimeout( source.ops.mint.get(operationId), - remaining(), + Math.max(remaining(), DIAGNOSTIC_LOOKUP_TIMEOUT_MS), ).catch(() => null); const detail = current?.state === "pending" && current.error ? current.error