aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-06-06 09:10:16 -0700
committerAdam Soltys <[email protected]>2026-06-06 09:10:16 -0700
commit8cb1882111c079cfac9870054d9917fa5f94de76 (patch)
tree9b03a58eb19bdd3bbcedb32ee2b356dbfaccff7d /lib
parentc4aba2cfd76eeaa0a177a4a92ca4539f5bf61e52 (diff)
downloadcoinos-server-8cb1882111c079cfac9870054d9917fa5f94de76.tar.gz
coinos-server-8cb1882111c079cfac9870054d9917fa5f94de76.zip
nwc: fix pay_invoice reply reliability, rate-limit drops, notifications
Three issues reported by an NWC client integrator (refs #80): (a) pay_invoice sync reply: the handler polled listpays for ~20s then returned a misleading "Payment timed out" error on slow routes even though the payment settled — client never got the preimage. sendLightning already runs xpay to completion and finalize() sets p.ref = preimage, so return that directly; keep a longer listpays loop only as a fallback, and on still-in-flight return an explicit "check with lookup_invoice" rather than implying failure. (b) rate limiting: raised per-pubkey cap 5/min -> 60/min (normal usage — a balance check + payment + a few lookup_invoice reconciles — trivially exceeded 5), and over-limit requests now get a NIP-47 RATE_LIMITED error reply instead of a silent drop that looked exactly like a lost reply. Also: every request path now always sends a 23195 reply (success, error, or handler-returned- nothing) so a client never hangs waiting. (c) notifications: payment_received/payment_sent are advertised in the info event but app.notify defaulted to false unless the client sent notify:"true" (only ~1% of apps had it on), so they never fired. Default notify ON; only disable when explicitly "false". (Existing apps still false — backfill TBD.) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Diffstat (limited to 'lib')
-rw-r--r--lib/nwc.ts117
1 files changed, 81 insertions, 36 deletions
diff --git a/lib/nwc.ts b/lib/nwc.ts
index 3a1ae5b0..af4dc7b7 100644
--- a/lib/nwc.ts
+++ b/lib/nwc.ts
@@ -42,8 +42,12 @@ const nwcEventMaxAgeSeconds = 5 * 60;
const handledKey = "handled:nwc";
const handledMaxSize = 200000;
-// Per-pubkey rate limiting for NWC requests
-const nwcRateLimit = 5; // max requests per minute per pubkey
+// Per-pubkey rate limiting for NWC requests. Raised 5 -> 60/min: normal client
+// usage (a balance check or two + a payment + a few lookup_invoice reconciles)
+// blows past 5 trivially, and over-limit requests were dropped SILENTLY with no
+// reply — indistinguishable from lost replies. 60/min (1/s avg) is ample
+// headroom for interactive use while still capping abuse.
+const nwcRateLimit = 60; // max requests per minute per pubkey
const nwcRateWindow = 60 * 1000; // 1 minute in ms
const nwcRequestTimes: Map<string, number[]> = new Map();
@@ -157,11 +161,44 @@ export default () => {
if (ev.created_at && now - ev.created_at > nwcEventMaxAgeSeconds) return;
if (await db.zScore(handledKey, ev.id)) return;
- // Per-pubkey rate limiting
+ let { content, pubkey } = ev;
+ const pk = ev.tags.find((t) => t[0] === "p")[1];
+ const sk = serverKeys[pk];
+ const { params, method } = JSON.parse(
+ await nip04.decrypt(sk, pubkey, content),
+ );
+
+ // Helper: send a 23195 reply (used for both results and errors). Pulled
+ // up so the rate-limit path can reply instead of dropping silently.
+ const reply = async (payloadObj: any) => {
+ const enc = await nip04.encrypt(sk, pubkey, JSON.stringify(payloadObj));
+ const signed = await finalizeEvent(
+ {
+ created_at: Math.floor(Date.now() / 1000),
+ kind: 23195,
+ pubkey: serverPubkey,
+ tags: [["p", pubkey], ["e", ev.id]],
+ content: enc,
+ } as UnsignedEvent,
+ hexToBytes(sk),
+ );
+ r.send(["EVENT", signed]);
+ };
+
+ // Per-pubkey rate limiting. Over-limit requests get a NIP-47 RATE_LIMITED
+ // error reply (not a silent drop) so the client can distinguish throttling
+ // from a lost reply and back off.
const times = nwcRequestTimes.get(ev.pubkey) || [];
const cutoff = Date.now() - nwcRateWindow;
const recent = times.filter((t) => t > cutoff);
- if (recent.length >= nwcRateLimit) return;
+ if (recent.length >= nwcRateLimit) {
+ db.zAdd(handledKey, { score: now, value: ev.id });
+ await reply({
+ result_type: method,
+ error: { code: "RATE_LIMITED", message: "Too many requests, slow down" },
+ });
+ return;
+ }
recent.push(Date.now());
nwcRequestTimes.set(ev.pubkey, recent);
@@ -171,12 +208,6 @@ export default () => {
if (size > handledMaxSize) {
await db.zRemRangeByRank(handledKey, 0, size - handledMaxSize - 1);
}
- let { content, pubkey } = ev;
- const pk = ev.tags.find((t) => t[0] === "p")[1];
- const sk = serverKeys[pk];
- const { params, method } = JSON.parse(
- await nip04.decrypt(sk, pubkey, content),
- );
// console.log("nwc", method, params, pubkey);
@@ -189,26 +220,27 @@ export default () => {
if (!user) fail("user not found");
const result = await handle(method, params, ev, app, user);
- const payload = JSON.stringify({ result_type: method, ...result });
- content = await nip04.encrypt(sk, pubkey, payload);
-
- let response: UnsignedEvent = {
- created_at: Math.floor(Date.now() / 1000),
- kind: 23195,
- pubkey: serverPubkey,
- tags: [
- ["p", pubkey],
- ["e", ev.id],
- ],
- content,
- };
-
- response = await finalizeEvent(response, hexToBytes(sk));
- r.send(["EVENT", response]);
+ // A handler returning nothing (e.g. pay_invoice that couldn't confirm in
+ // time) must still produce a reply, or the client hangs forever waiting.
+ if (result === undefined || result === null) {
+ await reply({
+ result_type: method,
+ error: { code: "INTERNAL", message: "No response from handler" },
+ });
+ } else {
+ await reply({ result_type: method, ...result });
+ }
} catch (e) {
// Stale client state (deleted app or migrated user) is not a server fault — don't log.
if (e.message === "pubkey not found" || e.message === "user not found") return;
err("problem with nwc", pubkey, method, e.message);
+ // Still reply so the client isn't left hanging on the failure.
+ try {
+ await reply({
+ result_type: method,
+ error: { code: "INTERNAL", message: e.message },
+ });
+ } catch (_) {}
}
} catch (e) {
err("problem with nwc event", e.message);
@@ -318,7 +350,12 @@ const handle = (method, params, ev, app, user) =>
}
try {
- const { id: pid } = await sendLightning({
+ // sendLightning runs xpay to completion and finalize() sets p.ref to the
+ // preimage on success, so the returned record already carries it — no
+ // need to poll listpays and race a 20s timeout (the old loop returned a
+ // misleading "Payment timed out" error on slow routes even though the
+ // payment had settled; see issue #80).
+ const p = await sendLightning({
amount,
fee: max_fee || Math.round(amount * 0.01),
user,
@@ -326,22 +363,30 @@ const handle = (method, params, ev, app, user) =>
memo: JSON.stringify(metadata),
});
- await db.lPush(`${pubkey}:payments`, pid);
+ await db.lPush(`${pubkey}:payments`, p.id);
- for (let i = 0; i < 10; i++) {
+ if (p.ref) return result({ preimage: p.ref });
+
+ // Fallback: ref missing (shouldn't normally happen) — confirm via
+ // listpays before giving up, with a longer window than before.
+ for (let i = 0; i < 30; i++) {
const { pays } = await ln.listpays(pr);
- const p = pays.find((p) => p.status === "complete");
- if (p) {
- const { preimage } = p;
- return result({ preimage });
- }
+ const done = pays.find((x) => x.status === "complete");
+ if (done?.preimage) return result({ preimage: done.preimage });
+ if (pays.length && pays.every((x) => x.status === "failed"))
+ return error({ code: "PAYMENT_FAILED", message: "Payment failed" });
await sleep(2000);
}
} catch (e) {
- return error({ code: "INTERNAL", message: e.message });
+ return error({ code: "PAYMENT_FAILED", message: e.message });
}
- return error({ code: "INTERNAL", message: "Payment timed out" });
+ // Still in flight after the fallback window: the payment may yet settle.
+ // Tell the client explicitly rather than implying success or hard failure.
+ return error({
+ code: "INTERNAL",
+ message: "Payment still in flight; check status with lookup_invoice",
+ });
},
async pay_keysend() {