aboutsummaryrefslogtreecommitdiffstats
path: root/lib/nwc.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/nwc.ts')
-rw-r--r--lib/nwc.ts1033
1 files changed, 914 insertions, 119 deletions
diff --git a/lib/nwc.ts b/lib/nwc.ts
index c659141a..21bab0a7 100644
--- a/lib/nwc.ts
+++ b/lib/nwc.ts
@@ -1,24 +1,56 @@
import config from "$config";
-import { db, g } from "$lib/db";
-import { generate } from "$lib/invoices";
+import { archive, db, g, gf } from "$lib/db";
+import { generate, getUserOffer } from "$lib/invoices";
import ln from "$lib/ln";
import { err, l, warn } from "$lib/logging";
-import { handleZap, serverPubkey, serverSecret } from "$lib/nostr";
+import {
+ decryptPayload,
+ encryptPayload,
+ encryptionSchemes,
+ handleZap,
+ serverPubkey,
+ serverPubkey2,
+ serverSecret,
+ serverSecret2,
+} from "$lib/nostr";
import { sendInternal, sendKeysend, sendLightning } from "$lib/payments";
import { fail, getInvoice, sleep } from "$lib/utils";
import rpc from "@coinos/rpc";
-import { hexToBytes } from "@noble/hashes/utils";
+import { sha256 } from "@noble/hashes/sha256";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { Relay } from "nostr";
-import { finalizeEvent, nip04 } from "nostr-tools";
+import { finalizeEvent } from "nostr-tools";
import type { UnsignedEvent } from "nostr-tools";
+const serverKeys = {
+ [serverPubkey]: serverSecret,
+ [serverPubkey2]: serverSecret2,
+};
+
const result = (result) => ({ result });
const error = (error) => ({ error });
+// Every rejected NWC payment used to vanish without a trace: the error went
+// back to the client and nothing was logged, so a user reporting "zaps over
+// 1k fail" left no evidence server-side. One warn line per failure: who,
+// which connection, how much, and why.
+const payFail = (method, app, user, amount, err) => {
+ warn(
+ `nwc ${method} failed`,
+ user?.username,
+ String(app?.pubkey || "").slice(0, 12),
+ amount,
+ err?.error?.code,
+ String(err?.error?.message || "").slice(0, 160),
+ );
+ return err;
+};
const bc = rpc(config.bitcoin);
const methods = [
"pay_keysend",
"pay_invoice",
+ "pay",
+ "receive",
"get_balance",
"get_info",
"make_invoice",
@@ -27,155 +59,626 @@ const methods = [
];
const week = 7 * 24 * 60 * 60;
+const nwcEventMaxAgeSeconds = 5 * 60;
+// Tolerated clock skew for future-dated events. Events further ahead than this
+// are rejected so a captured, future-dated signed event can't be replayed later.
+const nwcClockSkewSeconds = 60;
+const handledKey = "handled:nwc";
+
+// 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();
+
+// Per-app async mutex for spends. checkBudget reads `${pubkey}:payments` and the
+// spend is only recorded after the payment settles, so N concurrent requests on
+// one connection would each read the same stale "spent" total and all pass —
+// up to N × max_amount out. Serializing the check + send + record per app closes
+// that window (same shape as withLimitLock for the asset-type limits).
+const budgetLocks: Map<string, Promise<void>> = new Map();
+const withBudgetLock = async <T>(key: string, fn: () => Promise<T>): Promise<T> => {
+ const prev = budgetLocks.get(key) || Promise.resolve();
+ let release: () => void = () => {};
+ const next = new Promise<void>((res) => {
+ release = res;
+ });
+ budgetLocks.set(key, prev.then(() => next));
+ await prev;
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+};
+const budgetedMethods = new Set(["pay_invoice", "pay", "pay_keysend"]);
+const nwcDormancyWindow = 30 * 24 * 60 * 60 * 1000;
+
+const isDormantNwcApp = async (app) => {
+ const cutoff = Date.now() - nwcDormancyWindow;
+ let lastActivity = Number(app.created) || 0;
+
+ // Payment indexes are newest-first. Check a few entries because an archived
+ // or missing record must not make a recently active connection look dormant.
+ const pids = await db.lRange(`${app.pubkey}:payments`, 0, 19);
+ for (const pid of pids) {
+ const payment = await gf(`payment:${pid}`);
+ if (payment?.created)
+ lastActivity = Math.max(lastActivity, Number(payment.created));
+ }
+
+ return lastActivity < cutoff;
+};
export default () => {
- const r = new Relay("ws://sf:7777");
+ let r: any;
+ let heartbeatInterval: any;
+ let infoCheckInterval: any;
- r.on("open", async (_) => {
- r.subscribe("nwc", { kinds: [23194], "#p": [serverPubkey] });
- const info = await finalizeEvent(
+ // Publish the kind 13194 info event and CONFIRM the relay stored it. The
+ // long-lived NWC socket's send is fire-and-forget (await wait_connected; ws.send)
+ // and was called without await — so a send that rejected (e.g. ws in CLOSING
+ // state during a reconnect) was silently dropped while we logged success,
+ // leaving clients with "no info event" until a manual republish. Publish over a
+ // fresh short-lived socket and wait for the relay's OK, retrying a few times.
+ async function publishInfo(pk: string, sk: string) {
+ const info = finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: 13194,
tags: [
- ["p", serverPubkey],
+ ["p", pk],
["notifications", "payment_received payment_sent"],
+ ["encryption", encryptionSchemes],
],
content: methods.join(" "),
},
- hexToBytes(serverSecret),
+ hexToBytes(sk),
);
- r.send(["EVENT", info]);
- });
+ // No success log: this now runs every 2 minutes per pubkey, so a "published"
+ // line each time is pure noise. Only the failure paths below log.
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ if (await sendEventConfirmed(info)) return true;
+ warn(`nwc 13194 publish not confirmed (attempt ${attempt})`, pk);
+ await sleep(1000);
+ }
+ err("nwc failed to publish 13194 info event after retries", pk);
+ return false;
+ }
+
+ // Send an event to strfry over a fresh socket and resolve true once the relay
+ // replies OK=accepted. Independent of the long-lived NWC socket, whose
+ // fire-and-forget send proved unreliable for the persisted info event.
+ function sendEventConfirmed(ev: any, timeoutMs = 5000): Promise<boolean> {
+ return new Promise((resolve) => {
+ let ws: WebSocket;
+ let done = false;
+ const finish = (ok: boolean) => {
+ if (done) return;
+ done = true;
+ try { ws.close(); } catch (_) {}
+ resolve(ok);
+ };
+ try {
+ ws = new WebSocket("ws://sf:7777");
+ } catch (_) {
+ return resolve(false);
+ }
+ ws.onopen = () => ws.send(JSON.stringify(["EVENT", ev]));
+ ws.onmessage = (e: any) => {
+ try {
+ const m = JSON.parse(e.data);
+ if (m[0] === "OK" && m[1] === ev.id) finish(!!m[2]);
+ } catch (_) {}
+ };
+ ws.onerror = () => finish(false);
+ setTimeout(() => finish(false), timeoutMs);
+ });
+ }
+
+ // The kind 13194 info event (NWC capability advertisement) is REPLACEABLE, so
+ // it's meant to persist on the relay. NWC clients (Alby Go, Lightning Piggies,
+ // etc.) fetch it before they'll talk to the wallet — without it they report
+ // "no info event" and show an unknown balance.
+ //
+ // We used to query the relay and publish only the missing pubkeys, but the
+ // stored event kept vanishing between checks (cause never pinned down), which
+ // left clients broken until the next hourly check. Republishing both server
+ // info events unconditionally on a short interval is cheap (a replaceable
+ // event, so it just overwrites in place) and self-heals immediately.
+ // publishInfo() uses its own short-lived socket, so this works regardless of
+ // the long-lived nwc socket's state.
+ function ensureInfo() {
+ publishInfo(serverPubkey, serverSecret);
+ publishInfo(serverPubkey2, serverSecret2);
+ }
+
+ function connect() {
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
+ if (infoCheckInterval) clearInterval(infoCheckInterval);
+ r = new Relay("ws://sf:7777", { reconnect: false });
+
+ r.on("open", async (_) => {
+ l("nwc connected to strfry");
+
+ // Periodically check if connection is alive
+ heartbeatInterval = setInterval(() => {
+ try {
+ if (!r?.ws || r.ws.readyState !== 1) {
+ warn("nwc heartbeat: connection dead, reconnecting");
+ clearInterval(heartbeatInterval);
+ try { r.close(); } catch (_) {}
+ setTimeout(connect, 1000);
+ }
+ } catch (_) {
+ warn("nwc heartbeat: error, reconnecting");
+ clearInterval(heartbeatInterval);
+ setTimeout(connect, 1000);
+ }
+ }, 30000);
+ r.subscribe("nwc", { kinds: [23194], "#p": [serverPubkey, serverPubkey2], since: Math.floor(Date.now() / 1000) - nwcEventMaxAgeSeconds });
+
+ // Publish the 13194 info events immediately, then keep republishing every
+ // 2 minutes so a vanished event is never missing for long.
+ ensureInfo();
+ infoCheckInterval = setInterval(ensureInfo, 2 * 60 * 1000);
+ });
- r.on("event", async (sub, ev) => {
+
+ r.on("close", () => {
+ warn("nwc strfry connection lost, reconnecting in 5s");
+ if (heartbeatInterval) clearInterval(heartbeatInterval);
+ if (infoCheckInterval) clearInterval(infoCheckInterval);
+ setTimeout(connect, 5000);
+ });
+
+ r.on("error", () => {});
+
+ r.on("event", async (sub, ev) => {
try {
if (sub !== "nwc") return;
- if (await db.sIsMember("handled", ev.id)) return;
- db.sAdd("handled", ev.id);
+ const now = Math.floor(Date.now() / 1000);
+ // Reject events outside the accepted window in BOTH directions. Old events
+ // are stale; future-dated ones (beyond a small skew) could otherwise be
+ // replayed indefinitely once a time-bounded dedup entry expired.
+ if (ev.created_at) {
+ if (now - ev.created_at > nwcEventMaxAgeSeconds) return;
+ if (ev.created_at - now > nwcClockSkewSeconds) return;
+ }
+ // Atomic dedup claim. SET NX is a single-command check-and-set, so two
+ // concurrent deliveries of the same signed event cannot both pass — the
+ // previous zScore-then-(unawaited)-zAdd let duplicate relay deliveries each
+ // reach handle() and pay twice. The claim outlives the accepted age window
+ // so a future-dated event can't be replayed after it would have expired.
+ if (
+ !(await db.set(`${handledKey}:${ev.id}`, "1", {
+ NX: true,
+ EX: 2 * nwcEventMaxAgeSeconds,
+ }))
+ )
+ return;
+
let { content, pubkey } = ev;
+ const pk = ev.tags.find((t) => t[0] === "p")[1];
+ const sk = serverKeys[pk];
+ // Clients mark nip44 requests with an `encryption` tag; absence means
+ // legacy nip04 (NIP-47). Replies must use the same scheme.
+ const scheme =
+ ev.tags.find((t) => t[0] === "encryption")?.[1] === "nip44_v2"
+ ? "nip44_v2"
+ : "nip04";
const { params, method } = JSON.parse(
- await nip04.decrypt(serverSecret, pubkey, content),
+ await decryptPayload(scheme, 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 encryptPayload(
+ scheme,
+ 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], ["encryption", scheme]],
+ 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) {
+ // The event is already claimed above, so it won't be reprocessed.
+ 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);
+
// console.log("nwc", method, params, pubkey);
if (!methods.includes(method)) return;
+ // Incident kill switch. NWC connection secrets are long-lived bearer
+ // credentials; if they may have been disclosed, a ledger hard-freeze is
+ // too broad to leave on indefinitely and per-app deletion is both slow and
+ // destructive. This flag makes every NWC method fail closed while keeping
+ // the connection records available for deliberate rotation/recovery.
+ if (await g("nwc:disabled")) {
+ await reply({
+ result_type: method,
+ error: {
+ code: "INTERNAL",
+ message: "NWC is temporarily disabled",
+ },
+ });
+ return;
+ }
+
try {
const app = await g(`app:${pubkey}`);
if (!app) fail("pubkey not found");
const user = await g(`user:${app.uid}`);
+ if (!user) fail("user not found");
+
+ // Forced credential rotation. NWC secrets are bearer credentials, so a
+ // suspected disclosure cannot be repaired by changing an account
+ // password. The cutoff retires every connection created before the
+ // incident epoch while allowing users to create replacement connections
+ // without a global NWC outage. Keep the explicit quarantine membership
+ // as a durable forensic marker and as defense in depth if the cutoff is
+ // later removed accidentally.
+ const credentialCutoff = Number(await g("nwc:credential-cutoff")) || 0;
+ if (
+ credentialCutoff > 0 &&
+ (!Number(app.created) || Number(app.created) < credentialCutoff)
+ ) {
+ await db.sAdd("nwc:quarantined", pubkey);
+ // Retired clients commonly keep polling until their owners replace
+ // the connection. The rejection is expected and can be extremely
+ // noisy, so reply without logging every attempt. Explicit quarantine
+ // and newly detected attack-pattern events remain warning-level.
+ await reply({
+ result_type: method,
+ error: {
+ code: "UNAUTHORIZED",
+ message:
+ "This NWC connection has been retired; create a new one",
+ },
+ });
+ return;
+ }
- const result = await handle(method, params, ev, app, user);
- const payload = JSON.stringify({ result_type: method, ...result });
- content = await nip04.encrypt(serverSecret, pubkey, payload);
+ // Incident containment without disrupting every NWC user. Quarantine
+ // only connections whose bearer secret shows the probe/drain pattern;
+ // retain the app record so support can identify and rotate it later.
+ if (await db.sIsMember("nwc:quarantined", pubkey)) {
+ warn("Blocked quarantined NWC connection", user.username, pubkey, method);
+ await reply({
+ result_type: method,
+ error: {
+ code: "UNAUTHORIZED",
+ message: "This NWC connection has been disabled; create a new one",
+ },
+ });
+ return;
+ }
- let response: UnsignedEvent = {
- created_at: Math.floor(Date.now() / 1000),
- kind: 23195,
- pubkey: serverPubkey,
- tags: [
- ["p", pubkey],
- ["e", ev.id],
- ],
- content,
- };
+ // Active-incident guard: the leaked credential set is being walked with
+ // keysend probes against years-old, previously idle connections. Block
+ // the first keysend after 30 days of inactivity and quarantine only that
+ // connection. Ordinary pay_invoice/pay traffic and active keysend apps
+ // remain unaffected. The Redis flag makes this guard easy to retire once
+ // the credential-rotation campaign is complete.
+ if (
+ method === "pay_keysend" &&
+ (await g("nwc:guard:dormant-keysend")) &&
+ (await isDormantNwcApp(app))
+ ) {
+ await db.sAdd("nwc:quarantined", pubkey);
+ warn("SECURITY: quarantined dormant NWC keysend", user.username, pubkey);
+ await reply({
+ result_type: method,
+ error: {
+ code: "UNAUTHORIZED",
+ message: "This dormant NWC connection has been disabled; create a new one",
+ },
+ });
+ return;
+ }
- response = await finalizeEvent(response, hexToBytes(serverSecret));
- r.send(["EVENT", response]);
+ // Serialize budget-affecting methods per app so the budget check and the
+ // resulting spend can't interleave with a concurrent request on the same
+ // connection. Read-only methods run without contending for the lock.
+ const run = () => handle(method, params, ev, app, user);
+ const result = budgetedMethods.has(method)
+ ? await withBudgetLock(app.pubkey, run)
+ : await run();
+ // 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) {
- err(
- "problem with nwc",
- pubkey,
- method,
- JSON.stringify(params),
- e.message,
- );
+ // Stale client state (deleted app or migrated user) is not a server
+ // fault — don't log it, but DO answer: a NIP-47 UNAUTHORIZED reply
+ // lets the client surface an error instead of spinning forever on a
+ // connection this server no longer knows.
+ if (e.message === "pubkey not found" || e.message === "user not found") {
+ try {
+ await reply({
+ result_type: method,
+ error: { code: "UNAUTHORIZED", message: "unknown connection" },
+ });
+ } catch (_) {}
+ 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", e.message);
+ err("problem with nwc event", e.message);
}
});
+ }
+
+ connect();
+};
+
+// Pull the single lightning instruction out of a BIP-321 payment string for
+// the nwc `pay` method (nwc#2): a bitcoin: URI (preferring lno= over
+// lightning=), a lightning: URI, or a bare bolt11/bolt12 string. Returns
+// { instruction } or { error } (a ready-to-send NIP-47 error payload).
+const noInstruction = error({
+ code: "UNSUPPORTED_PAYMENT_INSTRUCTION",
+ message: "No lightning instruction found (bolt11 or bolt12 required)",
+});
+const parseInstruction = (payment) => {
+ if (typeof payment !== "string") return noInstruction;
+ let s = payment.replace(/\s/g, "");
+ const lower = s.toLowerCase();
+ if (lower.startsWith("lightning:")) {
+ s = s.slice("lightning:".length);
+ } else if (lower.startsWith("bitcoin:")) {
+ const i = s.indexOf("?");
+ if (i === -1) return noInstruction;
+ let lno;
+ let bolt11;
+ // BIP-321 keys are case-insensitive and may repeat; first one wins
+ for (const [k, v] of new URLSearchParams(s.slice(i + 1))) {
+ const key = k.toLowerCase();
+ // BIP-321: a req- parameter the wallet doesn't understand invalidates
+ // the whole URI. We support none — including req-pop, since we can't
+ // open a proof-of-payment callback — so any req- key is a rejection.
+ // A plain (optional) pop is skippable and falls through harmlessly.
+ if (key.startsWith("req-")) {
+ return error({
+ code: "BAD_REQUEST",
+ message: `Unsupported required BIP-321 parameter: ${key}`,
+ });
+ }
+ if (key === "lno") lno ||= v;
+ else if (key === "lightning") bolt11 ||= v;
+ }
+ s = lno || bolt11;
+ if (!s) return noInstruction;
+ }
+ s = s.toLowerCase();
+ return s.startsWith("ln") ? { instruction: s } : noInstruction;
+};
+
+// Network matching for the nwc `pay` method: the spec requires rejecting a
+// payment instruction for a different Bitcoin network BEFORE paying (error
+// code UNSUPPORTED_NETWORK) rather than letting xpay fail after the debit.
+// bolt11 encodes the network in its bech32 prefix (decode's `currency`);
+// bolt12 carries BOLT chain_hashes (genesis hash, internal byte order), with
+// absence meaning mainnet. Keyed by CLN getinfo's `network` name.
+const bolt11Prefixes = {
+ bitcoin: "bc",
+ testnet: "tb",
+ signet: "tbs",
+ regtest: "bcrt",
+};
+const chainHashes = {
+ bitcoin: "6fe28c0ab6f1b372c1a6a246ae63f74f931e8365e15a089c68d6190000000000",
+ testnet: "43497fd7f826957108f4a30fd9cec3aeba79972084e90ead01ea330900000000",
+ signet: "f61eee3b63a380a477a063af32b2bbc97c9ff9f01f2c4225e973988108000000",
+ regtest: "06226e46111a0b59caaf126043eb5bbf28c34f3a5e332a1fc7b2b73cf188910f",
+};
+const matchesNetwork = (decoded, network) => {
+ if (decoded.type === "bolt11 invoice")
+ return decoded.currency === bolt11Prefixes[network];
+ const ours = chainHashes[network];
+ if (decoded.type === "bolt12 offer")
+ return decoded.offer_chains
+ ? decoded.offer_chains.includes(ours)
+ : network === "bitcoin";
+ const chain = decoded.invreq_chain || chainHashes.bitcoin;
+ return chain === ours;
+};
+
+// CLN v26.06's experimental `createproof` RPC produces the lnp payer proof
+// that NIP-177 zap receipts (kind 9736) embed. The default proof OMITS
+// invreq_payer_note, which NIP-177 verification requires disclosed (it binds
+// the payment to the zap intent) — and passing `include` REPLACES the default
+// disclosure set, so invoice_amount must be re-listed alongside it or the
+// amount gets hidden too. Feature-detected through the generic call() — on
+// older nodes this errors and we omit the proof, which the nwc `pay` spec
+// allows.
+const createPayerProof = async (
+ invstring: string,
+ hasPayerNote: boolean = false,
+) => {
+ try {
+ const r = await ln.call("createproof", {
+ invstring,
+ ...(hasPayerNote && { include: ["invreq_payer_note", "invoice_amount"] }),
+ });
+ return r?.proofs?.[0]?.bolt12;
+ } catch (e) {
+ return undefined;
+ }
+};
+
+// Connection validity + spending budget for an NWC app, shared by pay_invoice
+// and pay. Returns a NIP-47 error payload, or null when the spend is allowed.
+const checkBudget = async (app, amount) => {
+ const { max_amount, budget_renewal, pubkey, created } = app;
+
+ if (!created) {
+ return {
+ budgetError: error({
+ code: "UNAUTHORIZED",
+ message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`,
+ }),
+ remaining: 0,
+ };
+ }
+
+ const unlimited =
+ max_amount === undefined || max_amount === null || max_amount === "";
+ const limit = unlimited ? 0 : Number(max_amount);
+ if (!Number.isFinite(limit) || limit < 0) {
+ return {
+ budgetError: error({
+ code: "UNAUTHORIZED",
+ message: "This NWC connection has an invalid spending budget",
+ }),
+ remaining: 0,
+ };
+ }
+
+ // A blank budget is deliberately unlimited. A positive budget must have a
+ // known renewal period; otherwise an unknown value makes every historical
+ // payment fall out of the window and silently resets the budget each call.
+ if (limit === 0) return { budgetError: null, remaining: undefined };
+
+ const periods = {
+ daily: 60 * 60 * 24 * 1000,
+ weekly: 60 * 60 * 24 * 7 * 1000,
+ monthly: 60 * 60 * 24 * 30 * 1000,
+ yearly: 60 * 60 * 24 * 365 * 1000,
+ never: 60 * 60 * 24 * 365 * 10 * 1000,
+ };
+
+ const period = periods[budget_renewal];
+ if (!period) {
+ return {
+ budgetError: error({
+ code: "UNAUTHORIZED",
+ message: "This NWC connection has an invalid budget renewal period",
+ }),
+ remaining: 0,
+ };
+ }
+
+ const pids = await db.lRange(`${pubkey}:payments`, 0, -1);
+ let payments = await Promise.all(pids.map((pid) => gf(`payment:${pid}`)));
+ payments = payments.filter(
+ (p) => p?.created > Date.now() - period,
+ );
+
+ const spent = payments.reduce(
+ (a, b) =>
+ a +
+ (Math.abs(Number.parseInt(b.amount || 0)) +
+ Number.parseInt(b.tip || 0) +
+ Number.parseInt(b.fee || 0) +
+ Number.parseInt(b.ourfee || 0)),
+ 0,
+ );
+
+ const remaining = Math.max(0, limit - spent);
+ if (amount > remaining) {
+ return {
+ budgetError: error({
+ code: "QUOTA_EXCEEDED",
+ message: `Budget exceeded: ${spent + amount} of ${limit}`,
+ }),
+ remaining,
+ };
+ }
+
+ return { budgetError: null, remaining };
};
const handle = (method, params, ev, app, user) =>
({
async pay_invoice() {
- const { invoice: pr, metadata } = params;
+ const { metadata, amount: reqAmountMsat } = params;
+ // Lowercase so getInvoice() finds our own invoices below (see create()
+ // in routes/payments.ts).
+ const pr =
+ typeof params.invoice === "string"
+ ? params.invoice.replace(/\s/g, "").toLowerCase()
+ : params.invoice;
const { amount_msat, payee } = await ln.decode(pr);
const { id } = await ln.getinfo();
- const amount = Math.round(amount_msat / 1000);
- const { max_amount, max_fee, budget_renewal, pubkey, created } = app;
-
- const periods = {
- daily: 60 * 60 * 24 * 1000,
- weekly: 60 * 60 * 24 * 7 * 1000,
- monthly: 60 * 60 * 24 * 30 * 1000,
- yearly: 60 * 60 * 24 * 365 * 1000,
- never: 60 * 60 * 24 * 365 * 10 * 1000,
- };
-
- const pids = await db.lRange(`${pubkey}:payments`, 0, -1);
- let payments = await Promise.all(pids.map((pid) => g(`payment:${pid}`)));
- payments = payments.filter(
- (p) => p?.created > Date.now() - periods[budget_renewal],
- );
-
- const spent = payments.reduce(
- (a, b) =>
- a +
- (Math.abs(parseInt(b.amount || 0)) +
- parseInt(b.tip || 0) +
- parseInt(b.fee || 0) +
- parseInt(b.ourfee || 0)),
- 0,
- );
-
- if (!created) {
- warn(
- "old nwc token",
- pubkey,
- user?.username,
- spent,
- amount,
- max_amount,
- );
+ // For an amountless bolt11, the invoice carries no amount — NIP-47 lets the
+ // client specify it in the request's `amount` field (msat). Fall back to
+ // that so amountless invoices aren't rejected with "Invalid amount"
+ // (issue #83). Invoice amount takes precedence when present.
+ const amountMsat = amount_msat || reqAmountMsat;
+ if (!amountMsat) {
return error({
- code: "UNAUTHORIZED",
- message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`,
+ code: "OTHER",
+ message:
+ "Amountless invoice requires an amount; specify amount (msat) in the request",
});
}
+ const amount = Math.round(amountMsat / 1000);
+ const { max_fee, pubkey } = app;
- if (max_amount > 0 && spent + amount > max_amount) {
- warn(
- "budget exceeded",
- pubkey,
- user?.username,
- spent,
- amount,
- max_amount,
- );
- return error({
- code: "QUOTA_EXCEEDED",
- message: `Budget exceeded: ${spent + amount} of ${max_amount}`,
- });
- }
+ const { budgetError, remaining } = await checkBudget(app, amount);
+ if (budgetError) return payFail("pay_invoice", app, user, amount, budgetError);
if (payee === id) {
const invoice = await getInvoice(pr);
- const recipient = await g(`user:${invoice.uid}`);
+ const recipient = await g(`user:${invoice?.uid}`);
- if (recipient?.username !== "mint") {
+ // No coinos invoice record means the bolt11 is on our node but not
+ // ours to settle internally (a mint quote, say) — pay it over
+ // Lightning below like any other.
+ if (invoice) {
const { id: pid } = await sendInternal({
amount,
invoice,
recipient,
sender: user,
+ maxTotal: remaining,
});
const preimage = pid;
- if (pubkey !== user.pubkey) await db.lPush(`${pubkey}:payments`, pid);
+ await db.lPush(`${pubkey}:payments`, pid);
if (invoice.memo?.includes("9734")) {
const { invoices } = await ln.listinvoices({ invstring: pr });
@@ -193,32 +696,301 @@ const handle = (method, params, ev, app, user) =>
}
}
- const { id: pid } = await sendLightning({
- amount,
- fee: max_fee || Math.round(amount * 0.01),
- user,
- pr,
- memo: JSON.stringify(metadata)
+ try {
+ // 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,
+ pr,
+ memo: JSON.stringify(metadata),
+ maxTotal: remaining,
+ });
+
+ await db.lPush(`${pubkey}:payments`, p.id);
+
+ 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 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 payFail("pay_invoice", app, user, amount,
+ error({ code: "PAYMENT_FAILED", message: "Payment failed" }));
+ await sleep(2000);
+ }
+ return payFail("pay_invoice", app, user, amount,
+ error({ code: "PAYMENT_FAILED", message: "Payment still pending after 60s" }));
+ } catch (e) {
+ return payFail("pay_invoice", app, user, amount,
+ error({ code: "PAYMENT_FAILED", message: e.message }));
+ }
+
+ // 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",
});
+ },
- await db.lPush(`${pubkey}:payments`, pid);
+ // nwc#2 generalized payment: BIP-321 URI carrying a bolt11 invoice or a
+ // bolt12 offer/invoice. This is what Amethyst v1.13+ uses for bolt12 zaps —
+ // payer_note carries the nostr:nip177:<intent-id> binding and the client
+ // builds the kind 9736 receipt from the returned preimage/payer_proof.
+ async pay() {
+ const { payment, amount: reqAmountMsat, payer_note, metadata } = params;
+ const { pubkey } = app;
- for (let i = 0; i < 10; i++) {
- const { pays } = await ln.listpays(pr);
- const p = pays.find((p) => p.status === "complete");
- if (p) {
- const { preimage } = p;
- return result({ preimage });
+ const parsed = parseInstruction(payment);
+ if (parsed.error) return parsed;
+ const pr = parsed.instruction;
+
+ let decoded;
+ try {
+ decoded = await ln.decode(pr);
+ if (decoded.valid === false) throw new Error("invalid");
+ } catch (e) {
+ return error({
+ code: "BAD_REQUEST",
+ message: "Failed to decode payment instruction",
+ });
+ }
+
+ const { network } = await ln.getinfo();
+ if (!matchesNetwork(decoded, network)) {
+ return error({
+ code: "UNSUPPORTED_NETWORK",
+ message: "Payment instruction is for a different Bitcoin network",
+ });
+ }
+
+ const instruction_type = decoded.type.includes("bolt12")
+ ? "bolt12"
+ : "bolt11";
+
+ // payer_note rides in a bolt12 invoice request's invreq_payer_note;
+ // bolt11 has no payer-message field, and the spec requires delivering
+ // the note or rejecting before payment — never silently dropping it
+ if (payer_note && instruction_type !== "bolt12") {
+ return error({
+ code: "BAD_REQUEST",
+ message: "payer_note requires a bolt12 payment instruction",
+ });
+ }
+
+ const instructionAmountMsat =
+ decoded.type === "bolt12 offer"
+ ? decoded.offer_amount_msat
+ : instruction_type === "bolt12"
+ ? decoded.invoice_amount_msat
+ : decoded.amount_msat;
+
+ if (
+ reqAmountMsat !== undefined &&
+ reqAmountMsat !== null &&
+ (!Number.isFinite(Number(reqAmountMsat)) || Number(reqAmountMsat) <= 0)
+ ) {
+ return error({ code: "BAD_REQUEST", message: "Invalid amount" });
+ }
+ if (
+ reqAmountMsat &&
+ instructionAmountMsat &&
+ Number(reqAmountMsat) !== Number(instructionAmountMsat)
+ ) {
+ return error({
+ code: "BAD_REQUEST",
+ message: `Requested amount conflicts with the instruction amount (${instructionAmountMsat} msat)`,
+ });
+ }
+
+ const amountMsat = instructionAmountMsat || reqAmountMsat;
+ if (!amountMsat) {
+ return error({
+ code: "BAD_REQUEST",
+ message:
+ "Amountless payment instruction requires an amount (msat) in the request",
+ });
+ }
+ const amount = Math.round(amountMsat / 1000);
+
+ const { budgetError, remaining } = await checkBudget(app, amount);
+ if (budgetError) return budgetError;
+
+ const created_at = Math.floor(Date.now() / 1000);
+
+ // Recipient is a coinos user (their invoice or standing offer is ours):
+ // settle internally, same as pay_invoice does
+ const invoice = await getInvoice(pr);
+ if (invoice) {
+ const recipient = await g(`user:${invoice.uid}`);
+
+ // Internal settlement never builds a bolt12 invoice request, so the
+ // spec's deliver-or-reject rule for payer_note is met by handing the
+ // note to the recipient as the payment memo (bolt11 + payer_note was
+ // already rejected above)
+ const { id: pid } = await sendInternal({
+ amount,
+ invoice,
+ memo: payer_note,
+ recipient,
+ sender: user,
+ maxTotal: remaining,
+ });
+
+ await db.lPush(`${pubkey}:payments`, pid);
+
+ if (invoice.memo?.includes("9734")) {
+ const { invoices } = await ln.listinvoices({ invstring: pr });
+ const inv = invoices[0];
+ if (inv) {
+ inv.payment_preimage = pid;
+ inv.paid_at = created_at;
+ try {
+ await handleZap(inv, user.pubkey);
+ } catch (e) {
+ console.log("zap receipt failed", e);
+ }
+ }
+ }
+
+ // Internal settlement never touches CLN, so there's no real
+ // preimage or payer proof to hand back
+ return result({
+ transaction_id: pid,
+ state: "settled",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: 0,
+ created_at,
+ settled_at: Math.floor(Date.now() / 1000),
+ });
+ }
+
+ try {
+ const p = await sendLightning({
+ amount,
+ fee: app.max_fee || Math.round(amount * 0.01),
+ user,
+ pr,
+ memo: metadata ? JSON.stringify(metadata) : undefined,
+ payerNote: payer_note,
+ maxTotal: remaining,
+ });
+
+ await db.lPush(`${pubkey}:payments`, p.id);
+
+ const preimage = p.ref;
+ if (!preimage) {
+ return result({
+ transaction_id: p.id,
+ state: "pending",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: 0,
+ created_at,
+ });
}
- await sleep(2000);
+
+ return result({
+ transaction_id: p.id,
+ state: "settled",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: (p.fee || 0) * 1000,
+ payment_hash: bytesToHex(sha256(hexToBytes(preimage))),
+ preimage,
+ // p.hash is the invoice actually paid (for an offer, the bolt12
+ // invoice fetched from it)
+ payer_proof:
+ instruction_type === "bolt12"
+ ? await createPayerProof(p.hash, !!payer_note)
+ : undefined,
+ created_at,
+ settled_at: Math.floor(Date.now() / 1000),
+ });
+ } catch (e) {
+ return error({ code: "PAYMENT_FAILED", message: e.message });
}
+ },
+
+ // nwc#2 counterpart to pay: hand back a BIP-321 URI combining a fresh
+ // bolt11 invoice with the user's standing bolt12 offer
+ async receive() {
+ const { amount, description } = params;
+
+ try {
+ const invoice = await generate({
+ invoice: {
+ amount: amount ? Math.round(amount / 1000) : 0,
+ type: "lightning",
+ memo: description,
+ },
+ user,
+ });
- return error({ code: "INTERNAL", message: "Payment timed out" });
+ const qs = new URLSearchParams();
+ qs.set("lightning", invoice.hash);
+ try {
+ // The spec requires the description in EVERY instruction that
+ // supports one (offers do), and an amount shouldn't come back as an
+ // amountless lno — so an amounted or described receive mints a
+ // matching per-request offer; the reusable standing offer only fits
+ // the blank request. On failure (e.g. an identical offer already
+ // registered to another user) the lno is omitted rather than
+ // substituting one with the wrong description/amount.
+ const offer =
+ amount || description
+ ? await generate({
+ invoice: {
+ type: "bolt12",
+ amount: amount ? Math.round(amount / 1000) : 0,
+ memo: description,
+ },
+ user,
+ })
+ : await getUserOffer(user);
+ if (offer?.hash) qs.set("lno", offer.hash);
+ } catch (e) {
+ warn("failed to include offer in receive", e.message);
+ }
+
+ return result({
+ bip321: `bitcoin:?${qs.toString()}`,
+ transaction_id: invoice.id,
+ });
+ } catch (e) {
+ return error({ code: "BAD_REQUEST", message: e.message });
+ }
},
async pay_keysend() {
- const { amount: amount_msat, pubkey, tlv_records: extratlvs } = params;
+ const { amount: amount_msat, pubkey, tlv_records } = params;
const amount = Math.round(amount_msat / 1000);
+ const extratlvs = {};
+
+ // convert tlv_records to the extratlvs format
+ // tlv_records: [{ type: 1, value: "asdf" }]
+ // extratlvs: { "1": "asdf" }
+ if (tlv_records && tlv_records.length) {
+ for (const record of tlv_records) {
+ extratlvs[record.type.toString()] = record.value;
+ }
+ }
+
+ // Keysend spends the same custodial balance as pay_invoice, so it must be
+ // subject to the same budget — otherwise a budget-limited connection string
+ // is a full-drain credential. It also left no `${app.pubkey}:payments`
+ // entry, so subsequent budgeted calls under-counted the spend.
+ const { budgetError, remaining } = await checkBudget(app, amount);
+ if (budgetError) return budgetError;
try {
const { payment_hash } = await sendKeysend({
@@ -227,8 +999,16 @@ const handle = (method, params, ev, app, user) =>
pubkey,
user,
extratlvs,
+ fee: app.max_fee,
+ maxTotal: remaining,
});
+ // Record the debit against the app budget. debit() stored the payment id
+ // under the keysend label (= ev.id); resolve and push it so checkBudget
+ // counts this spend going forward.
+ const pid = await g(`payment:${ev.id}`);
+ if (pid) await db.lPush(`${app.pubkey}:payments`, pid);
+
for (let i = 0; i < 10; i++) {
const { pays } = await ln.listpays({ payment_hash });
const p = pays.find((p) => p.status === "complete");
@@ -246,7 +1026,7 @@ const handle = (method, params, ev, app, user) =>
},
async get_info() {
- const { alias, blockheight, color, id } = await ln.getinfo();
+ const { alias, blockheight, color, id, network } = await ln.getinfo();
return result({
alias,
@@ -254,7 +1034,8 @@ const handle = (method, params, ev, app, user) =>
block_height: blockheight,
color,
pubkey: id,
- network: "mainnet",
+ // CLN calls mainnet "bitcoin"; NIP-47 clients expect "mainnet"
+ network: network === "bitcoin" ? "mainnet" : network,
methods,
notifications: ["payment_received", "payment_sent"],
});
@@ -268,7 +1049,7 @@ const handle = (method, params, ev, app, user) =>
async make_invoice() {
const { amount, description, description_hash, expiry } = params;
- l("nwc make_invoice", user.username);
+ // l("nwc make_invoice", user.username);
const invoice = {
amount: Math.round(amount / 1000),
@@ -277,7 +1058,14 @@ const handle = (method, params, ev, app, user) =>
expiry,
};
- const { hash, created: created_at } = await generate({ invoice, user });
+ const { hash, created, paymentHash } = await generate({ invoice, user });
+
+ // generate() stores `created` in milliseconds (Date.now()), but NWC
+ // timestamps are Unix SECONDS (NIP-47). Returning ms here produced an
+ // expires_at ~1.78e12 that overflowed clients' date parsers — e.g.
+ // ptcpay's .NET DateTimeOffset (issue #84: "Valid values are between
+ // -62135596800 and 253402300799"). Convert to seconds.
+ const created_at = Math.floor(created / 1000);
return result({
type: "incoming",
@@ -287,6 +1075,8 @@ const handle = (method, params, ev, app, user) =>
amount,
created_at,
expires_at: created_at + expiry,
+ fees_paid: 0,
+ payment_hash: paymentHash,
metadata: {},
});
},
@@ -356,11 +1146,16 @@ const handle = (method, params, ev, app, user) =>
async list_transactions() {
const { from, until, limit = 10, offset = 0, type } = params;
- const payments = await db.lRange(`${user.id}:payments`, 0, -1);
+ const listKey = `${user.id}:payments`;
+ const main = (await db.lRange(listKey, 0, -1)) || [];
+ const archived = (await archive.lRange(listKey, 0, -1)) || [];
+ const payments = [...new Set([...main, ...archived])];
let transactions = [];
for (const pid of payments) {
- const p = await g(`payment:${pid}`);
+ const p = await gf(`payment:${pid}`);
+ if (!p) continue;
+ if (p.revertedDuplicate) continue;
const created_at = Math.floor(p.created / 1000);
if (created_at < from || created_at > until) continue;
if (p.amount < 0 && type === "incoming") continue;
@@ -385,7 +1180,7 @@ const handle = (method, params, ev, app, user) =>
description: p.memo,
preimage: p.ref,
payment_hash,
- amount: (Math.abs(p.amount) + p.tip) * 1000,
+ amount: (Math.abs(p.amount) + (p.tip || 0)) * 1000,
fees_paid: (p?.fee || 0) * 1000,
created_at,
expires_at: created_at + week,