diff --git a/docs/nwc-timeouts.md b/docs/nwc-timeouts.md new file mode 100644 index 0000000..b4a59db --- /dev/null +++ b/docs/nwc-timeouts.md @@ -0,0 +1,13 @@ +# NWC request timeouts + +In `applesauce-wallet-connect@6.2.0`, encryption negotiation waits for a wallet-info event (kind 13194) before starting the response timeout. If a relay subscription stalls before that event arrives, the library request can remain pending indefinitely. + +The wallet adapter adds an overall deadline: 15 seconds per read attempt and 45 seconds per payment. Reads can rebuild the relay connection and retry once. Payments are never automatically retried. The library still applies its own 30-second response timeout after negotiation; the 45-second deadline does not extend it. + +Normal NIP-47 wallet errors do not rebuild the shared relay connection: a wallet error proves a response arrived, and rebuilding could interrupt unrelated payments. Transport failures and timeouts, including the library's own timeout, still trigger recovery. + +Every CLI daemon request has a deadline covering headers and response-body consumption: 120 seconds by default, and 600 seconds for value-moving wallet routes (`/wallet/send/*`, `/wallet/receive/*`), whose mint operations can legitimately run longer. Aborting the CLI request never cancels the daemon-side operation; the longer bound only delays how soon the CLI reports the stall. + +A payment timeout is an **unknown outcome**, not proof that no payment occurred. Promise deadlines do not cancel the underlying operation. Check the mint quote, wallet transactions, and Cashu balance before creating and paying another invoice. + +The auto-refill loop starts when static configuration or a dynamic configuration getter is supplied, even without an NWC connection at startup. It reads the current wallet and configuration each cycle, so a later connection can activate refills without restarting the daemon. diff --git a/src/daemon/wallet/auto-refill.ts b/src/daemon/wallet/auto-refill.ts index 5f3fbae..39e9ea5 100644 --- a/src/daemon/wallet/auto-refill.ts +++ b/src/daemon/wallet/auto-refill.ts @@ -37,6 +37,15 @@ export function startAutoRefillLoop( getWallet: () => WalletConnect | undefined, getConfig: () => AutoRefillConfig | undefined, intervalMs: number = 5000, + payInvoice: ( + invoice: string, + ) => Promise<{ preimage?: string; fees_paid?: number }> = (invoice) => { + const wallet = getWallet(); + if (!wallet?.service) { + return Promise.reject(new Error("NWC not connected")); + } + return wallet.payInvoice(invoice); + }, ): () => void { let lastRefillAt = 0; let lastAttemptAt = 0; // tracks last attempt (success or failure) for backoff @@ -113,7 +122,7 @@ export function startAutoRefillLoop( logger.log("[auto-refill] Wallet disconnected during refill check"); return; } - const payment = await currentWallet.payInvoice(invoice); + const payment = await payInvoice(invoice); // Step 3: The Cashu mint should automatically detect the paid invoice // and issue tokens. We don't need to explicitly mint here; cocod diff --git a/src/daemon/wallet/coco-client.ts b/src/daemon/wallet/coco-client.ts index bbd1f84..869bcb1 100644 --- a/src/daemon/wallet/coco-client.ts +++ b/src/daemon/wallet/coco-client.ts @@ -1,3 +1,4 @@ +import { withTimeout as withRequestTimeout } from "../../utils/with-timeout"; import { recoveryKey, trackRecovery, drainRecoveryWork, waitForRecoveryWork, createRecoveryDisposer, type RecoveryWork } from "./recovery-work"; import { Manager, @@ -733,16 +734,7 @@ const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000; /** Rejects when `timeoutMs` elapses before `promise` settles. */ function withTimeout(promise: Promise, timeoutMs: number): Promise { if (timeoutMs === Infinity) return promise; - let timer: ReturnType | undefined; - const timeout = new Promise((_resolve, reject) => { - timer = setTimeout( - () => reject(new Error("Timed out contacting mint")), - timeoutMs, - ); - }); - return Promise.race([promise, timeout]).finally(() => { - if (timer !== undefined) clearTimeout(timer); - }); + return withRequestTimeout(promise, timeoutMs, "Timed out contacting mint"); } /** Structural subset of coco's Manager used by expired-quote settlement. */ diff --git a/src/daemon/wallet/index.nwc.test.ts b/src/daemon/wallet/index.nwc.test.ts new file mode 100644 index 0000000..3bbf064 --- /dev/null +++ b/src/daemon/wallet/index.nwc.test.ts @@ -0,0 +1,240 @@ +import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test"; +import { RestrictedError } from "applesauce-wallet-connect/helpers/error"; +import type { WalletAdapterOptions } from "./index"; + +/** + * Regression tests for the NWC hang fixed in this change. + * + * `applesauce-wallet-connect` applies its request timeout only after it has + * negotiated encryption from the wallet's `kind:13194` info event. On a stale + * relay subscription that negotiation never completes, so `getInfo()` / + * `getBalance()` wait forever. The adapter must bound the wait, rebuild the + * relay connection, and retry once instead of hanging `routstrd nwc status`. + */ + +type Behavior = "resolve" | "hang" | "restricted" | "library-timeout" | "deferred"; + +const state = { + infoQueue: [] as Behavior[], + balanceQueue: [] as Behavior[], + payQueue: [] as Behavior[], + createdInstances: 0, + pendingPayments: [] as (() => void)[], + paymentStarted: undefined as (() => void) | undefined, + closedPools: 0, +}; + +function next(queue: Behavior[]): Behavior { + return queue.shift() ?? "resolve"; +} + +class MockRelayPool { + relays = new Map([["wss://relay.example", {}]]); + remove(url: string, _close?: boolean): void { + state.closedPools++; + this.relays.delete(url); + } +} + +class MockWalletConnect { + service = "ab".repeat(32); + relays = ["wss://relay.example"]; + + constructor() { + state.createdInstances += 1; + } + + static fromConnectURI(_uri: string): MockWalletConnect { + return new MockWalletConnect(); + } + + waitForService(): Promise { + return Promise.resolve(this.service); + } + + getInfo(): Promise<{ + alias: string; + pubkey: string; + network: string; + methods: string[]; + }> { + const behavior = next(state.infoQueue); + if (behavior === "hang") return new Promise(() => {}); + if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted")); + if (behavior === "library-timeout") return Promise.reject(new Error("Timeout")); + return Promise.resolve({ + alias: "Test Wallet", + pubkey: "cd".repeat(32), + network: "mainnet", + methods: ["get_balance", "get_info"], + }); + } + + getBalance(): Promise<{ balance: number }> { + const behavior = next(state.balanceQueue); + if (behavior === "hang") return new Promise(() => {}); + if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted")); + return Promise.resolve({ balance: 123_000 }); + } + + payInvoice( + _invoice: string, + ): Promise<{ preimage: string; fees_paid: number }> { + const behavior = next(state.payQueue); + if (behavior === "hang") return new Promise(() => {}); + if (behavior === "restricted") return Promise.reject(new RestrictedError("restricted")); + if (behavior === "library-timeout") return Promise.reject(new Error("Timeout")); + if (behavior === "deferred") { + return new Promise((resolve) => { + const closedAtStart = state.closedPools; + state.pendingPayments.push(() => { + // Closing the shared pool loses the original payment response. + if (state.closedPools === closedAtStart) { + resolve({ preimage: "00".repeat(32), fees_paid: 1000 }); + } + }); + state.paymentStarted?.(); + }); + } + return Promise.resolve({ preimage: "00".repeat(32), fees_paid: 1000 }); + } +} + +mock.module("applesauce-wallet-connect", () => ({ + WalletConnect: MockWalletConnect, +})); +mock.module("applesauce-relay", () => ({ RelayPool: MockRelayPool })); + +const { createWalletAdapter } = await import("./index"); + +/** + * Type of the injected wallet client, derived from the adapter options so this + * test keeps compiling when the legacy `CocodClient` is replaced (see #118). + */ +type WalletClientOption = NonNullable; + +function makeClient(): WalletClientOption { + return { + getBalances: async () => ({ "https://mint.example": 0 }), + getDefaultMint: async () => "https://mint.example", + receiveBolt11: async () => ({ invoice: "lnbc-test-invoice" }), + receiveCashu: async () => "ok", + } as unknown as WalletClientOption; +} + +function makeAdapter(timeoutMs = 25) { + return createWalletAdapter({ + walletClient: makeClient(), + nwcConnectionString: + "nostr+walletconnect://" + + "ab".repeat(32) + + "?relay=wss%3A%2F%2Frelay.example&secret=" + + "11".repeat(32), + nwcReadTimeoutMs: timeoutMs, + nwcPayTimeoutMs: timeoutMs, + }); +} + +beforeEach(() => { + state.infoQueue = []; + state.balanceQueue = []; + state.payQueue = []; + state.createdInstances = 0; + state.closedPools = 0; + state.pendingPayments = []; + state.paymentStarted = undefined; +}); + +afterAll(() => { + mock.restore(); +}); + +describe("NWC status resilience", () => { + it("rebuilds a stale relay connection and still reports status", async () => { + state.infoQueue = ["hang"]; // first attempt stalls, retry succeeds + + const adapter = await makeAdapter(); + const status = await adapter.getNwcStatus(); + + expect(status.connected).toBe(true); + expect(status.alias).toBe("Test Wallet"); + expect(status.balance).toBe(123); + // initial connection + one rebuild + expect(state.createdInstances).toBe(2); + }); + + it("returns a bounded error instead of hanging forever", async () => { + state.infoQueue = ["hang", "hang"]; // every attempt stalls + + const adapter = await makeAdapter(); + const startedAt = Date.now(); + const status = await adapter.getNwcStatus(); + + expect(status.connected).toBe(false); + expect(status.error).toContain("timed out"); + expect(Date.now() - startedAt).toBeLessThan(2_000); + }); + + it("bounds a hung invoice payment and surfaces the error", async () => { + state.payQueue = ["hang"]; + + const adapter = await makeAdapter(); + const startedAt = Date.now(); + const result = await adapter.fundFromNWC(2100); + + expect(result.success).toBe(false); + expect(result.error).toContain("timed out"); + expect(Date.now() - startedAt).toBeLessThan(2_000); + }); +}); + +describe("NWC wallet errors versus transport stalls", () => { + it("does not retry or rebuild on a wallet read error", async () => { + state.infoQueue = ["restricted"]; + const adapter = await makeAdapter(); + expect((await adapter.getNwcStatus()).error).toBe("restricted"); + expect(state.createdInstances).toBe(1); + expect(state.closedPools).toBe(0); + }); + + it("keeps an in-flight payment alive during a pay-only status check", async () => { + state.payQueue = ["deferred"]; + state.balanceQueue = ["restricted"]; + const adapter = await makeAdapter(500); + const started = new Promise((resolve) => { state.paymentStarted = resolve; }); + const payment = adapter.fundFromNWC(2100); + await started; + expect((await adapter.getNwcStatus()).connected).toBe(true); + expect(state.createdInstances).toBe(1); + state.pendingPayments[0]!(); + expect((await payment).success).toBe(true); + expect(state.closedPools).toBe(0); + }); + + it("keeps a slow payment alive when an overlapping payment gets a wallet error", async () => { + state.payQueue = ["deferred", "restricted"]; + const adapter = await makeAdapter(500); + const started = new Promise((resolve) => { state.paymentStarted = resolve; }); + const slow = adapter.fundFromNWC(2100); + await started; + expect((await adapter.fundFromNWC(2100)).error).toBe("restricted"); + state.pendingPayments[0]!(); + expect((await slow).success).toBe(true); + expect(state.createdInstances).toBe(1); + }); + + it("rebuilds and retries reads on the library's own timeout", async () => { + state.infoQueue = ["library-timeout"]; + const adapter = await makeAdapter(); + expect((await adapter.getNwcStatus()).connected).toBe(true); + expect(state.createdInstances).toBe(2); + }); + + it("rebuilds after the library's payment timeout without retrying payment", async () => { + state.payQueue = ["library-timeout", "restricted"]; + const adapter = await makeAdapter(); + expect((await adapter.fundFromNWC(2100)).error).toBe("Timeout"); + expect(state.createdInstances).toBe(2); + expect(state.payQueue).toEqual(["restricted"]); + }); +}); diff --git a/src/daemon/wallet/index.ts b/src/daemon/wallet/index.ts index a752c0a..4a7d2fe 100644 --- a/src/daemon/wallet/index.ts +++ b/src/daemon/wallet/index.ts @@ -1,11 +1,24 @@ import { getTokenMetadata } from "@cashu/cashu-ts"; import { InsufficientBalanceError } from "@routstr/sdk"; import { WalletConnect } from "applesauce-wallet-connect"; +import { WalletBaseError } from "applesauce-wallet-connect/helpers/error"; import { RelayPool } from "applesauce-relay"; import { logger } from "../../utils/logger"; +import { withTimeout } from "../../utils/with-timeout"; import { createCocodClient, type CocodClient } from "./cocod-client"; import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill"; +/** + * NWC reads (get_info/get_balance) should answer in a couple of seconds. If + * they don't, the long-lived relay subscription is presumed stale and the + * connection is rebuilt before one retry. + */ +const NWC_READ_TIMEOUT_MS = 15_000; +/** Overall bound including encryption negotiation; replies have a library 30s timeout. */ +const NWC_PAY_TIMEOUT_MS = 45_000; + +type NwcPayment = { preimage?: string; fees_paid?: number }; + export function decodeCashuTokenAmount(token: string): { amount: number; unit: "sat" | "msat"; @@ -37,6 +50,10 @@ export interface WalletAdapterOptions { walletClient?: CocodClient; /** NWC connection string for Lightning funding (uses applesauce-wallet-connect) */ nwcConnectionString?: string; + /** Override the NWC read timeout in milliseconds (test hook). */ + nwcReadTimeoutMs?: number; + /** Override the NWC payment timeout in milliseconds (test hook). */ + nwcPayTimeoutMs?: number; /** Auto-refill configuration (static, for startup only) */ autoRefill?: AutoRefillConfig; /** @@ -82,19 +99,44 @@ export async function createWalletAdapter( let wallet: WalletConnect | undefined; let pool: RelayPool | undefined; + let nwcConnectionString = options.nwcConnectionString; + const nwcReadTimeoutMs = options.nwcReadTimeoutMs ?? NWC_READ_TIMEOUT_MS; + const nwcPayTimeoutMs = options.nwcPayTimeoutMs ?? NWC_PAY_TIMEOUT_MS; // Getter for the current wallet instance (used by auto-refill loop) const getWallet = (): WalletConnect | undefined => wallet; - if (options.nwcConnectionString) { - pool = new RelayPool(); - wallet = WalletConnect.fromConnectURI(options.nwcConnectionString, { pool }); + /** Close the active relay pool, if any. */ + function closeNwcPool(): void { + if (pool) { + for (const [url] of pool.relays) { + pool.remove(url, true); + } + } + pool = undefined; + } + + /** + * (Re)create the relay pool + WalletConnect client for a connection string. + * Shared by interactive connects and self-healing recovery so both paths use + * identical setup. + */ + function connectNwc(connectionString: string, reason: string): void { + const nextPool = new RelayPool(); + const nextWallet = WalletConnect.fromConnectURI(connectionString, { + pool: nextPool, + }); + + pool = nextPool; + wallet = nextWallet; + nwcConnectionString = connectionString; // Connect in background (non-blocking) - wallet.waitForService() + nextWallet + .waitForService() .then(() => { logger.log( - `[nwc] NWC wallet connected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`, + `[nwc] NWC wallet ${reason}. Relay: ${nextWallet.relays[0]}, Service: ${nextWallet.service}`, ); }) .catch((err) => { @@ -102,39 +144,97 @@ export async function createWalletAdapter( }); } + /** + * Rebuild the NWC connection in place after a request stalled. applesauce's + * request timeout only covers the response stream, so a stale relay + * subscription can leave `getInfo`/`getBalance` pending forever while it + * negotiates encryption. Recreating the pool gives the next call a fresh + * subscription. + */ + function rebuildNwcConnection(reason: string): void { + if (!nwcConnectionString) return; + closeNwcPool(); + wallet = undefined; + connectNwc(nwcConnectionString, reason); + } + + /** + * Run an idempotent NWC read, bounding the wait and rebuilding the connection + * once if it stalls. + */ + async function nwcRead( + label: string, + operation: (w: WalletConnect) => Promise, + ): Promise { + const first = wallet; + if (!first?.service) { + throw new Error("NWC not connected"); + } + try { + return await withTimeout( + operation(first), + nwcReadTimeoutMs, + `${label} timed out`, + ); + } catch (error) { + // A normal NIP-47 error proves the wallet answered. Keep other calls alive. + if (error instanceof WalletBaseError || !nwcConnectionString) throw error; + logger.warn( + `[nwc] ${label} failed (${(error as Error).message}); rebuilding NWC connection and retrying`, + ); + rebuildNwcConnection("reconnected after stall"); + const retry = wallet; + if (!retry?.service) throw error; + return await withTimeout( + operation(retry), + nwcReadTimeoutMs, + `${label} timed out after reconnect`, + ); + } + } + + /** + * Pay a BOLT-11 invoice over NWC with a bounded wait. A timeout rebuilds the + * relay connection so later calls recover without a daemon restart. The + * payment is not retried here. A timeout is an unknown payment outcome, + * not proof of failure; callers must reconcile before trying a fresh invoice. + */ + async function payNwcInvoice(invoice: string): Promise { + const payer = wallet; + if (!payer?.service) throw new Error("NWC not connected"); + try { + return await withTimeout( + payer.payInvoice(invoice), + nwcPayTimeoutMs, + "NWC payment timed out", + ); + } catch (error) { + // Include the library's own timeout, but not normal wallet error replies. + if (!(error instanceof WalletBaseError)) { + rebuildNwcConnection("reconnected after payment stall"); + } + throw error; + } + } + + if (options.nwcConnectionString) { + connectNwc(options.nwcConnectionString, "connected"); + } + const walletAdapter = { async reconnect(connectionString?: string): Promise { logger.log( `[nwc] Reconnecting NWC wallet... ${connectionString ? "new connection string provided" : "disconnecting"}`, ); - // 1. Close existing relay pool connections - if (pool) { - for (const [url] of pool.relays) { - pool.remove(url, true); - } - } - - // 2. Update wallet reference + // Close existing relay pool connections and update the wallet reference + closeNwcPool(); wallet = undefined; - pool = undefined; - // 3. Create new wallet if connection string provided if (connectionString) { - pool = new RelayPool(); - wallet = WalletConnect.fromConnectURI(connectionString, { pool }); - - // Connect in background (non-blocking) - wallet.waitForService() - .then(() => { - logger.log( - `[nwc] NWC wallet reconnected. Relay: ${wallet!.relays[0]}, Service: ${wallet!.service}`, - ); - }) - .catch((err) => { - logger.error(`[nwc] NWC reconnection failed: ${err.message}`); - }); + connectNwc(connectionString, "reconnected"); } else { + nwcConnectionString = undefined; logger.log("[nwc] NWC wallet disconnected."); } }, @@ -192,9 +292,9 @@ export async function createWalletAdapter( const { invoice } = await client.receiveBolt11(amount, mintUrl); logger.log(`[nwc] Invoice: ${invoice}`); - // Step 3: Pay it via NWC + // Step 3: Pay it via NWC (bounded — a stale relay must not hang the CLI) logger.log("[nwc] Paying invoice via NWC..."); - const { preimage, fees_paid } = await wallet.payInvoice(invoice); + const { preimage, fees_paid } = await payNwcInvoice(invoice); logger.log(`[nwc] ✅ Payment successful!`); logger.log(`[nwc] Preimage: ${preimage}`); if (fees_paid !== undefined) { @@ -244,10 +344,10 @@ export async function createWalletAdapter( } try { - const info = await wallet.getInfo(); + const info = await nwcRead("get_info", (w) => w.getInfo()); let balance: number | undefined; try { - const bal = await wallet.getBalance(); + const bal = await nwcRead("get_balance", (w) => w.getBalance()); balance = Math.floor(bal.balance / 1000); // msats → sats } catch { // Balance might not be available @@ -326,21 +426,40 @@ export async function createWalletAdapter( let stopAutoRefill: (() => void) | undefined; + /** + * Start the auto-refill loop if it is not already running. Safe to call more + * than once; it becomes a no-op after the first call. + */ + function ensureAutoRefillLoop(): void { + if (stopAutoRefill) return; + const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill); + stopAutoRefill = startAutoRefillLoop( + client, + getWallet, + getConfig, + 5000, + payNwcInvoice, + ); + } + const autoRefillConfig = options.getAutoRefillConfig ? options.getAutoRefillConfig() : options.autoRefill; - if (autoRefillConfig && wallet) { - const getConfig = options.getAutoRefillConfig ?? (() => options.autoRefill); - stopAutoRefill = startAutoRefillLoop(client, getWallet, getConfig); - logger.log( - `[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`, - ); - } else if (wallet && options.getAutoRefillConfig) { - // Wallet exists but auto-refill is not currently enabled. - // Start the loop anyway so it can pick up changes without a restart. - stopAutoRefill = startAutoRefillLoop(client, getWallet, options.getAutoRefillConfig); - logger.log("[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)"); + if (options.getAutoRefillConfig || options.autoRefill) { + // Start the loop even when no wallet is connected yet: it reads the wallet + // and config fresh each cycle, so a later `nwc connect` activates refills + // without a daemon restart. + ensureAutoRefillLoop(); + if (autoRefillConfig) { + logger.log( + `[wallet] Auto-refill enabled: threshold=${autoRefillConfig.threshold} sats, amount=${autoRefillConfig.amount} sats, cooldown=${autoRefillConfig.cooldownMs / 60000} minutes`, + ); + } else { + logger.log( + "[wallet] Auto-refill loop started (currently disabled — enable via CLI to activate)", + ); + } } try { diff --git a/src/utils/daemon-client.timeout.test.ts b/src/utils/daemon-client.timeout.test.ts new file mode 100644 index 0000000..43f829c --- /dev/null +++ b/src/utils/daemon-client.timeout.test.ts @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + callDaemonUrl, + DAEMON_LONG_REQUEST_TIMEOUT_MS, + DAEMON_REQUEST_TIMEOUT_MS, +} from "./daemon-client"; +import { DEFAULT_CONFIG } from "./config"; + +const originalFetch = globalThis.fetch; +const originalAbortTimeout = AbortSignal.timeout; + +/** Deadline (ms) passed to AbortSignal.timeout by the last request. */ +let requestedDeadlines: number[] = []; + +afterEach(() => { + globalThis.fetch = originalFetch; + AbortSignal.timeout = originalAbortTimeout; + requestedDeadlines = []; +}); + +/** + * Record the requested deadline and arm a fast one instead, so tests do not + * wait out the real 120s/600s bounds. + */ +function shortenDeadline(): void { + AbortSignal.timeout = ((ms: number) => { + requestedDeadlines.push(ms); + return originalAbortTimeout(20); + }) as typeof AbortSignal.timeout; +} + +function stalledResponse(status = 200): void { + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + const signal = init?.signal; + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"output":')); + signal?.addEventListener("abort", () => controller.error(signal.reason), { once: true }); + }, + }), { status }); + }) as unknown as typeof fetch; +} + +const request = (path = "/nwc/status") => + callDaemonUrl("http://daemon.example", path, {}, DEFAULT_CONFIG); + +describe("daemon request deadline", () => { + test("bounds the wait for response headers", async () => { + shortenDeadline(); + globalThis.fetch = ((_input: string | URL | Request, init?: RequestInit) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal!.reason), { once: true }); + })) as unknown as typeof fetch; + await expect(request()).rejects.toThrow("Daemon request timed out"); + }); + + for (const status of [200, 500]) { + test(`bounds a stalled ${status} response body`, async () => { + shortenDeadline(); + stalledResponse(status); + await expect(request()).rejects.toThrow("payment outcome is unknown"); + }); + } + + test("applies the default deadline to ordinary routes", async () => { + shortenDeadline(); + globalThis.fetch = (async () => Response.json({ output: "ok" })) as unknown as typeof fetch; + expect(await request("/nwc/status")).toEqual({ output: "ok" }); + expect(requestedDeadlines).toEqual([DAEMON_REQUEST_TIMEOUT_MS]); + }); + + for (const path of ["/wallet/send/bolt11", "/wallet/receive/cashu"]) { + test(`uses the long deadline for value-moving route ${path}`, async () => { + shortenDeadline(); + globalThis.fetch = (async () => Response.json({ output: "paid" })) as unknown as typeof fetch; + expect(await request(path)).toEqual({ output: "paid" }); + expect(requestedDeadlines).toEqual([DAEMON_LONG_REQUEST_TIMEOUT_MS]); + }); + } + + test("bounds a stalled body on a long-running route too", async () => { + shortenDeadline(); + stalledResponse(); + await expect(request("/wallet/send/bolt11")).rejects.toThrow("payment outcome is unknown"); + expect(requestedDeadlines).toEqual([DAEMON_LONG_REQUEST_TIMEOUT_MS]); + }); + + test("preserves HTTP errors rather than labeling them connection failures", async () => { + globalThis.fetch = (async () => Response.json({ error: "restricted" }, { status: 403 })) as unknown as typeof fetch; + await expect(request()).rejects.toThrow("restricted"); + }); +}); diff --git a/src/utils/daemon-client.ts b/src/utils/daemon-client.ts index 7ae13ef..fe763f0 100644 --- a/src/utils/daemon-client.ts +++ b/src/utils/daemon-client.ts @@ -56,6 +56,32 @@ class DaemonConnectionError extends Error { } } +/** + * Upper bound for a single daemon request, including response-body + * consumption. The daemon bounds its own NWC operations, so this only guards + * against a wedged server; without it a hung request would block the CLI + * forever. + */ +export const DAEMON_REQUEST_TIMEOUT_MS = 120_000; + +/** + * Upper bound for value-moving wallet routes. A cashu melt/swap can + * legitimately run longer than {@link DAEMON_REQUEST_TIMEOUT_MS} (the mint has + * no request timeout in routstrd), so these get a more generous bound that + * still prevents an indefinite CLI hang. + */ +export const DAEMON_LONG_REQUEST_TIMEOUT_MS = 600_000; + +/** Routes that may legitimately outlive the default request timeout. */ +const LONG_RUNNING_ROUTES = ["/wallet/send/", "/wallet/receive/"]; + +function requestTimeoutMs(path: string): number { + const pathname = path.split("?")[0] ?? path; + return LONG_RUNNING_ROUTES.some((route) => pathname.startsWith(route)) + ? DAEMON_LONG_REQUEST_TIMEOUT_MS + : DAEMON_REQUEST_TIMEOUT_MS; +} + export function getDaemonBaseUrl(config: RoutstrdConfig): string { if (config.daemonUrl) { return config.daemonUrl.replace(/\/$/, ""); @@ -70,7 +96,7 @@ export function getAuthBaseUrl(config: RoutstrdConfig): string { return getDaemonBaseUrl(config); } -async function _callUrl( +export async function callDaemonUrl( baseUrl: string, path: string, options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object }, @@ -99,23 +125,41 @@ async function _callUrl( if (authorization) headers.set("Authorization", authorization); if (bodyString) headers.set("Content-Type", "application/json"); + const timeoutMs = requestTimeoutMs(path); + const timeoutError = () => + new Error( + `Daemon request timed out after ${timeoutMs / 1000}s; ` + + "any payment outcome is unknown — check before retrying", + ); + // The signal stays armed while the body is read, so a daemon that sends + // headers and then stalls the body cannot hang the CLI either. Aborting + // here never cancels the daemon's operation — see timeoutError's warning. + const signal = AbortSignal.timeout(timeoutMs); + let response: Response; try { response = await fetch(url, { method, headers, body: bodyString, + signal, }); } catch (error) { + if (signal.aborted) throw timeoutError(); + // Only connection failures qualify for alternate-host retries. throw new DaemonConnectionError(error); } - if (!response.ok) { - const errorData = (await response.json()) as { error?: string }; - throw new Error(errorData.error || `HTTP ${response.status}`); + try { + if (!response.ok) { + const errorData = (await response.json()) as { error?: string }; + throw new Error(errorData.error || `HTTP ${response.status}`); + } + return (await response.json()) as CommandResponse; + } catch (error) { + if (signal.aborted) throw timeoutError(); + throw error; } - - return response.json() as Promise; } async function callLocalDaemon( @@ -127,7 +171,7 @@ async function callLocalDaemon( let connectionError: DaemonConnectionError | undefined; for (const baseUrl of localDaemonBaseUrls(config)) { try { - return await _callUrl(baseUrl, path, options, config); + return await callDaemonUrl(baseUrl, path, options, config); } catch (error) { if (!(error instanceof DaemonConnectionError)) throw error; connectionError = error; @@ -142,7 +186,7 @@ export async function callDaemon( ): Promise { const config = await loadConfig(); if (config.daemonUrl) { - return _callUrl(getDaemonBaseUrl(config), path, options, config); + return callDaemonUrl(getDaemonBaseUrl(config), path, options, config); } return callLocalDaemon(path, options, config); } @@ -157,7 +201,7 @@ export async function callAuth( if (!config.authUrl && !config.daemonUrl) { return callLocalDaemon(path, options, config); } - return _callUrl(getAuthBaseUrl(config), path, options, config); + return callDaemonUrl(getAuthBaseUrl(config), path, options, config); } export async function isDaemonRunning(): Promise { diff --git a/src/utils/with-timeout.test.ts b/src/utils/with-timeout.test.ts new file mode 100644 index 0000000..88ab40d --- /dev/null +++ b/src/utils/with-timeout.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { withTimeout } from "./with-timeout"; + +describe("withTimeout", () => { + test("passes through a settled value", async () => { + await expect(withTimeout(Promise.resolve(42), 1000, "nope")).resolves.toBe( + 42, + ); + }); + + test("rejects with the provided message once the deadline elapses", async () => { + const never = new Promise(() => {}); + await expect(withTimeout(never, 20, "operation timed out")).rejects.toThrow( + "operation timed out", + ); + }); + + test("propagates the original rejection", async () => { + await expect( + withTimeout(Promise.reject(new Error("boom")), 1000, "unused"), + ).rejects.toThrow("boom"); + }); +}); diff --git a/src/utils/with-timeout.ts b/src/utils/with-timeout.ts new file mode 100644 index 0000000..8a0f143 --- /dev/null +++ b/src/utils/with-timeout.ts @@ -0,0 +1,21 @@ +/** + * Rejects when `timeoutMs` elapses before `promise` settles. + * This bounds the caller's wait; it does not cancel the underlying operation. + * + * Used to bound requests that may otherwise wait forever — notably NWC calls + * whose underlying library applies its own timeout only after a support/encryption + * handshake that can itself hang on a stale relay subscription. + */ +export function withTimeout( + promise: Promise, + timeoutMs: number, + message = "Operation timed out", +): Promise { + let timer: ReturnType | undefined; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => { + if (timer !== undefined) clearTimeout(timer); + }); +}