mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
Merge pull request #117 from Routstr/fix/nwc-status-hang
fix(nwc): bound NWC requests so nwc status cannot hang forever
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
if (timeoutMs === Infinity) return promise;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_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. */
|
||||
|
||||
@@ -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<string, unknown>([["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<string> {
|
||||
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<never>(() => {});
|
||||
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<never>(() => {});
|
||||
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<never>(() => {});
|
||||
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<WalletAdapterOptions["walletClient"]>;
|
||||
|
||||
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<void>((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<void>((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"]);
|
||||
});
|
||||
});
|
||||
+162
-43
@@ -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<T>(
|
||||
label: string,
|
||||
operation: (w: WalletConnect) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<NwcPayment> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<CommandResponse>;
|
||||
}
|
||||
|
||||
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<CommandResponse> {
|
||||
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<boolean> {
|
||||
|
||||
@@ -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<never>(() => {});
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message = "Operation timed out",
|
||||
): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
});
|
||||
return Promise.race([promise, timeout]).finally(() => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user