mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 20:38:22 +00:00
fix(nwc): preserve wallet error responses and bound CLI response bodies
This commit is contained in:
-238
@@ -1,238 +0,0 @@
|
|||||||
# NWC hang: `routstrd nwc status` never returns
|
|
||||||
|
|
||||||
Investigation, root cause, fix, build, deployment, and verification.
|
|
||||||
|
|
||||||
- **Date:** 2026-09-29
|
|
||||||
- **Affected command:** `routstrd nwc status` (also `nwc fund` and the auto-refill loop)
|
|
||||||
- **Base version:** `routstrd` v0.4.11
|
|
||||||
- **Fix branch / commit:** `fix/nwc-status-hang` — `ceb6d72`
|
|
||||||
- **Library involved:** `applesauce-wallet-connect@6.2.0` (latest at the time)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Symptom
|
|
||||||
|
|
||||||
`routstrd nwc status` hung forever. The local daemon was alive and healthy;
|
|
||||||
only the NWC endpoint blocked.
|
|
||||||
|
|
||||||
```
|
|
||||||
$ curl -m 6 http://127.0.0.1:8008/health # 3 ms
|
|
||||||
$ curl -m 85 http://127.0.0.1:8008/nwc/status # still pending after 85 s
|
|
||||||
```
|
|
||||||
|
|
||||||
The daemon logs showed the same wedge for manual funding attempts, which never
|
|
||||||
printed a result:
|
|
||||||
|
|
||||||
```
|
|
||||||
14:14:19 [nwc] Paying invoice via NWC... <- never completed
|
|
||||||
14:16:03 [nwc] Paying invoice via NWC... <- never completed
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Call chain
|
|
||||||
|
|
||||||
```
|
|
||||||
cli.ts:2198 nwc status action
|
|
||||||
-> handleDaemonCommand("/nwc/status") utils/daemon-client.ts
|
|
||||||
-> ensureDaemonRunning() (health check, OK)
|
|
||||||
-> fetch(...) (no timeout originally)
|
|
||||||
-> GET /nwc/status daemon/http/index.ts:660
|
|
||||||
-> deps.walletAdapter.getNwcStatus() wallet/index.ts
|
|
||||||
-> await wallet.getInfo() wallet/index.ts
|
|
||||||
-> await wallet.getBalance() wallet/index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
`wallet` is a `WalletConnect` instance from `applesauce-wallet-connect`.
|
|
||||||
|
|
||||||
## 3. Root cause
|
|
||||||
|
|
||||||
`applesauce-wallet-connect` applies its per-request timeout **only after** it has
|
|
||||||
negotiated an encryption scheme from the wallet's `kind:13194` info event:
|
|
||||||
|
|
||||||
```js
|
|
||||||
genericCall(method, params, options = {}) {
|
|
||||||
return defer(async () => {
|
|
||||||
const encryption = await firstValueFrom(this.encryption$); // can block forever
|
|
||||||
return await WalletRequestFactory.create(...).sign();
|
|
||||||
}).pipe(
|
|
||||||
switchMap((requestEvent) => {
|
|
||||||
const responses$ = this.events$.pipe(
|
|
||||||
...,
|
|
||||||
simpleTimeout(options.timeout || this.defaultTimeout), // 30 s default
|
|
||||||
);
|
|
||||||
return merge(request$, responses$);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If `encryption$` never emits, the `switchMap` (and therefore the timeout) is
|
|
||||||
never even created. `encryption$` derives from `support$`, which only emits when
|
|
||||||
the wallet service publishes a `kind:13194` wallet-info event. On a stale or
|
|
||||||
half-open relay subscription that event never arrives, so `getInfo()` /
|
|
||||||
`getBalance()` wait forever.
|
|
||||||
|
|
||||||
The `"nip04"` fallback in `encryption$` is effectively unreachable (it maps over
|
|
||||||
`support$`, which is filtered to wallet-info events only). NWC only needs the
|
|
||||||
info event to *choose* encryption — it could default to nip04 and proceed, since
|
|
||||||
the service pubkey is already known from the connection URI.
|
|
||||||
|
|
||||||
### Proof
|
|
||||||
|
|
||||||
A standalone probe against an unreachable relay still had `getInfo()` pending
|
|
||||||
after 45 s, despite the library's advertised 30 s internal timeout:
|
|
||||||
|
|
||||||
```
|
|
||||||
service set from URI: true
|
|
||||||
internal defaultTimeout (s): 30
|
|
||||||
RESULT: getInfo() STILL PENDING after 45.004s — internal timeout never fires
|
|
||||||
```
|
|
||||||
|
|
||||||
A fresh `WalletConnect` to the real relay returned `getInfo` in ~2 s, so the
|
|
||||||
wallet/relay themselves were fine — the daemon's long-lived relay subscription
|
|
||||||
had gone stale (the daemon still held a TCP socket to the relay, `67.205.128.242:443`).
|
|
||||||
|
|
||||||
## 4. Contributing bugs in routstrd
|
|
||||||
|
|
||||||
Even with the library defect, routstrd should not have been able to hang forever:
|
|
||||||
|
|
||||||
1. **No timeout in the daemon's NWC calls.** `getNwcStatus`, `fundFromNWC`
|
|
||||||
(`wallet.payInvoice`), and the auto-refill loop had no bound.
|
|
||||||
2. **No timeout on the HTTP route.** `GET /nwc/status` just `await`s the payload;
|
|
||||||
so did every other route.
|
|
||||||
3. **No timeout on the CLI `fetch`.** `_callUrl()` in `utils/daemon-client.ts`
|
|
||||||
used `fetch` with no `AbortController`, so a hung daemon blocked the CLI.
|
|
||||||
4. **Auto-refill never started after a hot connect.** The loop was only started
|
|
||||||
during `createWalletAdapter(...)` when a connection string already existed at
|
|
||||||
startup. `nwc connect` hot-reloads via `walletAdapter.reconnect()`, which did
|
|
||||||
not start the loop — hence no `auto-refill` log lines at all.
|
|
||||||
|
|
||||||
## 5. The fix
|
|
||||||
|
|
||||||
Branch `fix/nwc-status-hang` (based on tag `v0.4.11`), commit `ceb6d72`.
|
|
||||||
|
|
||||||
- **`src/utils/with-timeout.ts`** (new)
|
|
||||||
`withTimeout(promise, ms, message)` — `Promise.race` with a clearing timer.
|
|
||||||
|
|
||||||
- **`src/daemon/wallet/index.ts`**
|
|
||||||
- NWC reads (`get_info` / `get_balance`) are bounded (default 15 s). On timeout,
|
|
||||||
the relay pool is closed, a fresh `WalletConnect`/`RelayPool` is created from
|
|
||||||
the stored connection string, and the read is retried once.
|
|
||||||
- Invoice payments are bounded (default 45 s). A timeout rebuilds the
|
|
||||||
connection so later calls recover without a daemon restart; the payment is
|
|
||||||
not auto-retried (the invoice is single-use — the caller decides).
|
|
||||||
- `ensureAutoRefillLoop()` starts the loop whenever auto-refill is configured,
|
|
||||||
even if no wallet is connected yet. `reconnect()` calls it, so a hot
|
|
||||||
`nwc connect` now activates refills without a restart.
|
|
||||||
- Timeouts are injectable (`nwcReadTimeoutMs`, `nwcPayTimeoutMs`) for tests.
|
|
||||||
|
|
||||||
- **`src/daemon/wallet/auto-refill.ts`**
|
|
||||||
Takes the bounded `payInvoice` function instead of calling
|
|
||||||
`wallet.payInvoice` directly.
|
|
||||||
|
|
||||||
- **`src/utils/daemon-client.ts`**
|
|
||||||
120 s `AbortController` timeout on CLI daemon requests, with a distinct
|
|
||||||
"Daemon request timed out" error instead of a generic connection failure.
|
|
||||||
|
|
||||||
- **Tests**
|
|
||||||
- `src/daemon/wallet/index.nwc.test.ts` — mocks `applesauce-wallet-connect` /
|
|
||||||
`applesauce-relay` and proves: (a) a stale connection is rebuilt and status
|
|
||||||
still succeeds, (b) two consecutive hangs produce a bounded error, (c) a hung
|
|
||||||
invoice payment returns an error instead of hanging.
|
|
||||||
- `src/utils/with-timeout.test.ts` — unit tests for the helper.
|
|
||||||
|
|
||||||
Design note: this is defense-in-depth on routstrd's side. A request that stalls
|
|
||||||
now fails in bounded time and self-heals instead of blocking a user command or
|
|
||||||
the daemon's auto-refill loop.
|
|
||||||
|
|
||||||
## 6. Build, install, restart
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cd /root/workspace/routstrd
|
|
||||||
bun run build # dist/index.js + dist/daemon/index.js
|
|
||||||
```
|
|
||||||
|
|
||||||
The original global package was backed up first:
|
|
||||||
|
|
||||||
```
|
|
||||||
/root/routstrd-backup-20260929-155718
|
|
||||||
# path also stored in /root/.routstrd-build-backup-path
|
|
||||||
```
|
|
||||||
|
|
||||||
The new `dist/` (and matching `src/` files) were copied into
|
|
||||||
`/root/.bun/install/global/node_modules/routstrd/`, then:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
routstrd restart
|
|
||||||
```
|
|
||||||
|
|
||||||
Old daemon PID `90` -> new PID `21452`; `/health` was polled until healthy
|
|
||||||
before the command returned (~2 s).
|
|
||||||
|
|
||||||
## 7. Verification
|
|
||||||
|
|
||||||
```
|
|
||||||
$ time routstrd nwc status
|
|
||||||
{
|
|
||||||
"connected": true,
|
|
||||||
"alias": "Megalithic.me",
|
|
||||||
"network": "mainnet",
|
|
||||||
"balance": 60876,
|
|
||||||
"autoRefill": { "threshold": 30000, "amount": 21000, "cooldownMs": 30000 }
|
|
||||||
}
|
|
||||||
real 0m1.540s
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
$ time curl -m30 http://127.0.0.1:8008/nwc/status
|
|
||||||
real 0m1.347s
|
|
||||||
```
|
|
||||||
|
|
||||||
The auto-refill loop now starts and actually runs:
|
|
||||||
|
|
||||||
```
|
|
||||||
[wallet] Auto-refill enabled: threshold=30000 sats, amount=21000 sats ...
|
|
||||||
[nwc] NWC wallet connected. Relay: wss://relay-nwc.rizful.com/v1 ...
|
|
||||||
[auto-refill] Paying invoice via NWC...
|
|
||||||
[auto-refill] Successfully refilled 21000 sats. Preimage: 49b20b7b...
|
|
||||||
```
|
|
||||||
|
|
||||||
`routstrd status` reports `wallet: connected`, balance `22820` sats.
|
|
||||||
|
|
||||||
## 8. Test results
|
|
||||||
|
|
||||||
New tests: **6 pass, 0 fail**. `bunx tsc --noEmit` is clean.
|
|
||||||
|
|
||||||
The full suite still reports 4 failures that are **pre-existing on a clean
|
|
||||||
`v0.4.11`** and unrelated to this change:
|
|
||||||
|
|
||||||
- 3 in `tests/utils/daemon-client.test.ts` — `tests/integrations/pi.test.ts`
|
|
||||||
calls `mock.module("../../src/utils/daemon-client", ...)` and the mock leaks
|
|
||||||
across files depending on run order.
|
|
||||||
- 1 in `src/daemon/wallet/diagnostics.test.ts` — the unreadable-file test relies
|
|
||||||
on `chmod 000`, which does not block reads when running as root.
|
|
||||||
|
|
||||||
Confirmed by stashing the fix and running the suite on the pristine tag: same
|
|
||||||
4 failures.
|
|
||||||
|
|
||||||
## 9. Rollback
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cp /root/routstrd-backup-20260929-155718/dist/index.js \
|
|
||||||
/root/.bun/install/global/node_modules/routstrd/dist/index.js
|
|
||||||
cp /root/routstrd-backup-20260929-155718/dist/daemon/index.js \
|
|
||||||
/root/.bun/install/global/node_modules/routstrd/dist/daemon/index.js
|
|
||||||
routstrd restart
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. Upstream recommendation
|
|
||||||
|
|
||||||
`applesauce-wallet-connect@6.2.0` still has this defect. Suggested fixes
|
|
||||||
upstream:
|
|
||||||
|
|
||||||
1. Apply the request timeout around the **whole** pipeline, e.g. move
|
|
||||||
`simpleTimeout` so it wraps `defer(async () => { ... }).pipe(switchMap(...))`,
|
|
||||||
not just the `responses$` stream inside it.
|
|
||||||
2. Make the `"nip04"` encryption fallback reachable (e.g. `startWith` on
|
|
||||||
`support$`, or default to nip04 when wallet info is not yet known).
|
|
||||||
3. Let `getInfo()` / `getBalance()` forward a timeout / `AbortSignal` to
|
|
||||||
`genericCall`.
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
CLI `/nwc/*` requests have a 120-second deadline covering headers and response-body consumption. Other daemon routes are not subject to this cap because mint payment operations may run longer and cannot be cancelled by aborting the CLI request.
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { withTimeout as withRequestTimeout } from "../../utils/with-timeout";
|
||||||
import {
|
import {
|
||||||
Manager,
|
Manager,
|
||||||
OperationInProgressError,
|
OperationInProgressError,
|
||||||
@@ -629,16 +630,7 @@ const EXPIRED_MINT_OBSERVATION_DEADLINE_MS = 15_000;
|
|||||||
|
|
||||||
/** Rejects when `timeoutMs` elapses before `promise` settles. */
|
/** Rejects when `timeoutMs` elapses before `promise` settles. */
|
||||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
return withRequestTimeout(promise, timeoutMs, "Timed out contacting mint");
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Structural subset of coco's Manager used by expired-quote settlement. */
|
/** Structural subset of coco's Manager used by expired-quote settlement. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
|
import { afterAll, beforeEach, describe, expect, it, mock } from "bun:test";
|
||||||
|
import { RestrictedError } from "applesauce-wallet-connect/helpers/error";
|
||||||
import type { CocodClient } from "./cocod-client";
|
import type { CocodClient } from "./cocod-client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,13 +12,16 @@ import type { CocodClient } from "./cocod-client";
|
|||||||
* relay connection, and retry once instead of hanging `routstrd nwc status`.
|
* relay connection, and retry once instead of hanging `routstrd nwc status`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type Behavior = "resolve" | "hang";
|
type Behavior = "resolve" | "hang" | "restricted" | "library-timeout" | "deferred";
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
infoQueue: [] as Behavior[],
|
infoQueue: [] as Behavior[],
|
||||||
balanceQueue: [] as Behavior[],
|
balanceQueue: [] as Behavior[],
|
||||||
payQueue: [] as Behavior[],
|
payQueue: [] as Behavior[],
|
||||||
createdInstances: 0,
|
createdInstances: 0,
|
||||||
|
pendingPayments: [] as (() => void)[],
|
||||||
|
paymentStarted: undefined as (() => void) | undefined,
|
||||||
|
closedPools: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function next(queue: Behavior[]): Behavior {
|
function next(queue: Behavior[]): Behavior {
|
||||||
@@ -27,6 +31,7 @@ function next(queue: Behavior[]): Behavior {
|
|||||||
class MockRelayPool {
|
class MockRelayPool {
|
||||||
relays = new Map<string, unknown>([["wss://relay.example", {}]]);
|
relays = new Map<string, unknown>([["wss://relay.example", {}]]);
|
||||||
remove(url: string, _close?: boolean): void {
|
remove(url: string, _close?: boolean): void {
|
||||||
|
state.closedPools++;
|
||||||
this.relays.delete(url);
|
this.relays.delete(url);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +58,10 @@ class MockWalletConnect {
|
|||||||
network: string;
|
network: string;
|
||||||
methods: string[];
|
methods: string[];
|
||||||
}> {
|
}> {
|
||||||
if (next(state.infoQueue) === "hang") return new Promise<never>(() => {});
|
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({
|
return Promise.resolve({
|
||||||
alias: "Test Wallet",
|
alias: "Test Wallet",
|
||||||
pubkey: "cd".repeat(32),
|
pubkey: "cd".repeat(32),
|
||||||
@@ -63,14 +71,31 @@ class MockWalletConnect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getBalance(): Promise<{ balance: number }> {
|
getBalance(): Promise<{ balance: number }> {
|
||||||
if (next(state.balanceQueue) === "hang") return new Promise<never>(() => {});
|
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 });
|
return Promise.resolve({ balance: 123_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
payInvoice(
|
payInvoice(
|
||||||
_invoice: string,
|
_invoice: string,
|
||||||
): Promise<{ preimage: string; fees_paid: number }> {
|
): Promise<{ preimage: string; fees_paid: number }> {
|
||||||
if (next(state.payQueue) === "hang") return new Promise<never>(() => {});
|
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 });
|
return Promise.resolve({ preimage: "00".repeat(32), fees_paid: 1000 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,6 +134,9 @@ beforeEach(() => {
|
|||||||
state.balanceQueue = [];
|
state.balanceQueue = [];
|
||||||
state.payQueue = [];
|
state.payQueue = [];
|
||||||
state.createdInstances = 0;
|
state.createdInstances = 0;
|
||||||
|
state.closedPools = 0;
|
||||||
|
state.pendingPayments = [];
|
||||||
|
state.paymentStarted = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -153,3 +181,54 @@ describe("NWC status resilience", () => {
|
|||||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getTokenMetadata } from "@cashu/cashu-ts";
|
import { getTokenMetadata } from "@cashu/cashu-ts";
|
||||||
import { InsufficientBalanceError } from "@routstr/sdk";
|
import { InsufficientBalanceError } from "@routstr/sdk";
|
||||||
import { WalletConnect } from "applesauce-wallet-connect";
|
import { WalletConnect } from "applesauce-wallet-connect";
|
||||||
|
import { WalletBaseError } from "applesauce-wallet-connect/helpers/error";
|
||||||
import { RelayPool } from "applesauce-relay";
|
import { RelayPool } from "applesauce-relay";
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { withTimeout } from "../../utils/with-timeout";
|
import { withTimeout } from "../../utils/with-timeout";
|
||||||
@@ -13,7 +14,7 @@ import { startAutoRefillLoop, type AutoRefillConfig } from "./auto-refill";
|
|||||||
* connection is rebuilt before one retry.
|
* connection is rebuilt before one retry.
|
||||||
*/
|
*/
|
||||||
const NWC_READ_TIMEOUT_MS = 15_000;
|
const NWC_READ_TIMEOUT_MS = 15_000;
|
||||||
/** Lightning payments can legitimately take a little longer to settle. */
|
/** Overall bound including encryption negotiation; replies have a library 30s timeout. */
|
||||||
const NWC_PAY_TIMEOUT_MS = 45_000;
|
const NWC_PAY_TIMEOUT_MS = 45_000;
|
||||||
|
|
||||||
type NwcPayment = { preimage?: string; fees_paid?: number };
|
type NwcPayment = { preimage?: string; fees_paid?: number };
|
||||||
@@ -176,7 +177,8 @@ export async function createWalletAdapter(
|
|||||||
`${label} timed out`,
|
`${label} timed out`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!nwcConnectionString) throw error;
|
// A normal NIP-47 error proves the wallet answered. Keep other calls alive.
|
||||||
|
if (error instanceof WalletBaseError || !nwcConnectionString) throw error;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`[nwc] ${label} failed (${(error as Error).message}); rebuilding NWC connection and retrying`,
|
`[nwc] ${label} failed (${(error as Error).message}); rebuilding NWC connection and retrying`,
|
||||||
);
|
);
|
||||||
@@ -194,8 +196,8 @@ export async function createWalletAdapter(
|
|||||||
/**
|
/**
|
||||||
* Pay a BOLT-11 invoice over NWC with a bounded wait. A timeout rebuilds the
|
* 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
|
* relay connection so later calls recover without a daemon restart. The
|
||||||
* payment is not retried here: the invoice itself is single-use, and the
|
* payment is not retried here. A timeout is an unknown payment outcome,
|
||||||
* caller decides whether to attempt a fresh invoice.
|
* not proof of failure; callers must reconcile before trying a fresh invoice.
|
||||||
*/
|
*/
|
||||||
async function payNwcInvoice(invoice: string): Promise<NwcPayment> {
|
async function payNwcInvoice(invoice: string): Promise<NwcPayment> {
|
||||||
const payer = wallet;
|
const payer = wallet;
|
||||||
@@ -207,7 +209,10 @@ export async function createWalletAdapter(
|
|||||||
"NWC payment timed out",
|
"NWC payment timed out",
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rebuildNwcConnection("reconnected after payment timeout");
|
// Include the library's own timeout, but not normal wallet error replies.
|
||||||
|
if (!(error instanceof WalletBaseError)) {
|
||||||
|
rebuildNwcConnection("reconnected after payment timeout");
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,10 +237,6 @@ export async function createWalletAdapter(
|
|||||||
nwcConnectionString = undefined;
|
nwcConnectionString = undefined;
|
||||||
logger.log("[nwc] NWC wallet disconnected.");
|
logger.log("[nwc] NWC wallet disconnected.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// A connection added after startup previously never started the
|
|
||||||
// auto-refill loop, so it silently stayed disabled until a restart.
|
|
||||||
ensureAutoRefillLoop();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getBalances(): Promise<Record<string, number>> {
|
async getBalances(): Promise<Record<string, number>> {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test";
|
||||||
|
import { callDaemonUrl, DAEMON_REQUEST_TIMEOUT_MS } from "./daemon-client";
|
||||||
|
import { DEFAULT_CONFIG } from "./config";
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalSetTimeout = globalThis.setTimeout;
|
||||||
|
const originalClearTimeout = globalThis.clearTimeout;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
globalThis.setTimeout = originalSetTimeout;
|
||||||
|
globalThis.clearTimeout = originalClearTimeout;
|
||||||
|
});
|
||||||
|
|
||||||
|
function shortenDeadline(): void {
|
||||||
|
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) =>
|
||||||
|
originalSetTimeout(callback, delay === DAEMON_REQUEST_TIMEOUT_MS ? 20 : delay, ...args)
|
||||||
|
) as typeof setTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
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("NWC 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("does not impose the NWC deadline on mint payment routes", async () => {
|
||||||
|
let deadlines = 0;
|
||||||
|
globalThis.setTimeout = ((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => {
|
||||||
|
if (delay === DAEMON_REQUEST_TIMEOUT_MS) deadlines++;
|
||||||
|
return originalSetTimeout(callback, delay, ...args);
|
||||||
|
}) as typeof setTimeout;
|
||||||
|
globalThis.fetch = (async () => Response.json({ output: "paid" })) as unknown as typeof fetch;
|
||||||
|
expect(await request("/wallet/send/bolt11")).toEqual({ output: "paid" });
|
||||||
|
expect(deadlines).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clears the deadline after consuming a successful response", async () => {
|
||||||
|
let cleared = 0;
|
||||||
|
globalThis.clearTimeout = ((timer) => {
|
||||||
|
cleared++;
|
||||||
|
originalClearTimeout(timer as ReturnType<typeof setTimeout>);
|
||||||
|
}) as typeof clearTimeout;
|
||||||
|
globalThis.fetch = (async () => Response.json({ output: "ok" })) as unknown as typeof fetch;
|
||||||
|
expect(await request()).toEqual({ output: "ok" });
|
||||||
|
expect(cleared).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
+33
-28
@@ -57,9 +57,9 @@ class DaemonConnectionError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upper bound for a single daemon request. The daemon bounds its own NWC
|
* Upper bound for NWC requests, including response-body consumption.
|
||||||
* operations, so this only guards against a wedged server; without it a hung
|
* Other routes can perform long-running mint payments; aborting the client
|
||||||
* request would block the CLI forever.
|
* does not cancel those operations, so do not impose this cap on them.
|
||||||
*/
|
*/
|
||||||
export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
|
export const DAEMON_REQUEST_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ export function getAuthBaseUrl(config: RoutstrdConfig): string {
|
|||||||
return getDaemonBaseUrl(config);
|
return getDaemonBaseUrl(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _callUrl(
|
export async function callDaemonUrl(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
path: string,
|
path: string,
|
||||||
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
|
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
|
||||||
@@ -106,36 +106,41 @@ async function _callUrl(
|
|||||||
if (authorization) headers.set("Authorization", authorization);
|
if (authorization) headers.set("Authorization", authorization);
|
||||||
if (bodyString) headers.set("Content-Type", "application/json");
|
if (bodyString) headers.set("Content-Type", "application/json");
|
||||||
|
|
||||||
let response: Response;
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(
|
const timeoutId = path.startsWith("/nwc/")
|
||||||
() => controller.abort(),
|
? setTimeout(() => controller.abort(), DAEMON_REQUEST_TIMEOUT_MS)
|
||||||
DAEMON_REQUEST_TIMEOUT_MS,
|
: undefined;
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
let response: Response;
|
||||||
method,
|
try {
|
||||||
headers,
|
response = await fetch(url, {
|
||||||
body: bodyString,
|
method,
|
||||||
signal: controller.signal,
|
headers,
|
||||||
});
|
body: bodyString,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (controller.signal.aborted) throw error;
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
return await response.json() as CommandResponse;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Daemon request timed out after ${DAEMON_REQUEST_TIMEOUT_MS / 1000}s`,
|
`Daemon request timed out after ${DAEMON_REQUEST_TIMEOUT_MS / 1000}s; ` +
|
||||||
|
"any payment outcome is unknown — check before retrying",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw new DaemonConnectionError(error);
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId);
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = (await response.json()) as { error?: string };
|
|
||||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json() as Promise<CommandResponse>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callLocalDaemon(
|
async function callLocalDaemon(
|
||||||
@@ -147,7 +152,7 @@ async function callLocalDaemon(
|
|||||||
let connectionError: DaemonConnectionError | undefined;
|
let connectionError: DaemonConnectionError | undefined;
|
||||||
for (const baseUrl of localDaemonBaseUrls(config)) {
|
for (const baseUrl of localDaemonBaseUrls(config)) {
|
||||||
try {
|
try {
|
||||||
return await _callUrl(baseUrl, path, options, config);
|
return await callDaemonUrl(baseUrl, path, options, config);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof DaemonConnectionError)) throw error;
|
if (!(error instanceof DaemonConnectionError)) throw error;
|
||||||
connectionError = error;
|
connectionError = error;
|
||||||
@@ -162,7 +167,7 @@ export async function callDaemon(
|
|||||||
): Promise<CommandResponse> {
|
): Promise<CommandResponse> {
|
||||||
const config = await loadConfig();
|
const config = await loadConfig();
|
||||||
if (config.daemonUrl) {
|
if (config.daemonUrl) {
|
||||||
return _callUrl(getDaemonBaseUrl(config), path, options, config);
|
return callDaemonUrl(getDaemonBaseUrl(config), path, options, config);
|
||||||
}
|
}
|
||||||
return callLocalDaemon(path, options, config);
|
return callLocalDaemon(path, options, config);
|
||||||
}
|
}
|
||||||
@@ -177,7 +182,7 @@ export async function callAuth(
|
|||||||
if (!config.authUrl && !config.daemonUrl) {
|
if (!config.authUrl && !config.daemonUrl) {
|
||||||
return callLocalDaemon(path, options, config);
|
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> {
|
export async function isDaemonRunning(): Promise<boolean> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Rejects when `timeoutMs` elapses before `promise` settles.
|
* 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
|
* Used to bound requests that may otherwise wait forever — notably NWC calls
|
||||||
* whose underlying library applies its own timeout only after a support/encryption
|
* whose underlying library applies its own timeout only after a support/encryption
|
||||||
|
|||||||
Reference in New Issue
Block a user