aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/app.ts159
-rw-r--r--lib/auth.ts72
-rw-r--r--lib/changeid.ts121
-rw-r--r--lib/db.ts43
-rw-r--r--lib/ecash.ts200
-rw-r--r--lib/health.ts194
-rw-r--r--lib/image-worker.ts29
-rw-r--r--lib/image.ts50
-rw-r--r--lib/invoices.ts226
-rw-r--r--lib/lightning.ts383
-rw-r--r--lib/ln.ts244
-rw-r--r--lib/lnquote.ts303
-rw-r--r--lib/locations.ts3
-rw-r--r--lib/logging.ts40
-rw-r--r--lib/names.ts37
-rw-r--r--lib/nostr.ts43
-rw-r--r--lib/notes.ts1
-rw-r--r--lib/notifications.ts74
-rw-r--r--lib/nwc.ts1033
-rw-r--r--lib/payments.ts1101
-rw-r--r--lib/rates.ts53
-rw-r--r--lib/register.ts15
-rw-r--r--lib/reserved.ts31
-rw-r--r--lib/safe-fetch.ts165
-rw-r--r--lib/sockets.ts27
-rw-r--r--lib/spend-budget.ts39
-rw-r--r--lib/square.ts55
-rw-r--r--lib/types.ts1
-rw-r--r--lib/upload.ts59
-rw-r--r--lib/utils.ts13
-rw-r--r--lib/v3forward.ts133
-rw-r--r--lib/webhooks.ts8
-rw-r--r--lib/whitelist.ts5
33 files changed, 4386 insertions, 574 deletions
diff --git a/lib/app.ts b/lib/app.ts
index 1beedcd4..b2c3c1a7 100644
--- a/lib/app.ts
+++ b/lib/app.ts
@@ -1,61 +1,137 @@
import config from "$config";
-import fastify from "fastify";
+import cors from "@fastify/cors";
+import fastifyProxy from "@fastify/http-proxy";
import fastifyMultipart from "@fastify/multipart";
-import fastifyStatic from "@fastify/static";
import fastifyPassport from "@fastify/passport";
-import fastifySecureSession from "@fastify/secure-session";
-import fastifyProxy from "@fastify/http-proxy";
import fastifyRateLimit from "@fastify/rate-limit";
-import cors from "@fastify/cors";
+import fastifySecureSession from "@fastify/secure-session";
+import fastifyStatic from "@fastify/static";
+import fastify from "fastify";
import pino from "pino";
+import dns from "node:dns";
import * as path from "path";
import { jwtStrategy } from "$lib/auth";
const app = fastify({
logger: true,
- disableRequestLogging: true,
- maxParamLength: 500,
+ logController: new fastify.LogController({ disableRequestLogging: true }),
+ routerOptions: { maxParamLength: 500 },
});
const reqLogger = pino(pino.destination("req"));
-const resLogger = pino(pino.destination("res"));
+
+// Never log plaintext credentials. This MUST recurse: the previous version
+// only redacted top-level keys, but POST /register posts {user:{password}} —
+// one level down — so every registration wrote its plaintext password to the
+// request log, and `pin`/`newpin` were not on the list at all (POST /pin,
+// POST /user and POST /take all carry one). Anything nested in an array is
+// covered too. Depth is bounded so a pathological body can't spin here.
+const REDACT_FIELDS = new Set([
+ "password",
+ "newpassword",
+ "confirm",
+ "pin",
+ "newpin",
+ "secret",
+ "otpsecret",
+ "nsec",
+ "seed",
+ "privkey",
+ "mnemonic",
+]);
+const redactBody = (body: any, depth = 0): any => {
+ if (!body || typeof body !== "object" || depth > 6) return body;
+ if (Array.isArray(body)) return body.map((v) => redactBody(v, depth + 1));
+ const copy: any = { ...body };
+ for (const k of Object.keys(copy)) {
+ if (REDACT_FIELDS.has(k)) {
+ if (copy[k] !== undefined && copy[k] !== null && copy[k] !== "")
+ copy[k] = "[redacted]";
+ } else if (copy[k] && typeof copy[k] === "object") {
+ copy[k] = redactBody(copy[k], depth + 1);
+ }
+ }
+ return copy;
+};
+
+// Never persist session credentials to the request log. The Authorization
+// bearer token and the token cookie are full account credentials (JWTs that
+// don't expire) — logging them turns log-read access into account takeover.
+const REDACT_HEADERS = ["authorization", "cookie"];
+const redactHeaders = (headers: any) => {
+ if (!headers || typeof headers !== "object") return headers;
+ const copy: any = { ...headers };
+ for (const h of REDACT_HEADERS) if (h in copy) copy[h] = "[redacted]";
+ return copy;
+};
// app.addHook("onRequest", async (req) => {
// reqLogger.info({ url: req.raw.url, id: req.id });
// });
//
+// The client IP comes from cf-connecting-ip, which Cloudflare sets and lb/ui
+// pass through. Onion traffic skips Cloudflare: the tor container connects to
+// us directly, so whatever cf-connecting-ip / x-forwarded-for / rate-limit-by
+// the client sent would reach the rate limiter and IP bans untouched. Pin those
+// requests to the tor container's own address instead. Registered before the
+// rate limiter so its keyGenerator sees the rewritten header.
+const UNTRUSTED_PEERS = ["tor"];
+let untrusted = new Set<string>();
+const resolveUntrusted = async () => {
+ const next = new Set<string>();
+ for (const host of UNTRUSTED_PEERS) {
+ try {
+ for (const { address } of await dns.promises.lookup(host, { all: true }))
+ next.add(address);
+ } catch {}
+ }
+ if (next.size) untrusted = next;
+};
+await resolveUntrusted();
+setInterval(resolveUntrusted, 30000).unref();
+
+app.addHook("onRequest", async (req) => {
+ const peer = (req.socket.remoteAddress || "").replace(/^::ffff:/, "");
+ if (!untrusted.has(peer)) return;
+ req.headers["cf-connecting-ip"] = peer;
+ delete req.headers["x-forwarded-for"];
+ delete req.headers["rate-limit-by"];
+});
+
app.addHook("preHandler", async (req) => {
const url = req.raw.url;
- const ignore = ["/login","/ws","/me","/confirm","/public"];
- if (req.method === "GET" || ignore.some(path => url.startsWith(path))) return;
+ const ignore = [
+ "/ws",
+ "/me",
+ "/confirm",
+ "/public",
+ "/rates",
+ "/challenge",
+ "/rate",
+ "/lnurlp",
+ "/subscriptions",
+ "/accounts",
+ "/contacts",
+ ];
+ if (ignore.some((path) => url.startsWith(path))) return;
+ if (req.method === "GET" && url.startsWith("/users")) return;
+
+ const xff = req.headers["x-forwarded-for"];
+ const forwardedIp = Array.isArray(xff) ? xff[0] : xff?.split(",")[0]?.trim();
+ const ip = req.headers["cf-connecting-ip"] || forwardedIp || req.ip;
reqLogger.info({
method: req.method,
url,
- headers: req.headers,
- body: req.body,
- id: req.id
- });
-});
-
-app.addHook("onResponse", async (req, reply) => {
- const rawCookies = req.raw.headers.cookie || "";
-
- const cookies: any = rawCookies.split(";").reduce((acc, cookie) => {
- const [key, value] = cookie.split("=").map((s) => s.trim());
- if (key && value) acc[key] = value;
- return acc;
- }, {});
-
- resLogger.info({
+ ip,
+ headers: redactHeaders(req.headers),
+ query: req.query,
+ body: redactBody(req.body),
+ user: (req.user as any)?.username,
id: req.id,
- url: req.raw.url,
- statusCode: reply.raw.statusCode,
- durationMs: reply.elapsedTime,
- username: cookies.username,
});
});
@@ -63,7 +139,11 @@ app.register(fastifyRateLimit, {
allowList: (req) => req.raw.url?.includes("public"),
max: 2000,
timeWindow: 2000,
- keyGenerator: (req) => req.headers["cf-connecting-ip"] as string,
+ keyGenerator: (req) => {
+ const ip = (req.headers["cf-connecting-ip"] as string) || req.ip;
+ const ua = req.headers["user-agent"] || "unknown-ua";
+ return req.headers["rate-limit-by"] === "ua" ? ua : ip;
+ },
errorResponseBuilder: () => {
return {
statusCode: 429,
@@ -73,11 +153,28 @@ app.register(fastifyRateLimit, {
},
});
+app.register(fastifyRateLimit, {
+ allowList: (req) => {
+ const url = req.raw.url || "";
+ const matches = url.includes("/login") || url.includes("/send");
+ return !matches;
+ },
+ max: 10,
+ timeWindow: 10000,
+ keyGenerator: (req) => (req.headers["user-agent"] as string) || "unknown-ua",
+ errorResponseBuilder: () => ({
+ statusCode: 429,
+ error: "Too Many Requests",
+ message: "Rate limit exceeded",
+ }),
+});
+
app.register(fastifyProxy, {
upstream: "http://localhost:3120",
prefix: "/ws",
rewritePrefix: "/ws",
websocket: true,
+ disableRequestLogging: true,
});
app.register(fastifySecureSession, {
diff --git a/lib/auth.ts b/lib/auth.ts
index 29edcf61..107c44f4 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -1,20 +1,10 @@
-import fastifyPassport from "@fastify/passport";
-import { g } from "$lib/db";
-import { fail, getUser } from "$lib/utils";
import config from "$config";
+import { db } from "$lib/db";
+import { fail, getUser } from "$lib/utils";
+import { quiet, suppressed } from "$lib/logging";
+import fastifyPassport from "@fastify/passport";
import jwt from "passport-jwt";
-export const admin = {
- preValidation: fastifyPassport.authenticate(
- "jwt",
- { session: false },
- (req, res, err, user, info) => {
- if (!user.admin) return res.code(401).send("unauthorized");
- req.user = user;
- },
- ),
-};
-
export const optional = {
preValidation: fastifyPassport.authenticate(
"jwt",
@@ -36,6 +26,19 @@ export const requirePin = async ({ body, user }) => {
if (!user || (user.pin && user.pin !== body.pin)) fail("Invalid pin");
};
+// A caller-supplied `aid` decides which account's node wallet composes and
+// signs a withdrawal and whose balance gets debited, so it has to be an
+// account the caller actually owns. `${uid}:accounts` is the ownership list —
+// the same one GET /account/:id and payments.list already check — and a user's
+// own id is in it. Omitted `aid` means "my own account".
+export const requireAccount = async ({ aid, user }) => {
+ if (!user) fail("unauthorized");
+ if (!aid || aid === user.id) return user.id;
+ if ((await db.lPos(`${user.id}:accounts`, aid)) === null)
+ fail("unauthorized");
+ return aid;
+};
+
export const jwtStrategy = new jwt.Strategy(
{
jwtFromRequest: jwt.ExtractJwt.fromExtractors([
@@ -43,9 +46,46 @@ export const jwtStrategy = new jwt.Strategy(
(req) => (req.cookies ? req.cookies.token : null),
]),
secretOrKey: config.jwt,
+ passReqToCallback: true,
},
- async (payload, next) => {
- const user = await getUser(payload.id);
+ async (req, payload, next) => {
+ const { originalUrl: u, method: m } = req;
+
+ let { id } = payload;
+ const wl = { GET: ["/invoice", "/payments"], POST: ["/invoice"] };
+
+ if (id.endsWith("-ro") && wl[m].some((p) => u.startsWith(p)))
+ id = id.slice(0, -3);
+
+ const user = await getUser(id);
+
+ // Hard eviction: an account in the `evicted` set cannot authenticate AT ALL
+ // — every request, every endpoint — regardless of source IP/VPN. Unlike the
+ // `blacklist` freeze (which only blocks sends), this kills the value of a
+ // compromised/attacker JWT outright. Match on the immutable uid OR username
+ // so a rename can't shake it.
+ if (
+ user &&
+ ((await db.sIsMember("evicted", user.id)) ||
+ (await db.sIsMember("evicted", user.username?.toLowerCase?.().trim())))
+ ) {
+ // Distinctive, greppable line carrying the real source IP so the realtime
+ // auto-banner (scripts/atk-autoban.sh) can insta-ban it at Cloudflare.
+ // Throttled per account. An evicted JWT replayed from a rotating proxy
+ // pool otherwise emits one line per request and
+ // has atk-autoban.sh place a Cloudflare block on every residential IP it
+ // sees, which protects nothing. First hit logs with its IP; afterwards
+ // one summary per 10 minutes, worded so the auto-banner ignores it.
+ const n = quiet(`evicted:${user.id}`);
+ if (n !== null)
+ console.error(
+ n
+ ? `evicted-auth ${user.username} ${suppressed(n)}`
+ : `EVICTED_AUTH ${user.username} ${req.headers["cf-connecting-ip"] || req.ip}`,
+ );
+ return next(null, false);
+ }
+
next(null, user);
},
);
diff --git a/lib/changeid.ts b/lib/changeid.ts
new file mode 100644
index 00000000..1c6a6688
--- /dev/null
+++ b/lib/changeid.ts
@@ -0,0 +1,121 @@
+import { db, g, s, ga, sa, archive as arc } from "$lib/db";
+import { err, l } from "$lib/logging";
+import { randomUUID } from "crypto";
+
+export default async function changeid(un: string) {
+ un = un.toLowerCase();
+ const id = await g(`user:${un}`);
+ if (!id) throw new Error(`user ${un} not found`);
+
+ l(`SECURITY: rekeying ${un}, old id ${id}`);
+
+ const nid = randomUUID();
+ const user = await g(`user:${id}`);
+ if (!user) throw new Error(`user record ${id} not found`);
+
+ user.id = nid;
+ user.password = "reset";
+
+ if (user.pubkey) {
+ await s(user.pubkey, nid);
+ await sa(user.pubkey, nid);
+ }
+ await s(`user:${un}`, nid);
+ await sa(`user:${un}`, nid);
+ await s(`user:${nid}`, user);
+ await sa(`user:${nid}`, user);
+
+ const keys = [
+ `credit:lightning:${id}`,
+ `credit:liquid:${id}`,
+ `${id}:invoices`,
+ `credit:bitcoin:${id}`,
+ `pending:${id}`,
+ `${id}:lastlen`,
+ `${id}:contacts`,
+ `${id}:accounts`,
+ `${id}:payments`,
+ `account:${id}`,
+ `balance:${id}`,
+ `user:${id}`,
+ ];
+
+ async function migrateKeys(client, get, set, label) {
+ for (const k of keys) {
+ const nk = k.replace(id, nid);
+ try {
+ if (
+ k.includes("credit:") ||
+ k.includes("balance:") ||
+ k.includes("pending:")
+ ) {
+ const a = await get(k);
+ await client.incrBy(nk, parseInt(a) || 0);
+ await client.del(k);
+ } else if (k.includes(":invoices") || k.includes(":payments")) {
+ const arr = await client.lRange(k, 0, -1);
+ const narr = [];
+ for (const pid of arr) {
+ const ok = `${k.split(":")[1].slice(0, -1)}:${pid}`;
+ const o = await get(ok);
+ if (!o) continue;
+ o.id = pid;
+ o.uid = nid;
+ if (o.aid === id) o.aid = nid;
+ await set(ok, o);
+ narr.push(o);
+ }
+
+ await client.del(k);
+
+ for (const o of narr.sort((a, b) => b?.created - a?.created)) {
+ if (o) await client.rPush(nk, o.id);
+ }
+ } else if (k.includes("account:")) {
+ const acc = await get(k);
+ if (!acc) continue;
+ acc.id = nid;
+ await set(nk, acc);
+ await client.del(k);
+ } else if (k.includes("user:")) {
+ await client.del(k);
+ } else {
+ if (await client.exists(k)) await client.rename(k, nk);
+ }
+ } catch (e: any) {
+ err(`changeid [${label}]`, k, e.message);
+ }
+ }
+
+ await client.lRem(`${nid}:accounts`, 0, id);
+ await client.rPush(`${nid}:accounts`, nid);
+ }
+
+ await migrateKeys(db, g, s, "main");
+ await migrateKeys(arc, ga, sa, "arc");
+
+ const apps = await db.sMembers(`${id}:apps`);
+ for (const aid of apps) {
+ await db.del(`app:${aid}`);
+ }
+ await db.del(`${id}:apps`);
+
+ // Update uid/aid in invoice objects and their offer pointers
+ const invoiceIds = await db.lRange(`${nid}:invoices`, 0, -1);
+ for (const iid of invoiceIds) {
+ try {
+ const inv = await g(`invoice:${iid}`);
+ if (!inv || typeof inv === "string") continue;
+ let changed = false;
+ if (inv.uid === id) { inv.uid = nid; changed = true; }
+ if (inv.aid === id) { inv.aid = nid; changed = true; }
+ if (changed) {
+ await s(`invoice:${iid}`, inv);
+ l("changeid: updated invoice", iid);
+ }
+ } catch (e) {}
+ }
+
+ l(`SECURITY: rekeyed ${un} from ${id} to ${nid}`);
+ return nid;
+}
diff --git a/lib/db.ts b/lib/db.ts
index 56921adb..e03fb810 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -100,7 +100,7 @@ async function dbReconnect() {
async function archiveReconnect() {
try {
- await archive.connect();
+ if (!archive.isOpen) await archive.connect();
} catch (err) {
console.error("Failed to connect to Redis, retrying...", err);
setTimeout(archiveReconnect, 5000); // Retry after 5 seconds
@@ -130,9 +130,9 @@ export const g = async (k) => {
}
};
-export const s = (k, v) => {
+export const s = (k, v, ttl?: number) => {
if (k === "user:null" || k === "user:undefined") fail("null user");
- db.set(k, JSON.stringify(v));
+ db.set(k, JSON.stringify(v), ttl ? { EX: ttl } : undefined);
};
export const ga = async (k) => {
@@ -152,6 +152,43 @@ export const sa = (k, v) => {
archive.set(k, JSON.stringify(v));
};
+// Get with fallback to archive
+export const gf = async (k) => {
+ let v = await db.get(k);
+ if (v === null) v = await archive.get(k);
+ try {
+ return JSON.parse(v);
+ } catch (e) {
+ return v;
+ }
+};
+
+// lRange with fallback to archive for missing items
+export const lRangeWithArchive = async (k, start, end) => {
+ const items = await db.lRange(k, start, end);
+ return items || [];
+};
+
+// Get payment/invoice with archive fallback - for individual lookups
+export const getWithArchive = async (prefix, id) => {
+ let v = await db.get(`${prefix}:${id}`);
+ if (v === null) v = await archive.get(`${prefix}:${id}`);
+ if (v === null) return null;
+ try {
+ const parsed = JSON.parse(v);
+ // If it's a reference to another key, follow it
+ if (typeof parsed === "string") {
+ let ref = await db.get(`${prefix}:${parsed}`);
+ if (ref === null) ref = await archive.get(`${prefix}:${parsed}`);
+ if (ref === null) return null;
+ return JSON.parse(ref);
+ }
+ return parsed;
+ } catch (e) {
+ return v;
+ }
+};
+
const retries = {};
export const t = async (k, f) => {
try {
diff --git a/lib/ecash.ts b/lib/ecash.ts
index bcd2b7f4..599bd7b0 100644
--- a/lib/ecash.ts
+++ b/lib/ecash.ts
@@ -1,100 +1,172 @@
import config from "$config";
-import { g, s } from "$lib/db";
-import { lnb } from "$lib/ln";
-import { fail, wait } from "$lib/utils";
+import { g } from "$lib/db";
+import { generate } from "$lib/invoices";
+import { warn } from "$lib/logging";
+import { PaymentType } from "$lib/types";
+import { fail, getPayment, sleep, wait } from "$lib/utils";
import {
CashuMint,
CashuWallet,
- MintQuoteState,
PaymentRequest,
PaymentRequestTransportType,
getDecodedToken,
- getEncodedToken,
- getEncodedTokenV4,
} from "@cashu/cashu-ts";
-
const { URL } = process.env;
const m = new CashuMint(config.mintUrl);
-const w = new CashuWallet(m);
-const enc = (proofs) =>
- getEncodedToken({
- mint: config.mintUrl,
- proofs,
- });
+// Redeeming ecash is a plain NUT-05 melt: the proofs pay a Lightning invoice
+// that belongs to the redeeming user, and the settlement is credited by the
+// regular lightning listener like any other incoming payment. The mint's node
+// is our own, so the melt is a self-payment on cl — nothing is held in a house
+// wallet and no proofs are swapped or stored on our side.
+
+// cashu-ts 2.9.0 writes v1 keyset ids into V4 tokens in their 8-byte short form
+// and refuses to decode a short id unless it's handed the mint's keysets to
+// expand it against. config.mintUrl fronts every keyset we've ever issued, so
+// its list is enough to decode any token of ours.
+const KEYSETS_TTL = 10 * 60 * 1000;
+let cached: { keysets: any[]; at: number } | undefined;
+const keysets = async () => {
+ if (cached && Date.now() - cached.at < KEYSETS_TTL) return cached.keysets;
+ const { keysets } = await m.getKeySets();
+ // Never cache an empty list: a token can't be decoded or fee-checked
+ // against it, and the next call should try the mint again.
+ if (!keysets?.length) fail("Mint keysets unavailable");
+ cached = { keysets, at: Date.now() };
+ return keysets;
+};
+
+// A short keyset id that doesn't expand against our keysets belongs to some
+// other mint; say so instead of surfacing cashu-ts's mapping error.
+const decode = async (token) => {
+ try {
+ return getDecodedToken(token, await keysets());
+ } catch (e) {
+ if (/short keyset id/i.test(e.message))
+ fail("Unable to receive from other mints");
+ throw e;
+ }
+};
-const ext = async (mint) => {
- const issuer = new CashuMint(mint);
- const { pubkey: issuerPk } = await issuer.getInfo();
- const { pubkey: ourPk } = await m.getInfo();
- return issuerPk !== ourPk;
+// A token is ours when every proof is on one of our keysets. Keyset ids are
+// derived from the mint's public keys, so they identify the issuer; the token's
+// `mint` URL is just a label the sending wallet wrote in (tokens of ours turn
+// up with stray ports and paths on it) and is never fetched. A miss refreshes
+// the cached list once in case a keyset was rotated in since it was fetched.
+const ext = async (proofs) => {
+ const ours = (ks) => {
+ const ids = new Set(ks.map((k) => k.id));
+ return proofs.every((p) => ids.has(p.id));
+ };
+ if (ours(await keysets())) return false;
+ cached = undefined;
+ return !ours(await keysets());
};
+const sum = (proofs) => proofs.reduce((a, p) => a + p.amount, 0);
+
+// NUT-02 input fee: ceil of the summed per-proof ppk.
+const inputFee = (proofs, ks) =>
+ Math.ceil(
+ proofs.reduce(
+ (a, p) => a + (ks.find((k) => k.id === p.id)?.input_fee_ppk || 0),
+ 0,
+ ) / 1000,
+ );
+
+// nutshell's Lightning fee reserve: max(lightning_reserve_fee_min = 2 sat,
+// lightning_fee_percent = 1%), rounded up to whole sats. The actual routing fee
+// of a self-payment is zero, but without NUT-08 change outputs the mint keeps
+// the reserve — it's our mint, so nothing leaves the system.
+const reserve = (amount) => Math.max(2, Math.ceil(amount / 100));
+
export async function get(id) {
const token = await g(`cash:${id}`);
return token;
}
-export async function claim(token) {
- const { proofs: current } = getDecodedToken(await g("cash"));
- const { mint } = getDecodedToken(token);
-
- if (await ext(mint)) fail("Unable to receive from other mints");
+// Melt `token` into a Lightning invoice owned by `user`. With `invoice` (a
+// NUT-18 payment-request invoice of type ecash) the Lightning invoice takes
+// over that id, so whatever is waiting on it sees the payment land. Returns
+// the amount the user receives: the token's value less the mint's input fee
+// and Lightning fee reserve.
+export async function redeem({ token, user, invoice = undefined, memo = undefined }) {
+ const { proofs } = await decode(token);
+ if (!proofs?.length) fail("Token has no proofs");
+ if (await ext(proofs)) fail("Unable to receive from other mints");
- const rcvd = await w.receive(token);
+ // Cheap pre-check so a spent token fails before an invoice is created for
+ // it (the melt would reject it anyway, but not before leaving an unpaid
+ // invoice on the user's account).
+ const states = await new CashuWallet(m).checkProofsStates(proofs);
+ if (states.some((p) => p.state !== "UNSPENT")) fail("Token already spent");
- await s("cash", enc([...current, ...rcvd]));
- return rcvd.reduce((a, b) => a + b.amount, 0);
-}
+ const ks = await keysets();
+ const total = sum(proofs);
+ const fees = inputFee(proofs, ks);
+ let amount = total - fees - 2;
+ while (amount > 0 && amount + reserve(amount) + fees > total) amount--;
+ if (amount < 1) fail("Token is too small to redeem");
-export async function mint(amount) {
- const { keysets } = await m.getKeySets();
- const w = new CashuWallet(m, { keysets });
- const { proofs } = getDecodedToken(await g("cash"));
- const { send, keep } = await w.send(amount, proofs);
- const rcvd = await w.receive(enc(send));
- const change = enc(keep);
- await s("cash", change);
- return enc(rcvd);
-}
-
-export async function check(token) {
- const { mint, proofs } = getDecodedToken(token);
- const total = proofs.reduce((a, b) => a + b.amount, 0);
+ invoice = await generate({
+ invoice: {
+ ...(invoice ?? {}),
+ id: invoice?.id,
+ amount,
+ // The invoice amount is exactly what the melt can cover: no tip on top,
+ // and no fiat re-pricing of it.
+ tip: null,
+ fiat: undefined,
+ bolt11: undefined,
+ bolt12: undefined,
+ memo: memo ?? invoice?.memo,
+ type: PaymentType.lightning,
+ },
+ user,
+ });
- const external = await ext(mint);
+ const quote = await m.createMeltQuote({ unit: "sat", request: invoice.text });
+ if (quote.amount + quote.fee_reserve + fees > total)
+ fail("Mint fee reserve exceeds the token's value");
- let spent = 0;
- for (const [i, p] of (await w.checkProofsStates(proofs)).entries()) {
- if (p.state === "SPENT") spent += proofs[i].amount;
+ let r = await m.melt({ quote: quote.quote, inputs: proofs });
+ for (let i = 0; r.state === "PENDING" && i < 60; i++) {
+ await sleep(1000);
+ r = await m.checkMeltQuote(quote.quote);
}
+ if (r.state === "PENDING")
+ fail("Redemption is still settling; the funds will arrive once it does");
+ if (r.state !== "PAID") fail("Redemption failed");
- return { total, spent, mint, external };
-}
-
-export async function init(amount = 100000) {
+ // The lightning listener credits the settlement. Give it a moment so the
+ // caller's payment list already shows it, but the credit doesn't depend on
+ // this wait.
try {
- await new Promise((r) => setTimeout(r, 2000));
- const { quote, request } = await w.createMintQuote(amount);
- await lnb.pay(request);
+ await wait(() => getPayment(invoice.hash), 50, 200);
+ } catch {
+ warn("ecash redeemed but credit not yet visible", user.username, invoice.hash);
+ }
- await wait(async () => {
- const { state } = await w.checkMintQuote(quote);
- return state === MintQuoteState.PAID;
- });
+ return amount;
+}
- const proofs = await w.mintProofs(amount, quote);
+export async function check(token) {
+ const { mint, proofs } = await decode(token);
+ const total = sum(proofs);
- const cash = getEncodedTokenV4({
- mint: config.mintUrl,
- proofs,
- });
+ const external = await ext(proofs);
- await s("cash", cash);
- } catch (e) {
- console.log(e);
+ // Only ask our own mint about proof states.
+ let spent = 0;
+ if (!external) {
+ const w = new CashuWallet(m);
+ for (const [i, p] of (await w.checkProofsStates(proofs)).entries()) {
+ if (p.state === "SPENT") spent += proofs[i].amount;
+ }
}
+
+ return { total, spent, mint, external };
}
export function request(uuid, amount, memo) {
diff --git a/lib/health.ts b/lib/health.ts
new file mode 100644
index 00000000..2a876909
--- /dev/null
+++ b/lib/health.ts
@@ -0,0 +1,194 @@
+import ln, { LightningUnavailableError } from "$lib/ln";
+import { g } from "$lib/db";
+import { getLightningListenerStatus } from "$lib/lightning";
+import { err, l, warn } from "$lib/logging";
+
+const HEALTH_CHECK_INTERVAL = 2 * 60 * 1000; // 2 minutes
+const RPC_TIMEOUT = 30_000; // 30 seconds - fail fast if RPC hangs
+const MAX_CONSECUTIVE_FAILURES = 3;
+
+let consecutiveFailures = 0;
+let lastSuccessTime = Date.now();
+let lastFailureReason = "";
+let checkTimer: ReturnType<typeof setTimeout> | null = null;
+let listenerBacklogPayIndex: number | null = null;
+
+export function getHealthStatus() {
+ return {
+ consecutiveFailures,
+ lastSuccessTime,
+ lastFailureReason,
+ healthy: consecutiveFailures < MAX_CONSECUTIVE_FAILURES,
+ stalledCheck: checkTimer === null && consecutiveFailures > 0,
+ listenerBacklogPayIndex,
+ listener: getLightningListenerStatus(),
+ };
+}
+
+function withTimeout<T>(
+ promise: Promise<T>,
+ ms: number,
+ label: string,
+): Promise<T> {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error(`${label} timed out after ${ms}ms`)),
+ ms,
+ );
+ promise.then(
+ (v) => {
+ clearTimeout(timer);
+ resolve(v);
+ },
+ (e) => {
+ clearTimeout(timer);
+ reject(e);
+ },
+ );
+ });
+}
+
+async function checkLightningHealth(): Promise<boolean> {
+ const startTime = Date.now();
+
+ try {
+ // l("health check: testing lightning connection...");
+ const info = await withTimeout(ln.getinfo(), RPC_TIMEOUT, "getinfo");
+
+ if (!info?.id) {
+ throw new Error("getinfo returned invalid response (no node id)");
+ }
+
+ const testLabel = `health_check_${Date.now()}`;
+ const invoice = await withTimeout(
+ ln.invoice({
+ amount_msat: "1000msat",
+ label: testLabel,
+ description: "health check",
+ expiry: 60,
+ }),
+ RPC_TIMEOUT,
+ "invoice",
+ );
+
+ if (!invoice?.bolt11) {
+ throw new Error("invoice creation returned invalid response");
+ }
+
+ try {
+ await withTimeout(
+ ln.delinvoice({ label: testLabel, status: "unpaid" }),
+ RPC_TIMEOUT,
+ "delinvoice",
+ );
+ } catch (e) {
+ // Ignore cleanup errors
+ }
+
+ // A responsive CLN node is not sufficient: the dedicated receive listener
+ // can be wedged while getinfo/invoice still work. Probe one index beyond the
+ // durable cursor. Code 904 means no backlog and is the healthy outcome.
+ const payIndex = Number((await g("pay_index")) || 0);
+ listenerBacklogPayIndex = null;
+ try {
+ const waiting = await withTimeout(
+ ln.waitanyinvoice(payIndex, 1),
+ 15_000,
+ "listener backlog probe",
+ );
+ if (Number(waiting?.pay_index) > payIndex) {
+ // A paid invoice exists beyond the durable cursor. That alone is NOT a
+ // wedged listener — it is almost always the listener mid-processing this
+ // very payment, before it has advanced the cursor (normal sub-second
+ // latency). Only a listener that is genuinely STUCK fails to advance.
+ // Grace-check: give it a moment, then re-read the cursor. If it moved,
+ // the listener is alive and this was transient. Only fail if it did not.
+ const next = Number(waiting.pay_index);
+ await new Promise((r) => setTimeout(r, 2500));
+ const after = Number((await g("pay_index")) || 0);
+ if (after <= payIndex) {
+ listenerBacklogPayIndex = next;
+ throw new Error(
+ `lightning listener backlog: stored=${payIndex} next=${next} (cursor did not advance in 2.5s)`,
+ );
+ }
+ }
+ } catch (e: any) {
+ if (Number(e?.code ?? e?.errno) !== 904) throw e;
+ }
+
+ const duration = Date.now() - startTime;
+ // l(`health check: passed in ${duration}ms, node: ${info.id.slice(0, 16)}...`);
+
+ return true;
+ } catch (e: any) {
+ const duration = Date.now() - startTime;
+ const errorCode = e?.code ?? e?.errno ?? "unknown";
+ const errorMsg = e?.message ?? String(e);
+
+ err(
+ `health check: FAILED after ${duration}ms`,
+ `code=${errorCode}`,
+ `error=${errorMsg}`,
+ );
+
+ if (e instanceof LightningUnavailableError) {
+ err("health check: lightning RPC socket unavailable");
+ }
+
+ lastFailureReason = `${errorCode}: ${errorMsg}`;
+ return false;
+ }
+}
+
+export async function runHealthCheck() {
+ checkTimer = null;
+
+ try {
+ const healthy = await checkLightningHealth();
+
+ if (healthy) {
+ if (consecutiveFailures > 0) {
+ l(
+ `health check: recovered after ${consecutiveFailures} consecutive failures`,
+ );
+ }
+ consecutiveFailures = 0;
+ lastSuccessTime = Date.now();
+ lastFailureReason = "";
+ } else {
+ consecutiveFailures++;
+ warn(
+ `health check: failure ${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}`,
+ );
+
+ if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
+ err(
+ `health check: ${MAX_CONSECUTIVE_FAILURES} consecutive failures, ` +
+ `last success was ${Math.round((Date.now() - lastSuccessTime) / 1000)}s ago`,
+ );
+ err(`health check: last failure reason: ${lastFailureReason}`);
+ err("health check: exiting process to trigger container restart");
+
+ setTimeout(() => {
+ process.exit(1);
+ }, 1000);
+ return;
+ }
+ }
+ } catch (e: any) {
+ err("health check: unexpected error during health check", e?.message);
+ consecutiveFailures++;
+ }
+
+ // Schedule next check
+ checkTimer = setTimeout(runHealthCheck, HEALTH_CHECK_INTERVAL);
+}
+
+export function startHealthCheck() {
+ // l(
+ // `health check: starting (interval=${HEALTH_CHECK_INTERVAL / 1000}s, timeout=${RPC_TIMEOUT / 1000}s, max_failures=${MAX_CONSECUTIVE_FAILURES})`,
+ // );
+ // Run first check after a delay to let the app initialize
+ checkTimer = setTimeout(runHealthCheck, 30000);
+}
diff --git a/lib/image-worker.ts b/lib/image-worker.ts
new file mode 100644
index 00000000..d45927d1
--- /dev/null
+++ b/lib/image-worker.ts
@@ -0,0 +1,29 @@
+// Image conversion runs in THIS separate process, spawned per upload by
+// lib/image.ts. It is the only place sharp/libvips is ever loaded. Reads the
+// raw upload on stdin, writes the resized webp to stdout, exits 0. Any
+// decoder bug (libheif/libwebp/libpng heap corruption, SIGSEGV, SIGILL) kills
+// this process alone — the app keeps serving. 2026-09-26: a stream of corrupt
+// HEIF uploads crash-looped the whole server when this ran in-process.
+const sharp = require("sharp");
+
+const MAX_PIXELS = 80_000_000;
+
+const width = Number(process.argv[2]);
+if (!Number.isInteger(width) || width <= 0 || width > 4096) {
+ console.error("bad width");
+ process.exit(2);
+}
+
+try {
+ const input = Buffer.from(await new Response(Bun.stdin.stream()).arrayBuffer());
+ const out = await sharp(input, { failOnError: true, limitInputPixels: MAX_PIXELS })
+ .rotate()
+ .resize(width)
+ .webp()
+ .toBuffer();
+ await Bun.write(Bun.stdout, out);
+ process.exit(0);
+} catch (e) {
+ console.error(e?.message || String(e));
+ process.exit(2);
+}
diff --git a/lib/image.ts b/lib/image.ts
new file mode 100644
index 00000000..80bc65c3
--- /dev/null
+++ b/lib/image.ts
@@ -0,0 +1,50 @@
+import { warn } from "$lib/logging";
+
+// Decode + resize an image in a throwaway child process (lib/image-worker.ts)
+// so a native decoder crash cannot take the app down. Resolves with the webp
+// bytes; rejects with a short message on any failure (bad image, crash,
+// timeout). Never import sharp in the main process — that is the whole point.
+const WORKER = process.env.IMAGE_WORKER || "/home/bun/app/lib/image-worker.ts";
+const TIMEOUT_MS = 20_000;
+const MAX_OUTPUT = 20_000_000;
+
+export const convert = async (input: Buffer, width: number): Promise<Buffer> => {
+ const proc = Bun.spawn([process.execPath, WORKER, String(width)], {
+ stdin: new Blob([input]),
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ proc.kill("SIGKILL");
+ }, TIMEOUT_MS);
+
+ try {
+ const [out, errText, code] = await Promise.all([
+ new Response(proc.stdout).arrayBuffer(),
+ new Response(proc.stderr).text(),
+ proc.exited,
+ ]);
+
+ if (timedOut) throw new Error("image conversion timed out");
+
+ if (code !== 0 || proc.signalCode) {
+ // Exit 2 is the worker's own "sharp rejected this file"; anything else
+ // (signal, Bun panic banner, exit 1) is the decoder dying — log loudly,
+ // this is exactly the event the isolation exists for.
+ const reason = errText.trim().split("\n").filter(Boolean).slice(-1)[0] || "";
+ if (code !== 2)
+ warn(`IMAGE_WORKER_CRASH code=${code} signal=${proc.signalCode} ${reason.slice(0, 200)}`);
+ throw new Error(code === 2 && reason ? reason.slice(0, 200) : "image conversion failed");
+ }
+
+ if (!out.byteLength || out.byteLength > MAX_OUTPUT)
+ throw new Error("image conversion produced no output");
+
+ return Buffer.from(out);
+ } finally {
+ clearTimeout(timer);
+ }
+};
diff --git a/lib/invoices.ts b/lib/invoices.ts
index d624ac6b..0028dc91 100644
--- a/lib/invoices.ts
+++ b/lib/invoices.ts
@@ -2,6 +2,7 @@ import config from "$config";
import { db, g, s } from "$lib/db";
import { request } from "$lib/ecash";
import ln from "$lib/ln";
+import { warn } from "$lib/logging";
import { emit } from "$lib/sockets";
import { SATS, bip21, fail, getInvoice, getUser } from "$lib/utils";
import rpc from "@coinos/rpc";
@@ -12,14 +13,28 @@ import { PaymentType } from "$lib/types";
const bc = rpc(config.bitcoin);
const lq = rpc(config.liquid);
+// Preimage queue entries are {preimage, price, fiat, currency} JSON, but
+// entries queued before prices existed are bare hex strings — treat those
+// as priceless
+export const parseEntry = (e) => {
+ try {
+ const parsed = JSON.parse(e);
+ if (parsed?.preimage)
+ return { price: null, fiat: null, currency: null, ...parsed };
+ } catch {}
+ return { preimage: e, price: null, fiat: null, currency: null };
+};
+
export const generate = async ({ invoice, user }) => {
let {
address_type,
bolt11,
+ bolt12,
aid,
currency,
expiry,
fiat,
+ id,
tip,
amount,
items = [],
@@ -29,19 +44,26 @@ export const generate = async ({ invoice, user }) => {
memo,
memoPrompt,
prompt,
+ single_use,
type = PaymentType.lightning,
+ usePreimage,
webhook,
secret,
} = invoice;
- amount = parseInt(amount || 0);
- tip = parseInt(tip) || null;
+ amount = Number.parseInt(amount || 0);
+ tip = tip == null ? null : Number.parseInt(tip);
if (user) user = await getUser(user.username);
if (!user) fail("user not provided");
if (typeof prompt === "undefined") prompt = user.prompt;
- let account = await g(`account:${aid}`);
+ // The invoice shows `user`'s name but credits `aid`, so a supplied aid must
+ // be one of that user's own accounts — otherwise anyone could put a
+ // merchant's name on an invoice that pays their own account.
+ if (aid && aid !== user.id && (await db.lPos(`${user.id}:accounts`, aid)) === null)
+ aid = undefined;
+ let account = aid ? await g(`account:${aid}`) : null;
if (!account) account = await g(`account:${user.id}`);
if (!account) fail("account not found");
aid = account.id;
@@ -51,63 +73,164 @@ export const generate = async ({ invoice, user }) => {
if (!rate) rate = rates[currency];
if (fiat) amount = Math.round((SATS * fiat) / rate);
if (amount < 0) fail("invalid amount");
+ // On-chain deposits below 300 sats are silently dropped by the dust filter in
+ // the /confirm credit path (routes/payments.ts), so reject creating a bitcoin/
+ // liquid invoice for a specific sub-300-sat amount rather than letting a
+ // deposit vanish. amount = 0 (any-amount address) is unaffected.
+ if (
+ (type === PaymentType.bitcoin || type === PaymentType.liquid) &&
+ amount > 0 &&
+ amount < 300
+ )
+ fail(
+ "On-chain deposits must be at least 300 sats. Use a Lightning invoice for smaller amounts.",
+ );
if (tip < 0) fail("invalid tip");
if (rate < 0) fail("invalid rate");
if (memo && memo.length > 5000) fail("memo too long");
- const id = v4();
+ if (!id) id = v4();
+ else {
+ // A caller-supplied id re-uses an existing record (tip/webhook update,
+ // repeated bolt12 offer create). It must be the caller's own: the write
+ // below replaces `invoice:<id>` wholesale with uid = this user, and the
+ // old bolt11 pointer keeps resolving to the same id, so overwriting
+ // another user's invoice re-routes whatever their payer sends into this
+ // account. Same error as a missing invoice so ids can't be probed for
+ // existence.
+ const existing = await getInvoice(id);
+ if (existing && existing.uid !== user.id) fail("invoice not found");
+ }
let hash;
let text;
- let path;
-
- if (account.seed) {
- type = "bitcoin";
- const node = rpc({ ...config[type], wallet: aid });
- hash = await node.getNewAddress({ address_type });
- text = bip21(hash, invoice);
+ let paymentHash;
- ({ hdkeypath: path } = await node.getAddressInfo(hash));
- } else if (type === PaymentType.lightning) {
+ if (type === PaymentType.lightning) {
+ // SECURITY: never accept a caller-supplied bolt11. It let anyone register any
+ // node-payable invoice (incl. a nutshell mint quote) as their own coinos
+ // deposit — which both double-credited (coinos balance + mint ecash) and let
+ // an attacker hijack another user's incoming payment (there was no ownership
+ // guard, so it overwrote invoice:<bolt11>). Always mint the invoice here.
+ if (bolt11) fail("Cannot provide your own Lightning invoice");
let r;
- if (bolt11) {
- const { id: nodeid } = await ln.getinfo();
- r = await ln.decode(bolt11);
- if (r.payee !== nodeid) fail("invalid invoice");
- amount = Math.round(r.amount_msat / 1000);
- r.bolt11 = bolt11;
- } else {
+ {
expiry ||= 60 * 60 * 24 * 30;
- r = await ln.invoice({
- amount_msat: amount ? `${amount + tip}sat` : "any",
- label: id,
+
+ const args = {
+ label: `${id} ${user.username} ${Date.now()}`,
description: memo || "",
expiry,
deschashonly: true,
cltv: 19,
- });
+ };
+
+ if (usePreimage) {
+ // Consume the merchant's pre-loaded preimages (FIFO) so the invoice's
+ // payment hash is sha256(preimage) and paying it reveals a secret the
+ // merchant chose. Never fall back to a random preimage — a buyer would
+ // be paying for a secret that unlocks nothing.
+ const queue = `${user.id}:preimages`;
+ let entry;
+ while ((entry = await db.lPop(queue))) {
+ const {
+ preimage,
+ price,
+ fiat: entryFiat,
+ currency: entryCurrency,
+ } = parseEntry(entry);
+ // The invoice creator is unauthenticated, so a merchant-set price
+ // must win over the amount in the request — otherwise a buyer
+ // could name their own price for the secret. Fiat prices convert
+ // at the current rate, and the invoice takes on the merchant's
+ // currency so receipts show the fiat price as set.
+ if (price) amount = price;
+ else if (entryFiat) {
+ if (!rates[entryCurrency]) {
+ await db.lPush(queue, entry);
+ fail(`no rate available for ${entryCurrency}`);
+ }
+ currency = entryCurrency;
+ rate = rates[currency];
+ amount = Math.round((SATS * entryFiat) / rate);
+ }
+ try {
+ r = await ln.invoice({
+ ...args,
+ amount_msat: amount ? `${amount + tip}sat` : "any",
+ preimage,
+ });
+ // Keep the preimage recoverable for internal settlements, where
+ // cln never pays the invoice and so never reveals it — credit()
+ // picks this up as the payment ref
+ await db.set(`preimage:${r.payment_hash}`, preimage, {
+ EX: expiry,
+ });
+ break;
+ } catch (e) {
+ // cln rejects a preimage whose payment hash it has already seen —
+ // that one is permanently unusable, so drop it and try the next
+ if (/preimage/i.test(e.message)) {
+ warn("discarding unusable preimage", user.username, e.message);
+ continue;
+ }
+ await db.lPush(queue, entry);
+ throw e;
+ }
+ }
+ if (!r) fail("no preimages available");
+ } else {
+ r = await ln.invoice({
+ ...args,
+ amount_msat: amount ? `${amount + tip}sat` : "any",
+ });
+ }
}
hash = r.bolt11;
text = r.bolt11;
+ paymentHash = r.payment_hash;
} else if (type === PaymentType.bolt12) {
- const r = await ln.offer({
- amount: amount ? `${amount + tip}sat` : "any",
- label: id,
- description: memo || id,
- });
+ // SECURITY: same as bolt11 — never accept a caller-supplied bolt12. Always
+ // mint the offer server-side.
+ if (bolt12) fail("Cannot provide your own bolt12 invoice");
+ let r;
+ {
+ // The offer itself has to carry the expiry: CLN keeps answering
+ // invoice requests for an offer until its absolute_expiry, so an
+ // expiry only stored on our invoice record was never enforced.
+ expiry = Number.parseInt(expiry) || undefined;
+ r = await ln.offer({
+ amount: amount ? `${amount + tip}sat` : "any",
+ label: `${id} ${user.username} ${new Date()}`,
+ description: memo || id,
+ absolute_expiry: expiry
+ ? Math.floor(Date.now() / 1000) + expiry
+ : undefined,
+ single_use: !!single_use || undefined,
+ });
- if (await getInvoice(r.offer_id)) fail("Duplicate offer exists");
+ // CLN returns the same offer_id for identical offer contents, so a
+ // re-create of an offer we already track must not register a second
+ // invoice for it (duplicate :invoices entries — see kaliyi's OCEAN
+ // offer). If it's the same user's offer, hand back the existing record.
+ const existing = await getInvoice(r.offer_id);
+ if (existing) {
+ if (existing.uid === user.id) return existing;
+ fail("Duplicate offer exists");
+ }
+ await s(`invoice:${r.offer_id}`, id);
+ }
hash = r.bolt12;
text = r.bolt12;
-
- await s(`invoice:${r.offer_id}`, id);
} else if (type === PaymentType.bitcoin) {
address_type ||= "bech32";
hash = await bc.getNewAddress({ address_type });
text = bip21(hash, invoice);
} else if (type === PaymentType.liquid) {
+ if (await g("liquid:deposits:disabled"))
+ fail("Liquid deposits are temporarily disabled");
address_type ||= "blech32";
hash = await lq.getNewAddress({ address_type });
text = bip21(hash, invoice);
@@ -132,12 +255,12 @@ export const generate = async ({ invoice, user }) => {
items,
memo,
rate,
+ paymentHash,
pending: 0,
received: 0,
request_id,
memoPrompt,
own,
- path,
prompt,
secret,
text,
@@ -154,7 +277,13 @@ export const generate = async ({ invoice, user }) => {
await s(`invoice:${hash}`, id);
await s(`invoice:${id}`, invoice);
- await db.lPush(`${aid}:invoices`, id);
+ // generate() is also called on UPDATE (re-using an existing invoice id, e.g.
+ // a tip/webhook change or a repeated bolt12 offer create), which must NOT
+ // re-append the id to the list. Only add it the first time — otherwise the
+ // user's :invoices accumulates duplicate entries and the same offer/invoice
+ // renders multiple times (see kaliyi's OCEAN offer, listed 2-6x).
+ if ((await db.lPos(`${aid}:invoices`, id)) === null)
+ await db.lPush(`${aid}:invoices`, id);
if (request_id) {
const request = await g(`request:${request_id}`);
@@ -169,3 +298,32 @@ export const generate = async ({ invoice, user }) => {
return invoice;
};
+
+// Each user gets one standing amountless bolt12 offer (lno1...) they can hand
+// out as a reusable payment code — e.g. published in a kind 10058 list for
+// NIP-177 bolt12 zaps. Created lazily on first request, then reused; incoming
+// payments credit through the regular bolt12 receive path via local_offer_id.
+export const getUserOffer = async (user) => {
+ const key = `${user.id}:offer`;
+ const existingId = await g(key);
+ if (existingId) {
+ const existing = await g(`invoice:${existingId}`);
+ if (existing?.hash) return existing;
+ }
+
+ let domain;
+ try {
+ domain = new globalThis.URL(process.env.URL).host;
+ } catch (e) {}
+
+ const invoice = await generate({
+ invoice: {
+ type: PaymentType.bolt12,
+ memo: domain ? `${user.username}@${domain}` : user.username,
+ },
+ user,
+ });
+
+ await s(key, invoice.id);
+ return invoice;
+};
diff --git a/lib/lightning.ts b/lib/lightning.ts
index 8f5f44bc..6c37ddf1 100644
--- a/lib/lightning.ts
+++ b/lib/lightning.ts
@@ -1,73 +1,343 @@
-import { db, g, s } from "$lib/db";
-import ln from "$lib/ln";
-import { err, warn } from "$lib/logging";
+import { db, g } from "$lib/db";
+import ln, { lnListen, LightningUnavailableError } from "$lib/ln";
+import { err, l, warn } from "$lib/logging";
import { handleZap } from "$lib/nostr";
import { credit } from "$lib/payments";
import { PaymentType } from "$lib/types";
import { getInvoice, getPayment, getUser } from "$lib/utils";
-export async function listenForLightning() {
- const inv = await ln.waitanyinvoice((await g("pay_index")) || 0);
- const {
- local_offer_id,
- bolt11,
- bolt12,
- description,
- pay_index,
- payment_hash,
- amount_received_msat,
- payment_preimage: preimage,
- } = inv;
+const LISTENER_RETRY_DELAY = 5000; // 5 seconds
+const MAX_LISTENER_RETRIES = 10;
+// Never leave waitanyinvoice as an unbounded client-side promise. CLN's
+// server-side timeout returns code 904 during an ordinary quiet period; that is
+// treated as a clean re-arm below. A dead/half-open socket can therefore hold
+// the listener for at most the watchdog window instead of indefinitely.
+const LISTENER_WAIT_TIMEOUT_SECONDS = 30;
+const LISTENER_STALL_MS = 90 * 1000;
+const PROCESSING_STALL_MS = 5 * 60 * 1000;
+let listenerRetries = 0;
+let listenerActive = false;
+let lastPayTime = Date.now();
+let listenerPhase: "idle" | "waiting" | "processing" = "idle";
+let phaseStartedAt = Date.now();
+// Incremented whenever the watchdog force-recycles a stalled listener. The
+// blocked invocation captures the epoch at entry; if it later unblocks (the
+// reset makes its waitanyinvoice reject) it sees the epoch has moved and bows
+// out instead of re-arming, so we never run two listeners concurrently.
+let listenerEpoch = 0;
- await s("pay_index", pay_index);
- setTimeout(listenForLightning);
+// Recover the invoice listener the instant its long-poll socket dies (e.g. a cl
+// restart) instead of waiting up to LISTENER_STALL_MS for the stall watchdog.
+// waitanyinvoice is a NO_TIMEOUT long-poll, so a dead socket leaves it blocked
+// forever with no error — this onDrop signal is the only prompt trigger. Bump the
+// epoch so the (possibly forever-blocked) old invocation bows out, mark inactive,
+// and re-arm. If cl is still down, ensure()'s backoff + the retry loop below wait
+// it out. Debounced so a burst of socket errors recycles once.
+let recycleArmed = false;
+(lnListen as any).onDrop?.(() => {
+ if (!listenerActive || recycleArmed) return;
+ recycleArmed = true;
+ listenerEpoch++;
+ listenerActive = false;
+ listenerPhase = "idle";
+ warn("lightning listener: listen socket dropped — recycling immediately");
+ setTimeout(() => {
+ recycleArmed = false;
+ listenForLightning();
+ }, 100);
+});
- const received = Math.round(amount_received_msat / 1000);
+export async function listenForLightning() {
+ if (listenerActive) {
+ warn("lightning listener: already active, skipping duplicate call");
+ return;
+ }
+
+ listenerActive = true;
+ listenerPhase = "waiting";
+ phaseStartedAt = Date.now();
+ const myEpoch = listenerEpoch;
try {
- if (!preimage) return;
+ const payIndex = (await g("pay_index")) || 0;
+ // l(`lightning listener: waiting for invoice (pay_index=${payIndex})`);
- const invoice = await getInvoice(bolt11 ?? local_offer_id);
- if (!invoice) return warn("received lightning with no invoice", bolt11);
+ const inv = await lnListen.waitanyinvoice(
+ payIndex,
+ LISTENER_WAIT_TIMEOUT_SECONDS,
+ );
+ const {
+ label,
+ local_offer_id,
+ bolt11,
+ bolt12,
+ description,
+ invreq_payer_note,
+ pay_index,
+ payment_hash,
+ amount_received_msat,
+ payment_preimage: preimage,
+ } = inv;
- const p = await getPayment(bolt11 || bolt12);
- if (p) return warn("already processed", bolt11 || bolt12);
+ // If the watchdog recycled the listener while this call was blocked, a new
+ // listener now owns the stream. Bail without advancing pay_index or
+ // processing — the new listener's waitanyinvoice(payIndex) will return this
+ // same invoice and handle it, so nothing is lost or double-credited.
+ if (myEpoch !== listenerEpoch) return;
- if (invoice?.memo) {
- try {
- if (JSON.parse(description).kind === 9734) {
- const { pubkey } = await getUser(invoice.uid);
- handleZap(inv, pubkey);
+ // We are no longer blocked in waitanyinvoice. Track processing separately
+ // so the watchdog never resets a healthy socket merely because a ledger
+ // write or downstream notification is slow.
+ listenerPhase = "processing";
+ phaseStartedAt = Date.now();
+ const received = Math.round(amount_received_msat / 1000);
+
+ // Process the settlement before advancing pay_index. Advancing first loses
+ // the credit permanently if Valkey is loading/restarting between the cursor
+ // write and credit() (the 2026-08-15 gap at pay indexes 1057448-1057518).
+ // The inner function keeps intentional skips on the common commit path;
+ // genuine processing errors escape to the retry handler with the old cursor.
+ await (async () => {
+ if (!preimage) return;
+
+ // The mint (nutshell) shares this cl node, so waitanyinvoice also fires for
+ // mint-quote invoices. Those are owned by the mint (it issues ecash for
+ // them) and must NOT credit a coinos balance — otherwise a single payment
+ // is credited twice (coinos balance + ecash = double-mint exploit). The
+ // mint labels its invoices "lbl<random>"; coinos labels are
+ // "<uuid> <username> <ts>". Never credit a mint-owned invoice.
+ if (typeof label === "string" && label.startsWith("lbl"))
+ return warn("skipping mint-owned invoice (not a coinos deposit)", label, bolt11);
+
+ const invoice = await getInvoice(bolt11 ?? local_offer_id ?? bolt12);
+ if (!invoice) return warn("received lightning with no invoice", bolt11);
+
+ // A deleted account's invoice or bolt12 offer can still be paid (OCEAN
+ // payouts to a deleted user's offer, 2026-09-05). Nothing can be
+ // credited, and the post-credit persistence check below would throw and
+ // wedge the listener on this settlement forever. Record and skip.
+ if (!(await getUser(invoice.uid))) {
+ await db.sAdd("missing", preimage);
+ return warn("received lightning for missing user", invoice.uid, bolt11 ?? bolt12, received);
+ }
+
+ const p = await getPayment(bolt11 || bolt12);
+ if (p) return warn("already processed", bolt11 || bolt12);
+
+ if (invoice?.memo) {
+ // A non-JSON description just means this isn't a zap (the common case) —
+ // skip silently. Only a real kind-9734 zap request that fails to process
+ // is worth a warning. (The old `!includes("Unexpected")` guard was V8's
+ // parse-error wording; Bun's is "Unable to parse JSON string", so every
+ // non-zap memo'd invoice slipped through and spammed this warning.)
+ let zapreq;
+ try { zapreq = JSON.parse(description); } catch { zapreq = null; }
+ if (zapreq?.kind === 9734) {
+ try {
+ const { pubkey } = await getUser(invoice.uid);
+ handleZap(inv, pubkey);
+ } catch (e) {
+ warn("failed to handle zap", e.message);
+ }
}
- } catch (e) {
- if (!e.message.includes("Unexpected"))
- warn("failed to handle zap", e.message);
}
+
+ const paymentRequest = bolt11 || bolt12;
+ await credit({
+ hash: paymentRequest,
+ amount: received,
+ // A bolt12 payer note (e.g. nostr:nip177:<zap-intent-id>) identifies
+ // this specific payment; the offer's own memo is just its static label
+ memo: invreq_payer_note || invoice.memo,
+ ref: preimage,
+ type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning,
+ payment_hash,
+ });
+
+ // credit() may decline a duplicate idempotency claim. Only advance the
+ // listener once the durable payment pointer proves the ledger write won —
+ // unless the preimage was already credited by an earlier settlement (a
+ // reused merchant preimage), which can never persist and would otherwise
+ // wedge the listener in a restart loop.
+ if (!(await getPayment(paymentRequest))) {
+ if (preimage && (await db.get(`credited:${preimage}`)))
+ return warn("preimage already credited, skipping", paymentRequest);
+ throw new Error(`lightning credit did not persist for ${paymentRequest}`);
+ }
+ })();
+
+ // db.set is awaited directly: s() is intentionally fire-and-forget and an
+ // `await s(...)` would not wait for Redis to persist the cursor.
+ await db.set("pay_index", JSON.stringify(pay_index));
+
+ // If the watchdog recycled this invocation while it was processing, the
+ // replacement listener owns scheduling. The credit is idempotent and the
+ // committed cursor lets that listener continue from the next settlement.
+ if (myEpoch !== listenerEpoch) return;
+
+ lastPayTime = Date.now();
+
+ // Reset retry counter only after both the ledger operation and cursor write
+ // have succeeded.
+ if (listenerRetries > 0) {
+ l(`lightning listener: recovered after ${listenerRetries} retries`);
}
+ listenerRetries = 0;
+ listenerActive = false;
+ listenerPhase = "idle";
- await credit({
- hash: bolt11 || bolt12,
- amount: received,
- memo: invoice.memo,
- ref: preimage,
- type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning,
- payment_hash,
- });
- } catch (e) {
- console.log(e);
- err("problem receiving lightning payment", e.message);
+ // Schedule the next listen only after this settlement is durable.
+ setTimeout(listenForLightning);
+ } catch (e: any) {
+ const failedPhase = listenerPhase;
+ listenerActive = false;
+ listenerPhase = "idle";
+
+ // If the watchdog already recycled us (its reset is what made this
+ // waitanyinvoice reject), a fresh listener is running — don't retry or
+ // re-arm here, or we'd run two listeners.
+ if (myEpoch !== listenerEpoch) return;
+
+ const errorCode = e?.code ?? e?.errno ?? "unknown";
+ const errorMsg = e?.message ?? String(e);
+
+ // CLN error 904 is the expected result of a server-side wait timeout with
+ // no newly paid invoice. It proves the socket is responsive; immediately
+ // re-arm without incrementing the failure counter or emitting an error.
+ if (
+ failedPhase === "waiting" &&
+ (Number(errorCode) === 904 || /timed out/i.test(errorMsg))
+ ) {
+ listenerRetries = 0;
+ setTimeout(listenForLightning);
+ return;
+ }
+
+ err(
+ `lightning listener: error handling invoice`,
+ `code=${errorCode}`,
+ `error=${errorMsg}`
+ );
+
+ // A cl restart makes ensure() throw LightningUnavailableError until the socket
+ // returns — expected, self-heals, and restarting the app can't fix a down cl.
+ // So don't count it toward the process-exit escalation; just keep retrying and
+ // resume within LISTENER_RETRY_DELAY of cl coming back. Only genuine listener
+ // errors climb toward a container restart.
+ if (e instanceof LightningUnavailableError) {
+ err("lightning listener: RPC socket unavailable");
+ warn(`lightning listener: cl unavailable, retrying in ${LISTENER_RETRY_DELAY / 1000}s`);
+ setTimeout(listenForLightning, LISTENER_RETRY_DELAY);
+ return;
+ }
+
+ listenerRetries++;
+
+ if (listenerRetries >= MAX_LISTENER_RETRIES) {
+ err(
+ `lightning listener: ${MAX_LISTENER_RETRIES} consecutive failures, ` +
+ `last error: ${errorMsg}`
+ );
+ err("lightning listener: exiting process to trigger container restart");
+
+ setTimeout(() => {
+ process.exit(1);
+ }, 1000);
+ return;
+ }
+
+ warn(
+ `lightning listener: retry ${listenerRetries}/${MAX_LISTENER_RETRIES} ` +
+ `in ${LISTENER_RETRY_DELAY / 1000}s`
+ );
+ setTimeout(listenForLightning, LISTENER_RETRY_DELAY);
}
}
+export async function ensureListenerAlive() {
+ // Case 1: listener fell over (threw, or never armed) — just restart it.
+ if (!listenerActive) {
+ warn("lightning listener: not active, restarting");
+ listenForLightning();
+ return;
+ }
+
+ // Processing and waiting are different failure modes. Resetting the listen
+ // socket cannot repair a slow credit and risks overlapping two processors.
+ // Surface it loudly for intervention, but leave the single owner intact.
+ const phaseAge = Date.now() - phaseStartedAt;
+ if (listenerPhase === "processing") {
+ if (phaseAge >= PROCESSING_STALL_MS) {
+ err(
+ `lightning listener: invoice processing stalled ${Math.round(
+ phaseAge / 1000,
+ )}s — not starting a concurrent processor`,
+ );
+ }
+ return;
+ }
+
+ // A normal quiet wait returns code 904 every 30 seconds and re-arms. Reaching
+ // this age means the dedicated socket itself is stuck. Confirm a real backlog
+ // over the main socket before recycling it.
+ if (listenerPhase !== "waiting" || phaseAge < LISTENER_STALL_MS) return;
+
+ // Probe for a backlog: ask cl (on the MAIN socket, not the wedged listen one)
+ // whether a paid invoice exists at or after the app's stored pay_index, using
+ // waitanyinvoice with a short server-side timeout so it returns fast either
+ // way. If it returns an invoice, the listener's socket is stuck while real
+ // payments are waiting -> genuine zombie, recycle. If it times out (no waiting
+ // invoice), the listener is simply idle and healthy -> leave it alone.
+ let backlog = false;
+ try {
+ const payIndex = (await g("pay_index")) || 0;
+ const probe = await ln.waitanyinvoice(payIndex, 2); // 2s server-side timeout
+ if (probe?.pay_index) backlog = true;
+ } catch (e: any) {
+ // CLN returns an error (code 904) on timeout with no waiting invoice — that
+ // means NO backlog (healthy). Any other error: stay conservative, don't
+ // recycle on ambiguous signals.
+ backlog = false;
+ }
+ if (!backlog) return; // healthy idle wait, not a zombie.
+
+ err(
+ `lightning listener: backlog detected while listener blocked ${Math.round(
+ phaseAge / 1000,
+ )}s — recycling listen socket`,
+ );
+ // Bump the epoch first so the wedged invocation bows out when its
+ // waitanyinvoice rejects, then drop the socket and re-arm a fresh listener.
+ listenerEpoch++;
+ try {
+ (lnListen as any).reset?.();
+ } catch (_) {}
+ listenerActive = false;
+ listenerPhase = "idle";
+ setTimeout(listenForLightning);
+}
+
+export function getLightningListenerStatus() {
+ return {
+ active: listenerActive,
+ phase: listenerPhase,
+ phaseAgeMs: Date.now() - phaseStartedAt,
+ lastPayTime,
+ retries: listenerRetries,
+ };
+}
+
export async function replay(index) {
- const inv = await ln.waitanyinvoice(index - 1);
+ const inv = await lnListen.waitanyinvoice(index - 1);
const {
local_offer_id,
bolt11,
bolt12,
description,
+ invreq_payer_note,
amount_received_msat,
payment_preimage: preimage,
+ paid_at,
} = inv;
const received = Math.round(amount_received_msat / 1000);
@@ -75,33 +345,40 @@ export async function replay(index) {
try {
if (!preimage) return;
- const invoice = await getInvoice(bolt11 ?? local_offer_id);
+ const invoice = await getInvoice(bolt11 ?? local_offer_id ?? bolt12);
if (!invoice) return warn("received lightning with no invoice", bolt11);
- const p = await getPayment(bolt11 || bolt12);
+ let p = await getPayment(bolt11 || bolt12);
if (p) return warn("already processed", bolt11 || bolt12);
if (invoice?.memo) {
- try {
- if (JSON.parse(description).kind === 9734) {
+ // Non-JSON description = not a zap (common case); skip silently. Only a
+ // real kind-9734 zap request that fails processing warrants a warning.
+ // (The old `!includes("Unexpected")` guard was V8 wording; Bun's parse
+ // error is "Unable to parse JSON string", so non-zaps spammed the warning.)
+ let zapreq;
+ try { zapreq = JSON.parse(description); } catch { zapreq = null; }
+ if (zapreq?.kind === 9734) {
+ try {
const { pubkey } = await getUser(invoice.uid);
handleZap(inv, pubkey);
- }
- } catch (e) {
- if (!e.message.includes("Unexpected"))
+ } catch (e) {
warn("failed to handle zap", e.message);
+ }
}
}
- await credit({
+ p = await credit({
hash: bolt11 || bolt12,
amount: received,
- memo: invoice.memo,
+ memo: invreq_payer_note || invoice.memo,
ref: preimage,
type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning,
+ created: paid_at ? paid_at * 1000 : undefined,
});
+
+ return p;
} catch (e) {
- console.log(e);
err("problem receiving lightning payment", e.message);
}
}
diff --git a/lib/ln.ts b/lib/ln.ts
index c4ab40fd..e9c4af6a 100644
--- a/lib/ln.ts
+++ b/lib/ln.ts
@@ -1,5 +1,243 @@
import config from "$config";
+import { existsSync } from "fs";
+import net from "net";
-const { LightningClient } = (await import("@asoltys/clightning-client")).default;
-export default new LightningClient(config.lightning);
-export const lnb = new LightningClient(config.lightningb);
+// Patch net.Socket.prototype.connect to prevent Bun from crashing when
+// connecting to a non-existent unix socket. Bun's net.createConnection
+// throws ENOENT in a way that bypasses all JS error handlers.
+// Instead, we check if the socket file exists and emit a proper error event.
+const origConnect = net.Socket.prototype.connect;
+net.Socket.prototype.connect = function (...args: any[]) {
+ const sockPath = typeof args[0] === "string" ? args[0] : args[0]?.path;
+ if (typeof sockPath === "string" && sockPath.startsWith("/") && !existsSync(sockPath)) {
+ process.nextTick(() => {
+ this.emit(
+ "error",
+ Object.assign(new Error(`connect ENOENT ${sockPath}`), {
+ code: "ENOENT",
+ errno: -2,
+ syscall: "connect",
+ address: sockPath,
+ }),
+ );
+ });
+ return this;
+ }
+ return origConnect.apply(this, args);
+};
+
+const mod = (await import("@asoltys/clightning-client")).default;
+const { LightningClient } = mod as { LightningClient: any };
+
+class LightningUnavailableError extends Error {
+ code = "LIGHTNING_UNAVAILABLE" as const;
+ constructor(msg: string) {
+ super(msg);
+ this.name = "LightningUnavailableError";
+ }
+}
+
+function isUnavailable(e: any) {
+ const code = e?.code ?? e?.errno;
+ return code === "ENOENT" || code === 2 || code === "ECONNREFUSED";
+}
+
+function isSocketDied(e: any) {
+ const code = e?.code ?? e?.errno;
+ return (
+ code === "EPIPE" ||
+ code === "ECONNRESET" ||
+ code === "ENOENT" ||
+ code === 2 ||
+ // A timed-out RPC (see RPC_TIMEOUT below) means the socket is open but cl
+ // stopped responding (zombie/half-open). Treat it as dead so the client is
+ // dropped and the next call reconnects — without restarting the process.
+ code === "ETIMEDOUT"
+ );
+}
+
+// Wall-clock cap for short-lived RPCs. A unix-domain socket has no TCP
+// keepalive, so a hung cl would otherwise leave the await pending forever
+// (the "stuck spinner past /pay/bob, no error" symptom). 30s matches the
+// health-check RPC_TIMEOUT in lib/health.ts.
+const RPC_TIMEOUT = 30_000;
+
+// Methods that MUST NOT get a client-side timeout:
+// - waitanyinvoice/waitinvoice/waitsendpay/waitblockheight: long-polls that
+// are designed to block until something happens.
+// - pay/xpay/sendpay/keysend: in-flight payments. Rejecting the caller early
+// while the HTLC may still settle is exactly the leaked-debit hazard the
+// sendLightning guards exist to prevent — let these run to completion.
+// - fetchinvoice/sendinvoice/offer: bolt12 flows that round-trip to a remote
+// node over the network and can legitimately take a long time.
+const NO_TIMEOUT = new Set([
+ "waitanyinvoice",
+ "waitinvoice",
+ "waitsendpay",
+ "waitblockheight",
+ "pay",
+ "xpay",
+ "sendpay",
+ "keysend",
+ "fetchinvoice",
+ "sendinvoice",
+ "offer",
+]);
+
+class RpcTimeoutError extends Error {
+ code = "ETIMEDOUT" as const;
+ constructor(method: string, ms: number) {
+ super(`Lightning RPC ${method} timed out after ${ms}ms`);
+ this.name = "RpcTimeoutError";
+ }
+}
+
+function lightningProxy(rpcPath: string) {
+ let client: any = null;
+
+ let nextTryAt = 0;
+ let backoff = 250;
+ const maxBackoff = 5000;
+
+ const deathCbs = new Set<(reason?: string) => void>();
+
+ // Fully tear down a dead client. Just doing `client = null` orphans the old
+ // LightningClient: the @asoltys/clightning-client library keeps its internal
+ // reconnect() loop running on the (dead) socket forever — spamming
+ // `connect ENOENT …/lightning-rpc` and leaking a socket + timer on EVERY cl
+ // restart. So stop its reconnect loop, destroy the socket, and drop listeners.
+ // Then notify subscribers (the invoice listener) so they can recycle at once
+ // rather than waiting on the 5-minute stall watchdog. `notify=false` for a
+ // deliberate reset() where the caller already owns the re-arm.
+ function dropClient(reason?: string, notify = true) {
+ const dead = client;
+ client = null;
+ if (dead) {
+ try {
+ if (dead.reconnectTimeout) { clearTimeout(dead.reconnectTimeout); dead.reconnectTimeout = null; }
+ dead.reconnect = () => {}; // neuter any pending 'error'/'end' handler
+ dead.rl?.close?.();
+ dead.client?.removeAllListeners?.();
+ dead.client?.destroy?.();
+ dead.removeAllListeners?.();
+ } catch (_) {}
+ }
+ if (notify) for (const cb of deathCbs) { try { cb(reason); } catch (_) {} }
+ }
+
+ function ensure() {
+ if (client) return client;
+
+ const now = Date.now();
+ if (now < nextTryAt) {
+ throw new LightningUnavailableError(`Lightning not ready at ${rpcPath}`);
+ }
+
+ if (!existsSync(rpcPath)) {
+ nextTryAt = Date.now() + backoff;
+ backoff = Math.min(maxBackoff, Math.floor(backoff * 1.8));
+ throw new LightningUnavailableError(
+ `Lightning RPC socket not found at ${rpcPath}`
+ );
+ }
+
+ try {
+ const c = new LightningClient(rpcPath);
+ client = c;
+ // Prevent unhandled 'error' events from crashing the process. Guard on
+ // `c === client` so a stale client's late error can't tear down a newer,
+ // healthy one that already replaced it.
+ c.on("error", (e: any) => {
+ if (c === client && isSocketDied(e)) dropClient(e?.code ?? e?.errno);
+ });
+ backoff = 250;
+ nextTryAt = 0;
+ return client;
+ } catch (e: any) {
+ nextTryAt = Date.now() + backoff;
+ backoff = Math.min(maxBackoff, Math.floor(backoff * 1.8));
+
+ if (isUnavailable(e)) {
+ throw new LightningUnavailableError(
+ `Lightning RPC unavailable at ${rpcPath} (${e?.code ?? e?.errno ?? "error"})`
+ );
+ }
+ throw e;
+ }
+ }
+
+ return new Proxy(
+ {},
+ {
+ get(_t, prop: string | symbol) {
+ if (prop === "toString") return () => "[LightningProxy]";
+ if (prop === Symbol.toStringTag) return "LightningProxy";
+
+ // Force the underlying socket to be dropped so the next call reconnects.
+ // Used by the lightning-listener watchdog to recover a zombie long-poll
+ // (waitanyinvoice blocked on a dead socket while cl is actually alive —
+ // the per-call timeout can't catch it because long-polls are exempt).
+ if (prop === "reset") {
+ return () => {
+ const had = !!client;
+ dropClient("reset", false); // caller (watchdog) owns the re-arm
+ return had;
+ };
+ }
+
+ // Subscribe to socket-death so a consumer (the invoice listener) can
+ // recover the instant the connection dies, instead of polling/waiting.
+ // Returns an unsubscribe fn. Handled before the generic RPC dispatch so
+ // "onDrop" is never sent to cl as a method.
+ if (prop === "onDrop") {
+ return (cb: (reason?: string) => void) => {
+ deathCbs.add(cb);
+ return () => deathCbs.delete(cb);
+ };
+ }
+
+ return async (...args: any[]) => {
+ const c = ensure();
+ const v = c[prop as any];
+
+ if (typeof v !== "function") return v;
+
+ const method = String(prop);
+ try {
+ const call = v.apply(c, args);
+ if (NO_TIMEOUT.has(method)) return await call;
+
+ let timer: ReturnType<typeof setTimeout> | undefined;
+ const timeout = new Promise((_resolve, reject) => {
+ timer = setTimeout(
+ () => reject(new RpcTimeoutError(method, RPC_TIMEOUT)),
+ RPC_TIMEOUT,
+ );
+ });
+ try {
+ return await Promise.race([call, timeout]);
+ } finally {
+ clearTimeout(timer);
+ }
+ } catch (e: any) {
+ // ETIMEDOUT (from RpcTimeoutError) is included in isSocketDied, so a
+ // hung RPC drops the client and the next call reconnects.
+ if (isSocketDied(e)) dropClient(e?.code ?? e?.errno);
+ throw e;
+ }
+ };
+ },
+ }
+ );
+}
+
+// Main proxy for short-lived RPC calls (getinfo, invoice, decode, etc.)
+const ln = lightningProxy(config.lightning);
+export default ln;
+
+// Dedicated proxy for long-polling (waitanyinvoice) — uses its own socket
+// so it doesn't block the main connection
+export const lnListen = lightningProxy(config.lightningListen || config.lightning);
+
+export const lnb = lightningProxy(config.lightningb);
+export { LightningUnavailableError };
diff --git a/lib/lnquote.ts b/lib/lnquote.ts
new file mode 100644
index 00000000..de96f69a
--- /dev/null
+++ b/lib/lnquote.ts
@@ -0,0 +1,303 @@
+import config from "$config";
+import { g } from "$lib/db";
+import ln from "$lib/ln";
+import { warn } from "$lib/logging";
+import { PaymentType } from "$lib/types";
+import { fail } from "$lib/utils";
+import { v4 } from "uuid";
+
+// Up-front routing-fee quotes for lightning sends.
+//
+// xpay routes with askrene's `getroutes`, so asking askrene the same question
+// before we debit tells us exactly what the payment will cost — instead of
+// reserving a guessed maxfee and refunding the unused part afterwards. We
+// mirror what xpay does in plugins/xpay/xpay.c (v26.06): a throwaway private
+// layer carrying the invoice's route hints (bolt11) or blinded paths (bolt12,
+// via a fake destination node), then getroutes over the same layer stack in
+// the same order (auto.localchans, auto.sourcefree, xpay, private, user).
+
+// xpay's fake destination for blinded paths (xpay.c: pubkey 02 || 0..01).
+const FAKE_NODE =
+ "020000000000000000000000000000000000000000000000000000000000000001";
+
+const XPAY_MAXDELAY = 2016; // xpay's default maxdelay
+
+// Extra layers sendLightning passes to xpay. xpay forwards user layers to
+// getroutes verbatim, and getroutes rejects an unknown layer outright, so only
+// pass the ones that actually exist on this node (prod has prefer-kappa,
+// regtest doesn't). Cached briefly — layers don't come and go.
+const WANTED_LAYERS = ["prefer-kappa"];
+let layerCache: { at: number; layers: string[] } | undefined;
+export const xpayLayers = async (): Promise<string[]> => {
+ if (layerCache && Date.now() - layerCache.at < 60_000)
+ return layerCache.layers;
+ let layers: string[] = [];
+ try {
+ const r = await ln.call("askrene-listlayers", {});
+ const existing = new Set((r?.layers ?? []).map((l: any) => l.layer));
+ layers = WANTED_LAYERS.filter((l) => existing.has(l));
+ } catch (e: any) {
+ warn("askrene-listlayers failed", e?.message ?? String(e));
+ return [];
+ }
+ layerCache = { at: Date.now(), layers };
+ return layers;
+};
+
+let ourId: string | undefined;
+const nodeId = async () => {
+ if (!ourId) ({ id: ourId } = await ln.getinfo());
+ return ourId;
+};
+
+// Channel direction bit as CLN defines it: 0 when the source node id sorts
+// before the destination's (node_id_idx). Node ids are same-length lowercase
+// hex, so plain string comparison matches the byte comparison.
+const dir = (src: string, dst: string) => (src < dst ? 0 : 1);
+
+const isHex66 = (s: any) =>
+ typeof s === "string" && /^0[23][0-9a-f]{64}$/.test(s);
+
+// The routing-relevant parts of a decoded invoice: where it goes, the final
+// cltv, and the private topology xpay would add. Fails on anything that isn't
+// a bolt11 invoice or a bolt12 invoice (offers must be fetched first).
+const routingTarget = async (decoded: any) => {
+ const me = await nodeId();
+
+ if (decoded.type === "bolt11 invoice") {
+ const dest = decoded.payee;
+ if (!isHex66(dest)) fail("Invoice has no payee");
+ const hints: any[][] = decoded.routes ?? [];
+ return {
+ dest,
+ finalCltv: decoded.min_final_cltv_expiry ?? 18,
+ hints,
+ paths: [],
+ me,
+ };
+ }
+
+ if (decoded.type === "bolt12 invoice") {
+ // decode nests each path's payinfo inside the path (v26); older output
+ // put them in a parallel invoice_blindedpay array. Accept both.
+ const paths = decoded.invoice_paths ?? [];
+ const payinfos = decoded.invoice_blindedpay ?? [];
+ return {
+ dest: decoded.invoice_node_id,
+ finalCltv: 0, // xpay: unknown for blinded paths, payinfo carries the delta
+ hints: [],
+ paths: paths.map((p: any, i: number) => ({
+ path: p,
+ payinfo: p.payinfo ?? payinfos[i],
+ })),
+ me,
+ };
+ }
+
+ fail(`Cannot quote a ${decoded.type}`);
+};
+
+// Ask askrene what delivering `amount` sats along this invoice would cost.
+// Returns the routing fee in sats (rounded up — xpay's maxfee is msat, but
+// everything we store is sats, and a cap that's a few msat short fails hard).
+export const quoteRoutingFee = async ({
+ pr,
+ amount,
+}: {
+ pr: string;
+ amount: number;
+}) => {
+ amount = Number.parseInt(amount as any);
+ if (!(amount > 0)) fail("Invalid amount");
+ const amountMsat = amount * 1000;
+
+ const decoded = await ln.decode(pr.replace(/\s/g, "").toLowerCase());
+ const { dest, finalCltv, hints, paths, me } = await routingTarget(decoded);
+
+ // Self-pay: xpay short-circuits to an inject with no routing at all.
+ if (dest === me) return { fee: 0, feeMsat: 0, parts: 0 };
+
+ // Search cap for getroutes. Generous on purpose: this bounds the search,
+ // and the caller decides what to do with the answer.
+ const cap = Math.max(50_000, Math.ceil(amountMsat * 0.05));
+ // xpay sizes its fake hint channels at 100x (amount + maxfee).
+ const bigCap = (amountMsat + cap) * 100;
+
+ let destination = dest;
+ const fakes: any[] = [];
+ for (const route of hints) {
+ for (let i = 0; i < route.length; i++) {
+ const hop = route[i];
+ const next = i + 1 < route.length ? route[i + 1].pubkey : dest;
+ // xpay skips hints from ourselves — we know our own channels.
+ if (hop.pubkey === me) continue;
+ if (hop.pubkey === next) continue; // askrene rejects self-loops
+ fakes.push({
+ src: hop.pubkey,
+ dst: next,
+ scid: hop.short_channel_id,
+ cap: bigCap,
+ htlcMin: 0,
+ htlcMax: bigCap,
+ base: hop.fee_base_msat ?? 0,
+ ppm: hop.fee_proportional_millionths ?? 0,
+ delta: hop.cltv_expiry_delta ?? 0,
+ });
+ }
+ }
+
+ if (paths.length) {
+ destination = FAKE_NODE;
+ let n = 0;
+ for (const [i, { path, payinfo }] of paths.entries()) {
+ // Only introduction points given as a node id can be resolved here;
+ // xpay resolves scid-form ones through gossmap and discards the rest.
+ const first = path?.first_node_id;
+ if (!isHex66(first) || !payinfo) continue;
+ // Earlier paths get more capacity so askrene prefers them, like xpay.
+ const c = (amountMsat + cap) * (100 + (paths.length - i));
+ fakes.push({
+ src: first,
+ dst: FAKE_NODE,
+ scid: `0x0x${i}`, // block 0 is impossible, so it can't collide
+ cap: c,
+ htlcMin: payinfo.htlc_minimum_msat ?? 0,
+ htlcMax: payinfo.htlc_maximum_msat ?? c,
+ base: payinfo.fee_base_msat ?? 0,
+ ppm: payinfo.fee_proportional_millionths ?? 0,
+ delta: payinfo.cltv_expiry_delta ?? 0,
+ });
+ n++;
+ }
+ if (!n) fail("No usable blinded paths");
+ }
+
+ const layer = `quote-${v4()}`;
+ try {
+ await ln.call("askrene-create-layer", { layer, persistent: false });
+ for (const f of fakes) {
+ await ln.call("askrene-create-channel", {
+ layer,
+ source: f.src,
+ destination: f.dst,
+ short_channel_id: f.scid,
+ capacity_msat: f.cap,
+ });
+ await ln.call("askrene-update-channel", {
+ layer,
+ short_channel_id_dir: `${f.scid}/${dir(f.src, f.dst)}`,
+ enabled: true,
+ htlc_minimum_msat: f.htlcMin,
+ htlc_maximum_msat: f.htlcMax,
+ fee_base_msat: f.base,
+ fee_proportional_millionths: f.ppm,
+ cltv_expiry_delta: f.delta,
+ });
+ }
+
+ const r = await ln.call("getroutes", {
+ source: me,
+ destination,
+ amount_msat: amountMsat,
+ // Same stack and order as xpay: our channels re-added with real fees,
+ // then zeroed as the source, then xpay's learned constraints.
+ layers: [
+ "auto.localchans",
+ "auto.sourcefree",
+ "xpay",
+ layer,
+ ...(await xpayLayers()),
+ ],
+ maxfee_msat: cap,
+ final_cltv: finalCltv,
+ maxdelay: XPAY_MAXDELAY,
+ });
+
+ const routes: any[] = r?.routes ?? [];
+ if (!routes.length) fail("No route found");
+ const sent = routes.reduce(
+ (s, rt) => s + Number(rt.path?.[0]?.amount_msat ?? 0),
+ 0,
+ );
+ const delivered = routes.reduce(
+ (s, rt) => s + Number(rt.amount_msat ?? 0),
+ 0,
+ );
+ if (
+ !Number.isFinite(sent) ||
+ !Number.isFinite(delivered) ||
+ delivered < amountMsat
+ )
+ fail("No route found");
+ const feeMsat = Math.max(0, sent - delivered);
+ return { fee: Math.ceil(feeMsat / 1000), feeMsat, parts: routes.length };
+ } finally {
+ try {
+ await ln.call("askrene-remove-layer", { layer });
+ } catch (e: any) {
+ warn("askrene-remove-layer failed", layer, e?.message ?? String(e));
+ }
+ }
+};
+
+// Platform fee for a lightning send, exactly as debit() will charge it:
+// rate × (amount + routing fee), less any free-tier credit, and nothing at
+// all on sub-accounts.
+export const platformFee = async ({
+ uid,
+ aid,
+ amount,
+ fee,
+}: {
+ uid: string;
+ aid: string;
+ amount: number;
+ fee: number;
+}) => {
+ if (aid !== uid) return 0;
+ let rate = config.fee[PaymentType.lightning];
+ if (await g(`ln:nofree:${uid}`)) rate = config.fee.lightningHigh ?? rate;
+ const credit = Number.parseInt(await g(`credit:lightning:${uid}`)) || 0;
+ const ourfee = Math.round((amount + fee) * rate);
+ return Math.max(0, ourfee - Math.min(credit, ourfee));
+};
+
+// The most the account can deliver along this invoice such that amount +
+// routing fee + platform fee lands exactly on the balance. Both fees grow
+// with the amount, so iterate: quote at the balance, subtract the fees, quote
+// again at the smaller amount. Each step's fees are ≥ the next step's (fees
+// are monotone in amount), so from the second quote on the total never
+// exceeds the balance; the extra rounds only shave the last sat or two.
+export const quoteMax = async ({
+ pr,
+ uid,
+ aid,
+ ceiling,
+}: {
+ pr: string;
+ uid: string;
+ aid: string;
+ ceiling?: number;
+}) => {
+ const balance = Number.parseInt(await g(`balance:${aid}`)) || 0;
+ let amount = balance;
+ if (ceiling > 0) amount = Math.min(amount, Math.floor(ceiling));
+ if (amount <= 0) fail("Insufficient funds");
+
+ let fee = 0;
+ let ourfee = 0;
+ for (let i = 0; i < 5; i++) {
+ ({ fee } = await quoteRoutingFee({ pr, amount }));
+ ourfee = await platformFee({ uid, aid, amount, fee });
+ const next = Math.min(
+ balance - fee - ourfee,
+ ceiling > 0 ? Math.floor(ceiling) : Number.POSITIVE_INFINITY,
+ );
+ if (next <= 0) fail("Insufficient funds");
+ if (next === amount) break;
+ amount = next;
+ }
+
+ if (amount + fee + ourfee > balance) fail("Insufficient funds");
+ return { amount, fee, ourfee, balance, total: amount + fee + ourfee };
+};
diff --git a/lib/locations.ts b/lib/locations.ts
index 660645a8..a70d57e1 100644
--- a/lib/locations.ts
+++ b/lib/locations.ts
@@ -55,8 +55,7 @@ export const getLocations = async () => {
await s("locations", dedup(locations));
await s("locations:since", `${new Date().toISOString().split(".")[0]}Z`);
} catch (e) {
- console.log(e);
- err("problem fetching locations", e);
+ err("problem fetching locations", e.message);
}
setTimeout(getLocations, 60000);
diff --git a/lib/logging.ts b/lib/logging.ts
index aab110c2..af6ae942 100644
--- a/lib/logging.ts
+++ b/lib/logging.ts
@@ -4,6 +4,46 @@ export const l = (...msgs) => pino().info(msgs.join(" "));
export const warn = (...msgs) => pino().warn(msgs.join(" "));
export const err = (...msgs) => pino().error(msgs.join(" "));
+// Collapse a verbose error message for logging. CLN's xpay concatenates one
+// failure sentence per routing retry (e.g. "We got temporary_channel_failure
+// for X … updating our map." repeated 50+ times), which floods the logs on an
+// ordinary failed lightning send. Dedupe repeated sentences and cap length.
+export const shortError = (msg: any, max = 400): string => {
+ let s = typeof msg === "string" ? msg : msg?.message ?? String(msg);
+ const parts = s.split(/(?<=\.)\s+/);
+ const seen = new Set<string>();
+ const uniq: string[] = [];
+ for (const p of parts) {
+ const t = p.trim();
+ if (t && !seen.has(t)) { seen.add(t); uniq.push(t); }
+ }
+ s = uniq.join(" ");
+ const dropped = parts.length - uniq.length;
+ if (dropped > 0) s += ` [${dropped} repeated lines collapsed]`;
+ return s.length > max ? `${s.slice(0, max)}…` : s;
+};
+
+// Per-key log throttle for lines an attacker can emit at will (evicted-JWT
+// replays from a proxy pool, frozen-account send attempts, lnurl scans of
+// missing usernames). Returns null while the key is inside its quiet window,
+// otherwise the number of calls suppressed since the last emitted line — so
+// the first occurrence always logs, and the next one after the window carries
+// the suppressed count. In-memory: a restart simply logs again.
+const quietUntil = new Map<string, { until: number; dropped: number }>();
+export const quiet = (key: string, ms = 10 * 60 * 1000): number | null => {
+ const now = Date.now();
+ const q = quietUntil.get(key);
+ if (q && q.until > now) {
+ q.dropped++;
+ return null;
+ }
+ if (quietUntil.size > 10000) quietUntil.clear();
+ quietUntil.set(key, { until: now + ms, dropped: 0 });
+ return q?.dropped ?? 0;
+};
+export const suppressed = (n: number | null) =>
+ n ? `(+${n} similar suppressed)` : "";
+
export const line = () => {
const stack = new Error().stack;
const stackLine = stack.split("\n")[1];
diff --git a/lib/names.ts b/lib/names.ts
new file mode 100644
index 00000000..284c595d
--- /dev/null
+++ b/lib/names.ts
@@ -0,0 +1,37 @@
+import { warn } from "$lib/logging";
+import { fail } from "$lib/utils";
+
+// The v3 registrar (coinos v3 / halwallet) owns any name it has a record for:
+// routes/lnurl.ts serves a claimed name's lightning address from the registrar
+// ahead of the local account. So a local account taking a name the registrar
+// holds silently hands its incoming payments to whoever holds the v3 name.
+// Both places that assign a username — registration and the profile rename —
+// must therefore refuse a name the registrar holds, unless the account's own
+// nostr key is the holder (a user re-creating their migrated name is fine).
+const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io";
+const DOMAIN = "coinos.io";
+
+// Fail CLOSED: if the registrar can't be reached we don't know who owns the
+// name, and handing out a squatted name is worse than a retry.
+export const assertNameFree = async (username: string, ownPubkey?: string) => {
+ const name = username.toLowerCase();
+ let body: any;
+ try {
+ const r = await fetch(
+ `${NAMES_URL}/name/${encodeURIComponent(name)}?domain=${DOMAIN}`,
+ { signal: AbortSignal.timeout(3000) },
+ );
+ if (!r.ok) throw new Error(`registrar said ${r.status}`);
+ body = await r.json();
+ } catch (e: any) {
+ warn("names registrar unreachable checking", name, e?.message);
+ fail("Username unavailable right now, please try again");
+ }
+ // `pubkey` is only present for a real v3 record. `taken` without one means
+ // the registrar is echoing our own account list back at us — not a claim.
+ const holder = body?.taken && typeof body.pubkey === "string" ? body.pubkey : null;
+ if (holder && holder !== ownPubkey) {
+ warn("username held by v3 registrar", name, holder.slice(0, 8));
+ fail(`Username ${name} taken`);
+ }
+};
diff --git a/lib/nostr.ts b/lib/nostr.ts
index 4c28ebc9..ed1b2f42 100644
--- a/lib/nostr.ts
+++ b/lib/nostr.ts
@@ -3,20 +3,31 @@ import { db, g, s } from "$lib/db";
import { l, warn } from "$lib/logging";
import { scan } from "$lib/strfry";
import { fail, fields, getUser, pick } from "$lib/utils";
-import { bytesToHex } from "@noble/hashes/utils";
-import { finalizeEvent, getPublicKey, nip19, verifyEvent } from "nostr-tools";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
+import {
+ finalizeEvent,
+ getPublicKey,
+ nip04,
+ nip19,
+ nip44,
+ verifyEvent,
+} from "nostr-tools";
import type { Event } from "nostr-tools";
import { AbstractSimplePool } from "nostr-tools/abstract-pool";
import { Relay } from "nostr-tools/relay";
export const EX = 60 * 60 * 24;
-const coinos = await Relay.connect(config.nostr);
export const serverSecret = bytesToHex(
nip19.decode(config.nostrKey).data as Uint8Array,
);
-export const serverPubkey = getPublicKey(serverSecret);
+export const serverSecret2 = bytesToHex(
+ nip19.decode(config.nostrKey2).data as Uint8Array,
+);
+
+export const serverPubkey = getPublicKey(hexToBytes(serverSecret));
+export const serverPubkey2 = getPublicKey(hexToBytes(serverSecret2));
const alwaysTrue: any = (t: Event) => {
t[Symbol("verified")] = true;
@@ -33,6 +44,21 @@ export const anon = (pubkey) => ({
followers: [],
});
+// NIP-47 encryption schemes: clients pick one from the 13194 info event's
+// `encryption` tag and mark their requests with the same tag; replies use
+// whatever the request used. No tag means legacy nip04.
+export const encryptionSchemes = "nip44_v2 nip04";
+
+export const decryptPayload = (scheme, sk, pk, content) =>
+ scheme === "nip44_v2"
+ ? nip44.decrypt(content, nip44.getConversationKey(hexToBytes(sk), pk))
+ : nip04.decrypt(sk, pk, content);
+
+export const encryptPayload = (scheme, sk, pk, plaintext) =>
+ scheme === "nip44_v2"
+ ? nip44.encrypt(plaintext, nip44.getConversationKey(hexToBytes(sk), pk))
+ : nip04.encrypt(sk, pk, plaintext);
+
export async function publish(ev, url = config.nostr) {
if (!verifyEvent(ev)) fail("Invalid event");
const r = await Relay.connect(url);
@@ -42,7 +68,7 @@ export async function publish(ev, url = config.nostr) {
export async function handleZap(invoice, sender = undefined) {
try {
- const pubkey = serverPubkey;
+ const pubkey = serverPubkey2;
const zapreq = JSON.parse(invoice.description);
if (!zapreq.tags || zapreq.tags.length === 0) {
@@ -95,7 +121,7 @@ export async function handleZap(invoice, sender = undefined) {
tags.push(["preimage", invoice.payment_preimage]);
const ev = { pubkey, kind, created_at, content, tags };
- const signed = await finalizeEvent(ev, serverSecret);
+ const signed = await finalizeEvent(ev, hexToBytes(serverSecret2));
l("sending receipt");
@@ -180,9 +206,7 @@ export const getCount = async (pubkey) => {
}
return { follows, followers };
- } catch (e) {
- console.log(e);
- }
+ } catch (e) {}
};
export const getNostrUser = async (key) => {
@@ -231,7 +255,6 @@ export const q = async (f) => {
pool.subscribeMany(["wss://relay.primal.net"], [f], {
onevent(e) {
r.push(e);
- coinos.publish(e);
},
oneose() {
resolve(r);
diff --git a/lib/notes.ts b/lib/notes.ts
index 84d157ee..400e5978 100644
--- a/lib/notes.ts
+++ b/lib/notes.ts
@@ -196,7 +196,6 @@ export const parseContent = ({
return [`nostr:${type}`, bech32, { ...value, entity }];
} catch (e) {
- console.log(e);
// pass
}
}
diff --git a/lib/notifications.ts b/lib/notifications.ts
index d5b4f73f..fe245c61 100644
--- a/lib/notifications.ts
+++ b/lib/notifications.ts
@@ -4,11 +4,11 @@ import ln from "$lib/ln";
import { err, l, warn } from "$lib/logging";
import { mail, templates } from "$lib/mail";
import mqtt from "$lib/mqtt";
-import { publish, serverSecret } from "$lib/nostr";
+import { encryptPayload, publish, serverSecret2 } from "$lib/nostr";
import { emit } from "$lib/sockets";
import { f, fiat, fmt, getUser, link, nada, t } from "$lib/utils";
import { hexToBytes } from "@noble/hashes/utils";
-import { finalizeEvent, nip04 } from "nostr-tools";
+import { finalizeEvent } from "nostr-tools";
import webpush from "web-push";
if (config.vapid) {
@@ -19,6 +19,22 @@ if (config.vapid) {
);
}
+
+// LNURL-pay invoices carry the raw metadata array as their memo when the
+// payer left no comment, e.g. [["text/plain","Paying [email protected]"],...].
+// Receipts should show just the human-readable text.
+const printerMemo = (memo) => {
+ if (typeof memo !== "string" || !memo.startsWith("[")) return memo;
+ try {
+ const meta = JSON.parse(memo);
+ if (Array.isArray(meta)) {
+ const plain = meta.find((m) => Array.isArray(m) && m[0] === "text/plain");
+ if (plain) return plain[1];
+ }
+ } catch (e) {}
+ return memo;
+};
+
export const notify = async (p, user, withdrawal) => {
emit(user.id, "payment", p);
let { username } = user;
@@ -74,7 +90,8 @@ export const notify = async (p, user, withdrawal) => {
if (!mqtt.connected) await mqtt.reconnect();
mqtt.publish(
username,
- `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}:${p.memo}:${p.items}`,
+ `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}:${printerMemo(p.memo)}:${p.items}`,
+ { qos: 1 },
);
}
};
@@ -84,11 +101,23 @@ export const nwcNotify = async (p) => {
const user = await getUser(p.uid);
const pubkeys = await db.sMembers(`${user.id}:apps`);
if (pubkeys.length) {
- let payment_hash = "";
- if (p.type === "lightning") ({ payment_hash } = await ln.decode(p.hash));
+ let payment_hash = p.payment_hash || "";
+ if (!payment_hash && (p.type === "lightning" || p.type === "bolt12")) {
+ try {
+ const d = await ln.decode(p.hash);
+ // bolt11 decodes expose payment_hash, bolt12 invoices
+ // invoice_payment_hash
+ payment_hash = d.payment_hash || d.invoice_payment_hash || "";
+ } catch (e) {}
+ }
for (const pubkey of pubkeys) {
- const { notify } = await g(`app:${pubkey}`);
- if (!notify) continue;
+ // The app record may be missing/not-yet-written: a pubkey lands in
+ // `${uid}:apps` before its `app:<pubkey>` is persisted (creation race when
+ // adding an NWC connection), or a stale entry outlived its record. Guard
+ // the lookup — destructuring `notify` off null threw and, since the catch
+ // wraps the whole loop, aborted notifications for ALL the user's apps.
+ const app = await g(`app:${pubkey}`);
+ if (!app?.notify) continue;
l("notifying", pubkey, p.type, p.amount);
const notification = {
@@ -108,22 +137,33 @@ export const nwcNotify = async (p) => {
notification,
});
- const content = await nip04.encrypt(serverSecret, pubkey, payload);
+ // NIP-47: a wallet supporting both schemes publishes each notification
+ // twice — kind 23196 nip04-encrypted, kind 23197 nip44-encrypted
+ for (const [scheme, kind] of [
+ ["nip04", 23196],
+ ["nip44_v2", 23197],
+ ] as const) {
+ const content = await encryptPayload(
+ scheme,
+ serverSecret2,
+ pubkey,
+ payload,
+ );
- const unsigned = {
- content,
- tags: [["p", pubkey]],
- kind: 23196,
- created_at: Math.floor(Date.now() / 1000),
- };
+ const unsigned = {
+ content,
+ tags: [["p", pubkey]],
+ kind,
+ created_at: Math.floor(Date.now() / 1000),
+ };
- const event = finalizeEvent(unsigned, hexToBytes(serverSecret));
+ const event = finalizeEvent(unsigned, hexToBytes(serverSecret2));
- publish(event).catch(nada);
+ publish(event).catch(nada);
+ }
}
}
} catch (e) {
- console.log(e);
warn("nwc notification failed", e.message);
}
};
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,
diff --git a/lib/payments.ts b/lib/payments.ts
index cbff6dd3..09cf2cf6 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -1,11 +1,30 @@
import config from "$config";
+import { existsSync } from "fs";
import api from "$lib/api";
-import { db, g, ga, s } from "$lib/db";
+import { requireAccount } from "$lib/auth";
+import { archive, db, g, ga, gf, s, sa } from "$lib/db";
+import { assertWithinSpendLimit } from "$lib/spend-budget";
+
+// External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a
+// human) can `touch /home/adam/locks/<type>.locked` on the host, which is
+// bind-mounted into this container at /locks. Existence of /locks/<type>.locked
+// (or /locks/ALL.locked) blocks all external sends of that type. Lives outside
+// db so it can't be disabled by a db-write vector (May 18 /gateway lesson).
+//
+// Internal payments are never blocked by these — receives and internal
+// transfers between coinos users keep working with the site fully online.
+const isWithdrawLocked = (type: string): string | null => {
+ if (existsSync("/locks/ALL.locked")) return "ALL";
+ if (existsSync(`/locks/${type}.locked`)) return type;
+ return null;
+};
import { generate } from "$lib/invoices";
import ln from "$lib/ln";
-import { err, l, warn } from "$lib/logging";
+import { quoteRoutingFee, xpayLayers } from "$lib/lnquote";
+import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging";
import { handleZap } from "$lib/nostr";
import { notify, nwcNotify } from "$lib/notifications";
+import { squarePayment } from "$lib/square";
import {
SATS,
btc,
@@ -21,15 +40,65 @@ import {
t,
} from "$lib/utils";
import { callWebhook } from "$lib/webhooks";
+import { maybeForwardToV3, retryV3Forwards } from "$lib/v3forward";
+import changeid from "$lib/changeid";
import rpc from "@coinos/rpc";
import { bech32 } from "bech32";
import got from "got";
import { v4 } from "uuid";
+
import { PaymentType } from "$lib/types";
const bc = rpc(config.bitcoin);
const lq = rpc(config.liquid);
+
+// Fire-and-forget: push a freshly-broadcast tx straight to public explorers so
+// they see it immediately instead of waiting for it to propagate to their node
+// over the p2p network (measured ~8s otherwise). Never throws; never blocks.
+const broadcastToExplorers = (hex: string) => {
+ const net = config.bitcoin?.network;
+ if (net === "regtest" || net === "testnet") return; // public explorers are mainnet
+ for (const base of ["https://mempool.space/api", "https://blockstream.info/api"]) {
+ fetch(`${base}/tx`, {
+ method: "POST",
+ body: hex,
+ headers: { "Content-Type": "text/plain" },
+ }).catch(() => {});
+ }
+};
+
+// Throttled warn — emit at most once per WARN_THROTTLE_MS per (key, message) pair
+// so a downed external service (lq, bc, etc.) doesn't flood the log every loop tick.
+const WARN_THROTTLE_MS = 5 * 60 * 1000;
+const warnLastEmitted: Record<string, number> = {};
+const warnThrottled = (key: string, message: string) => {
+ const k = `${key}|${message}`;
+ const now = Date.now();
+ if ((warnLastEmitted[k] || 0) + WARN_THROTTLE_MS > now) return;
+ warnLastEmitted[k] = now;
+ warn(`${key}:`, message);
+};
+
+// Per-asset-type async mutex. The May 18 2026 drain exploited the fact that
+// the limit check + payment broadcast were non-atomic: a burst of concurrent
+// withdrawals would each pass the same stale ${type}:limit value before any
+// of them decremented it. This lock serializes the check + reservation per
+// asset type. freezeCheck also takes the lock when overwriting limits so its
+// periodic reconciliation can't race with an in-flight check.
+const limitLocks: Record<string, Promise<void>> = {};
+async function withLimitLock<T>(type: string, fn: () => Promise<T>): Promise<T> {
+ const prev = limitLocks[type] || Promise.resolve();
+ let release: () => void = () => {};
+ const next = new Promise<void>((res) => { release = res; });
+ limitLocks[type] = prev.then(() => next);
+ await prev;
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+}
const { URL } = process.env;
const dust = 547;
@@ -43,42 +112,87 @@ export const debit = async ({
user,
type = PaymentType.internal,
rate = undefined,
+ // a v3 bridge forward (lib/v3forward.ts) costs routing only
+ exempt = false,
+ maxTotal = undefined,
}) => {
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
const whitelisted = await db.sIsMember(
"whitelist",
user?.username?.toLowerCase().trim(),
);
- const blacklisted = await db.sIsMember(
- "blacklist",
- user?.username?.toLowerCase().trim(),
- );
+ // Blacklist (freeze) matches on EITHER the current username OR the uid. The
+ // uid never changes, so renaming the account can't shake the freeze — add the
+ // uid to the `blacklist` set to freeze a compromised account durably.
+ const blacklisted =
+ (await db.sIsMember("blacklist", user?.username?.toLowerCase().trim())) ||
+ (!!user?.id && (await db.sIsMember("blacklist", user.id)));
+
+ if (hash && await db.sIsMember("blocked_addresses", hash)) {
+ err(`SECURITY: blocked send to ${hash} by ${user.username}`);
+ await changeid(user.username);
+ fail("address blocked");
+ }
- const serverLimit = await g(`${type}:limit`);
const userLimit = await g("limit");
const frozen =
(await g("hardfreeze")) ||
((await g("freeze")) && type !== PaymentType.internal);
- if (frozen || (amount > userLimit && !whitelisted) || amount > serverLimit) {
- warn(
- "Blocking",
- user.username,
- amount,
- hash,
- user.id,
- type,
- frozen,
- userLimit,
- serverLimit,
- );
+ // Out-of-band withdrawal lock (file-based, can't be defeated by db writes).
+ // Internal sends are exempt — they don't touch external wallets.
+ if (type !== PaymentType.internal) {
+ const lockedKind = isWithdrawLocked(type);
+ if (lockedKind) {
+ const n = quiet(`block:${user.id}:lock`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind, suppressed(n));
+ fail("External withdrawals temporarily disabled");
+ }
+ }
+
+ if (frozen || (amount > userLimit && !whitelisted)) {
+ // Per-account throttle: a frozen throwaway retrying sends every few
+ // seconds is expected noise, one line per 5 min keeps the tripwire.
+ const n = quiet(`block:${user.id}:frozen`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit, suppressed(n));
fail("Problem sending payment");
}
+ // Atomic check + reserve against the per-asset-type server limit. Decrement
+ // immediately so concurrent calls can't reuse the same budget; freezeCheck
+ // reconciles to actual on-chain balance every 10s.
+ //
+ // Internal sends are exempt: they're pure ledger moves between coinos users
+ // and never touch an external wallet, so there's no hot-wallet liquidity to
+ // reserve against. freezeCheck only ever populates limits for external types
+ // (lightning/fund/ecash/bolt12/bitcoin/liquid), so `internal:limit` stays 0
+ // forever — applying the check here blocks every internal payment.
+ if (type !== PaymentType.internal) {
+ await withLimitLock(type, async () => {
+ const serverLimit = Number.parseInt((await g(`${type}:limit`)) ?? "0", 10) || 0;
+ if (amount > serverLimit) {
+ const n = quiet(`block:${user.id}:server`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, "serverLimit", serverLimit, suppressed(n));
+ // Say what's happening (users read the generic error as "account
+ // frozen") without revealing the limit or the hot-wallet balance.
+ fail(
+ type === PaymentType.bitcoin || type === PaymentType.liquid
+ ? "On-chain withdrawals are temporarily limited. Please try a smaller amount, send over Lightning, or try again later."
+ : "Payments are temporarily limited. Please try a smaller amount or try again later.",
+ );
+ }
+ await db.decrBy(`${type}:limit`, amount);
+ });
+ }
+
let ref;
const { id: uid, currency } = user;
+ if (!aid) aid = uid;
const rates = await g("rates");
if (!rate) rate = rates[currency];
@@ -103,27 +217,56 @@ export const debit = async ({
}
}
- const tip = parseInt(invoice?.tip) || null;
+ // Capture the tip ONCE, and only for INTERNAL payments — where credit() pays
+ // the invoice owner the same tip (sendInternal passes p.tip through). External
+ // sends (bitcoin/liquid/lightning/bolt12/fund) must NOT inherit a tip: the
+ // destination `hash` can coincidentally match another user's deposit invoice,
+ // which would otherwise pull that stranger's tip into this debit's balance
+ // check and stored record (the LiquidNode negative-balance vector). This one
+ // captured value feeds the ourfee calc, the atomic debit, and the record, so
+ // the record can never disagree with what was actually debited.
+ const tip =
+ type === PaymentType.internal ? Number.parseInt(invoice?.tip) || null : null;
if (tip < 0) fail("Invalid tip");
if (!amount || amount < 0) fail("Amount must be greater than zero");
let creditType = type;
if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning;
+
+ // High-throughput pass-through accounts (flagged `ln:nofree` by
+ // ln-freetier-monitor.ts) pay an elevated platform fee on lightning
+ // withdrawals; everyone else pays the standard rate. Flagged accounts already
+ // accrue no free-tier credit, so this rate is what they actually pay.
+ let feeRate = config.fee[creditType];
+ if (
+ creditType === PaymentType.lightning &&
+ aid === uid &&
+ (await g(`ln:nofree:${uid}`))
+ )
+ feeRate = config.fee.lightningHigh;
+
let ourfee: any = [
PaymentType.bitcoin,
PaymentType.liquid,
PaymentType.lightning,
].includes(type)
- ? Math.round((amount + fee + tip) * config.fee[creditType])
+ ? Math.round((amount + fee + tip) * feeRate)
: 0;
- if (aid && aid !== uid) ourfee = 0;
+ if (aid !== uid || exempt) ourfee = 0;
+
+ // NWC and other delegated spenders can provide the exact allowance remaining
+ // for this call. Enforce it only after debit has reloaded the invoice and
+ // captured its tip, so a recipient cannot pass a small-invoice budget check,
+ // mutate the tip, and drain the account through the internal-payment path.
+ assertWithinSpendLimit({ amount, tip, fee, ourfee, maxTotal });
+
const frozenBalance =
- !blacklisted || whitelisted ? 0 : await ga(`balance:${uid}`);
+ !blacklisted || whitelisted ? 0 : await g(`balance:${uid}`);
ourfee = await db.debit(
- `balance:${aid || uid}`,
+ `balance:${aid}`,
`credit:${creditType}:${aid !== uid ? 0 : uid}`,
t(user).insufficientFunds,
amount || 0,
@@ -135,6 +278,17 @@ export const debit = async ({
if (ourfee.err) fail(ourfee.err);
+ // Defense-in-depth: the DEBIT lua rejects overdrafts, so a negative balance
+ // here means the invariant was violated by a mismatched input or a non-lua
+ // balance write. Log loudly with full context so the exact vector is greppable
+ // (SECURITY: negative balance) instead of surfacing days later as a stuck
+ // negative account.
+ const postBal = Number.parseInt((await db.get(`balance:${aid}`)) || "0");
+ if (postBal < 0)
+ err(
+ `SECURITY: negative balance ${postBal} after debit user=${user?.username} aid=${aid} amount=${amount} tip=${tip} fee=${fee} ourfee=${ourfee} type=${type} hash=${hash}`,
+ );
+
const id = v4();
const p = {
id,
@@ -147,7 +301,13 @@ export const debit = async ({
memo,
iid,
uid,
- confirmed: true,
+ // Lightning/bolt12 sends are IN-FLIGHT until the HTLC settles (preimage
+ // revealed). Don't mark them confirmed on the optimistic debit — finalize()
+ // flips this to true once the preimage arrives. Hold invoices (e.g. Ark)
+ // can keep an HTLC pending for hours/days; showing "confirmed" while the
+ // recipient hasn't been paid is misleading. Other send types settle
+ // immediately (internal) or are broadcast right away (bitcoin/liquid/fund).
+ confirmed: ![PaymentType.lightning, PaymentType.bolt12].includes(type),
rate,
currency,
type,
@@ -166,9 +326,7 @@ export const debit = async ({
.exec();
l(user.username, "sent", type, amount);
- //emit(user.id, "payment", p);
- if (![PaymentType.lightning, PaymentType.bolt12].includes(type))
- nwcNotify(p, user);
+ if (![PaymentType.lightning, PaymentType.bolt12].includes(type)) nwcNotify(p);
return p;
};
@@ -181,16 +339,23 @@ export const credit = async ({
type = PaymentType.internal,
aid = undefined,
payment_hash = undefined,
+ created = undefined,
+ tip = undefined,
}) => {
- amount = parseInt(amount) || 0;
+ amount = Number.parseInt(amount) || 0;
- let inv;
- if (type === PaymentType.bolt12) {
+ // Kill switch (see routes/payments.ts confirm): no Liquid credits at all
+ // while `liquid:deposits:disabled` is set.
+ if (type === PaymentType.liquid && (await g("liquid:deposits:disabled"))) {
+ warn("liquid credit blocked (disabled)", hash, amount, ref);
+ return;
+ }
+
+ let inv = await getInvoice(hash);
+ if (!inv && type === PaymentType.bolt12) {
const { invoices } = await ln.listinvoices({ invstring: hash });
const { local_offer_id } = invoices[0];
inv = await getInvoice(local_offer_id);
- } else {
- inv = await getInvoice(hash);
}
if (!inv) {
@@ -198,15 +363,64 @@ export const credit = async ({
return;
}
- let { path, tip } = inv;
- tip = parseInt(tip) || 0;
+ // usePreimage invoices keep their merchant-supplied preimage at
+ // preimage:${paymentHash} (see generate()). Prefer it as the settlement
+ // ref: external settlements already carry the same value, but internal
+ // ones pass ref=sender.id, which would otherwise be surfaced as the
+ // payment preimage and lose the secret the buyer paid for. Only once the
+ // invoice is fully paid, though — internal sends can settle partial
+ // amounts, and an underpayment must not reveal the secret.
+ if (
+ inv.paymentHash &&
+ ![PaymentType.bitcoin, PaymentType.liquid].includes(type) &&
+ inv.received + amount >= inv.amount
+ ) {
+ const stored = await db.get(`preimage:${inv.paymentHash}`);
+ if (stored) ref = stored;
+ }
+
+ let { path } = inv;
+ // Use the tip the sender was actually debited (passed in for internal
+ // transfers) rather than re-reading the invoice. The invoice's tip can be
+ // mutated between the sender debit and this credit (PUT /invoice), which
+ // otherwise lets a payer credit a tip they were never charged — minting
+ // balance. Fall back to the invoice tip only for external receipts
+ // (lightning/ecash) where there is no paired coinos debit to diverge from.
+ tip = tip !== undefined ? Number.parseInt(tip) || 0 : Number.parseInt(inv.tip) || 0;
if (!memo) ({ memo } = inv);
- if (memo && memo.length > 5000) fail("memo too long");
+ // Truncate rather than throw: credit() runs AFTER the payment has settled (an
+ // incoming lightning/bolt12 deposit already advanced pay_index; an internal
+ // send already debited the sender). Throwing here left the sats in the house
+ // wallet with no retry path — the payer saw a completed payment and the
+ // recipient was never credited. An oversized memo (attacker-controllable via
+ // the lnurl comment or a bolt12 payer_note) must never destroy a settled
+ // payment; a clipped note is harmless.
+ if (memo && memo.length > 5000) memo = memo.slice(0, 5000);
if (amount < 0 || tip < 0) fail("Invalid amount");
+ // For a NON-internal receipt the tip is only a cosmetic split of the real
+ // settled `amount` — there is no paired coinos debit carrying it — so it can
+ // never legitimately exceed `amount`. If the invoice tip was mutated via
+ // PUT /invoice to a value larger than the amount actually received, the
+ // fallback above would pull in that inflated tip and the stored record's
+ // `amount - tip` would go negative: a corrupt record that could be reversed
+ // into balance. The balance credit itself is `amount` and is unaffected, but
+ // clamp the tip so the record can never be negative. Internal transfers keep
+ // the full debited tip (their record stores the base amount, non-negative).
+ if (type !== PaymentType.internal && tip > amount) tip = amount;
if (type === PaymentType.internal) amount += tip;
const user = await getUser(inv.uid);
+ if (!user) {
+ // The account is gone (deleted) but its invoice still receives — e.g. a
+ // bolt12 offer a mining pool keeps paying. Record the settlement in
+ // `missing` and skip, like an unknown invoice. Throwing here wedged the
+ // lightning listener on one settlement and blocked every later receipt
+ // for two hours (2026-09-05, pay_index 1068006).
+ warn("received payment for missing user", inv.uid, hash, amount);
+ await db.sAdd("missing", ref.split(":")[0]);
+ return;
+ }
const { id: uid, currency } = user;
const rates = await g("rates");
@@ -240,7 +454,7 @@ export const credit = async ({
tip,
type,
confirmed: true,
- created: Date.now(),
+ created: created || Date.now(),
items: undefined,
};
@@ -258,31 +472,66 @@ export const credit = async ({
p.confirmed = false;
balanceKey = "pending";
await s(`payment:${txid}:${vout}`, id);
+ // Mirror the txid:vout pointer into arc so future bulk /confirm sweeps find
+ // the prior credit via gf() fallback and don't double-credit. See
+ // feedback_apr29_double_credit_incident.md for the incident this prevents.
+ await sa(`payment:${txid}:${vout}`, id);
} else {
await s(`payment:${hash}`, id);
+ await sa(`payment:${hash}`, id);
+ }
+
+ // Idempotency guard for external settlements. The getPayment() pre-check in
+ // the lightning listener/replay is not atomic: N concurrent re-credits of the
+ // same payment all read it as uncredited before any writes the pointer, so
+ // each runs the incrBy below — the /replay double-credit race. SET NX is
+ // atomic, so only the first caller claims the settlement and credits; the rest
+ // bail here, before the incrBy. `ref` is the lightning preimage, unique per
+ // settlement; internal/fund/ecash pass ref=uid and are exempt.
+ if ([PaymentType.lightning, PaymentType.bolt12].includes(type) && ref) {
+ const claimed = await db.set(`credited:${ref}`, id, { NX: true });
+ if (!claimed) {
+ warn("duplicate credit blocked", hash, ref);
+ return;
+ }
}
const m = await db.multi();
let creditType = type;
if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning;
- if (
- [PaymentType.bitcoin, PaymentType.liquid, PaymentType.lightning].includes(
- creditType,
- )
- )
+ // Bitcoin/liquid always accrue the same-network fee credit.
+ if ([PaymentType.bitcoin, PaymentType.liquid].includes(creditType)) {
m.incrBy(
`credit:${creditType}:${uid}`,
Math.round(amount * config.fee[creditType]),
);
+ } else if (creditType === PaymentType.lightning) {
+ // Lightning FREE TIER: a lightning (incl. bolt12) receipt accrues a fee
+ // credit — so a normal deposit+withdraw is free — UNLESS this account is
+ // flagged as a high-throughput pass-through (using us to rebalance channels
+ // for free). ln-freetier-monitor.ts sets `ln:nofree:<uid>` from a 30-day
+ // round-trip analysis. It's behavioral + per-account, so it can't be dodged
+ // by splitting across sockpuppets: every puppet that rebalances gets flagged.
+ const noFree = await g(`ln:nofree:${uid}`);
+ if (!noFree)
+ m.incrBy(
+ `credit:lightning:${uid}`,
+ Math.round(amount * config.fee.lightning),
+ );
+ }
- m.set(`invoice:${inv.id}`, JSON.stringify(inv))
+ await m.set(`invoice:${inv.id}`, JSON.stringify(inv))
.set(`payment:${p.id}`, JSON.stringify(p))
.lPush(`${aid || uid}:payments`, p.id)
.incrBy(`${balanceKey}:${aid || uid}`, amount)
.set(`${aid || uid}:payments:last`, p.created)
.exec();
+ // Mirror the payment record + invoice into arc for the same protection.
+ await sa(`payment:${p.id}`, p);
+ await sa(`invoice:${inv.id}`, inv);
+
if (inv.items?.length) {
formatReceipt(inv.items, inv.currency);
p.items = inv.items;
@@ -293,13 +542,19 @@ export const credit = async ({
return p;
};
+const forwardSend = (o) => sendLightning({ ...o, exempt: true });
+// failed forwards retry with backoff; one process works the queue at a time
+setInterval(() => retryV3Forwards(forwardSend).catch(() => {}), 60_000).unref();
+
export const completePayment = async (inv, p, user) => {
const { id, autowithdraw, threshold, reserve, destination, username } = user;
let withdrawal;
- if (p.confirmed) {
+ // A name that moved to coinos v3: the receive is paid on to the v3 wallet
+ // (lib/v3forward.ts), and legacy autowithdraw stands down for it.
+ const forwarded = await maybeForwardToV3(p, user, forwardSend);
+ if (p.confirmed && !forwarded) {
if (autowithdraw) {
try {
- fail("autowithdrawals disabled");
const to = destination.trim();
const balance = await g(`balance:${id}`);
const amount = balance - reserve;
@@ -312,22 +567,32 @@ export const completePayment = async (inv, p, user) => {
};
}
} catch (e) {
- console.log(e);
withdrawal = { failed: true };
- warn(username, "autowithdraw failed", e.message);
+ warn(username, "autowithdraw failed", shortError(e.message));
}
}
}
nwcNotify(p);
notify(p, user, withdrawal);
- l(username, "received", p.type, p.amount);
+
+ squarePayment(p, user);
+
+ // completePayment runs at two lifecycle points for on-chain (bitcoin/liquid)
+ // deposits — once when the tx is first seen (pending) and again from /confirm
+ // when it confirms — plus once for instant lightning receives. Logging a bare
+ // "received" at each made a single on-chain deposit look like two receives in
+ // the logs (it never double-credited — the /confirm `p.confirmed` guard and
+ // the `seen` set prevent that; it was only noisy/misleading). Distinguish the
+ // states so the log reads as one deposit moving pending -> confirmed.
+ const stage = p.confirmed ? "received" : "receiving (pending)";
+ l(username, stage, p.type, p.amount);
callWebhook(inv, p);
};
const pay = async ({ aid = undefined, amount, to, user }) => {
if (!aid) aid = user.id;
- amount = parseInt(amount) || 0;
+ amount = Number.parseInt(amount) || 0;
let lnurl;
let pr;
if (to.includes("@") && to.includes(".")) {
@@ -341,14 +606,23 @@ const pay = async ({ aid = undefined, amount, to, user }) => {
}
const recipient = await getUser(to);
- if (recipient)
+ if (recipient) {
+ // An autowithdraw destination that resolves back to the sender is a
+ // self-send: the internal credit re-fires completePayment, which re-runs
+ // autowithdraw, and since the balance nets to the same value it never
+ // falls below threshold — an unbounded loop. Reject it here (the route's
+ // create() has the equivalent `Cannot send to self` guard; pay() lacked
+ // it, which is what let a self-pointed autowithdraw spin forever).
+ if (recipient.id === aid)
+ fail("Cannot autowithdraw to your own account");
return sendInternal({
amount,
recipient,
sender: user,
});
+ }
- const fee = Math.max(5, Math.round(amount * 0.005));
+ const fee = Math.max(5, Math.round(amount * 0.02));
if (lnurl) {
amount -= fee;
const { callback } = (await got(lnurl).json()) as any;
@@ -385,18 +659,58 @@ export const decode = async (hex) => {
const inflight = {};
export const sendOnchain = async (params) => {
let { aid, hex, rate, user, signed } = params;
- if (!aid) aid = user.id;
+ aid = await requireAccount({ aid, user });
+ const supplied = !!hex;
if (!hex) ({ hex } = await build(params));
const { tx, type } = await decode(hex);
+
const node =
aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid });
let { txid } = tx;
+ let locked = false;
+
+ // Only sign a transaction WE composed for this account. build() leaves a
+ // short-lived receipt keyed by txid; caller-supplied hex must match one.
+ // Otherwise the hot wallet signs whatever raw tx is posted to /bitcoin/send
+ // and the only thing between an arbitrary tx and the chain is the debit math
+ // below (plus the weSpent credit-side guard in /confirm) — this is the outer
+ // layer, so an unrecognized tx never reaches signRawTransactionWithWallet
+ // and never locks our UTXOs on the way to failing.
+ //
+ // Receipts survive client-side signing: build() returns an unsigned segwit
+ // tx and witness data doesn't change the txid, so the self-custody path
+ // (which posts back hex it finalized itself) matches the same receipt. They
+ // are not single-use — a replay of the identical tx is already stopped by
+ // inflight/lockUnspent/testMempoolAccept, and burning the receipt would
+ // break a legitimate retry after a transient failure.
+ if (supplied) {
+ const builder = await db.get(`build:${txid}`);
+ if (builder !== aid) fail("unrecognized tx");
+ }
try {
if (inflight[txid]) fail("payment in flight");
inflight[txid] = true;
+ // Reserve the exact inputs this tx spends. If locking fails, bitcoind is
+ // telling us an input is no longer unspent ("expected unspent output") —
+ // another send already committed it in the gap between build() (coin
+ // selection) and here. Broadcasting anyway would double-spend, and the
+ // higher-fee tx wins, silently losing the loser's funds (the replaced-tx
+ // incident). So abort hard rather than warn-and-continue; the debit hasn't
+ // happened yet and build() will pick fresh inputs on retry.
+ const lockVin = tx.vin.map(({ txid, vout }) => ({ txid, vout }));
+ if (lockVin.length) {
+ try {
+ await node.lockUnspent(false, lockVin);
+ locked = true;
+ } catch (e: any) {
+ warn("lockUnspent failed, aborting send", e.message);
+ fail("Selected inputs are no longer available, please retry");
+ }
+ }
+
if (!signed) {
if (config[type].walletpass)
await node.walletPassphrase(config[type].walletpass, 300);
@@ -411,6 +725,25 @@ export const sendOnchain = async (params) => {
const r = await node.testMempoolAccept([hex]);
if (!r[0].allowed) fail("transaction rejected");
+ // Known drain destinations. debit() checks `blocked_addresses` against
+ // its hash, which for an on-chain send is the txid, so the address itself
+ // was never matched here. Refuse before the debit: a drain operator can
+ // fund fresh throwaway accounts and sweep each one on-chain to the same
+ // address, so freezing accounts is a treadmill while the destination is
+ // constant.
+ for (const {
+ scriptPubKey: { address },
+ } of tx.vout) {
+ if (address && (await db.sIsMember("blocked_addresses", address))) {
+ const n = quiet(`blockedaddr:${user.id}`, 5 * 60 * 1000);
+ if (n !== null)
+ err(
+ `SECURITY: blocked on-chain send to ${address} by ${user.username} ${suppressed(n)}`,
+ );
+ fail("address blocked");
+ }
+ }
+
let total = 0;
let fee = 0;
let change = 0;
@@ -473,11 +806,20 @@ export const sendOnchain = async (params) => {
await s(`payment:${p.id}`, p);
await node.sendRawTransaction(hex);
+ broadcastToExplorers(hex);
delete inflight[txid];
return p;
} catch (e) {
delete inflight[txid];
+ // Release UTXOs only if WE locked them, so we don't accidentally free
+ // another concurrent send's reservation on a shared input.
+ if (locked) {
+ try {
+ const vin = tx?.vin?.map(({ txid, vout }) => ({ txid, vout })) ?? [];
+ if (vin.length) await node.lockUnspent(true, vin);
+ } catch {}
+ }
throw e;
}
};
@@ -490,10 +832,11 @@ export const sendKeysend = async ({
memo = undefined,
user,
extratlvs = undefined,
+ maxTotal = undefined,
}) => {
- fee = Math.max(parseInt(fee || amount * 0.005), 5);
+ fee = Math.max(Number.parseInt(fee || amount * 0.005), 5);
- let p = await g(`payment:${hash}`);
+ let p = await gf(`payment:${hash}`);
if (p) fail("duplicate keysend");
p = await debit({
@@ -503,19 +846,111 @@ export const sendKeysend = async ({
memo,
user,
type: PaymentType.lightning,
+ maxTotal,
});
- const r = await ln.keysend({
- destination: pubkey,
- amount_msat: amount * 1000,
- maxfee: fee * 1000,
- retry_for: 10,
- extratlvs,
- });
+ let outcome = "unknown";
- if (r.status !== "complete") reverse(p);
+ // COINOS-1: keysend randomizes its payment hash (the CLN command takes no
+ // preimage arg) and a throw yields no result object, so the caller-supplied
+ // `hash` is NOT the payment's real hash — verifying with it is what made the
+ // old code reverse on every throw. CLN keysend errors carry no payment_hash
+ // either (verified on v26.06.2: the error is {code, message, attempts}).
+ //
+ // Instead, tag the payment with our own unique `label` and note where the
+ // sendpays index stands beforehand. `wait sendpays created 0` returns the
+ // current index immediately, so on a throw we can list only sendpays created
+ // since — no full scan — and find ours by label regardless of error shape.
+ // `wait` isn't in the rpc client's generated method list, so go through the
+ // generic call(). nextvalue 0 never blocks — it returns the current index.
+ let startIndex: number | undefined;
+ try {
+ const w = await ln.call("wait", {
+ subsystem: "sendpays",
+ indexname: "created",
+ nextvalue: 0,
+ });
+ startIndex = w?.created;
+ } catch (e: any) {
+ warnThrottled("keysend: wait sendpays unavailable", e?.message ?? String(e));
+ }
- return r;
+ try {
+ const r = await ln.keysend({
+ destination: pubkey,
+ amount_msat: amount * 1000,
+ maxfee: fee * 1000,
+ retry_for: 10,
+ label: hash,
+ extratlvs,
+ });
+ warn("keysend returned", p.id, JSON.stringify({
+ status: r?.status,
+ has_preimage: !!(r?.payment_preimage ?? r?.preimage),
+ amount_sent_msat: r?.amount_sent_msat,
+ }));
+ // keysend blocks on retry_for until it settles or fails, so a successful
+ // return carries the preimage. Finalize HERE — flip the record
+ // pending->confirmed and refund the unused fee reserve — so every caller is
+ // covered. The NWC pay_keysend caller only polls listpays for the preimage
+ // to hand back to the client and never confirmed the record, which left
+ // every successful zap stuck "pending" forever (and check() can't rescue a
+ // keysend: its label isn't a bolt11, so the reconciler evicts it). Guard on
+ // complete+preimage; anything else stays pending for the caller/retry path.
+ const settledPreimage = r?.payment_preimage ?? r?.preimage;
+ if (r?.status === "complete" && settledPreimage) {
+ try {
+ await finalizeKeysend(r, p, settledPreimage);
+ outcome = "finalized-keysend";
+ } catch (e: any) {
+ warn("finalizeKeysend threw", p.id, e?.message);
+ outcome = "keysend-returned (finalize threw)";
+ }
+ } else {
+ outcome = "keysend-returned"; // not settled yet — caller polls for preimage
+ }
+ l("sendKeysend outcome", p.id, "=", outcome);
+ return r;
+ } catch (e: any) {
+ err("failed keysend", hash?.slice(0,16), "error:", shortError(e?.message));
+ try {
+ if (startIndex === undefined) {
+ // Couldn't establish the index baseline, so we can't bound the search.
+ // Never refund a payment that may have settled — leave the debit.
+ warn("keysend threw with no sendpays baseline — NOT reversing (manual review)", p.id, pubkey);
+ outcome = "unverified-after-keysend-throw (not reversed)";
+ } else {
+ const { payments = [] } = await ln.listsendpays({
+ index: "created",
+ start: startIndex,
+ });
+ const ours = payments.filter((x: any) => x.label === hash);
+ warn("sendpays after keysend-threw", p.id, JSON.stringify(ours.map((x: any) => ({ status: x.status }))));
+
+ if (ours.some((x: any) => x.status === "complete")) {
+ // Settled despite the throw — reversing here is the drain.
+ outcome = "keysend-completed-despite-throw";
+ } else if (!ours.length) {
+ // CLN records a sendpay BEFORE it sends the HTLC, so no record at all
+ // means nothing left the node (the common no-route/no-path failure).
+ // Safe — and necessary — to refund.
+ await reverse(p);
+ outcome = "reversed-after-keysend-throw (no htlc sent)";
+ } else if (ours.every((x: any) => x.status === "failed")) {
+ await reverse(p);
+ outcome = "reversed-after-keysend-throw (confirmed failed)";
+ } else {
+ // Still in flight: CLN may yet settle it, so leave the debit.
+ outcome = "pending-after-keysend-throw";
+ }
+ }
+ } catch (verifyErr: any) {
+ warnThrottled("sendpays verification failed (keysend)", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-keysend-throw";
+ }
+ warn("sendKeysend outcome", p.id, "=", outcome);
+ throw e;
+ }
};
export const sendLightning = async ({
@@ -524,30 +959,86 @@ export const sendLightning = async ({
amount,
fee = undefined,
memo = undefined,
+ payerNote = undefined,
+ maxTotal = undefined,
+ exempt = false,
}) => {
let p;
+ // BOLT11 is case-insensitive, but downstream code is not: the stored payment
+ // hash, the `pending` reconciler entry, and check()'s startsWith("ln") test
+ // all assume lowercase. An uppercase-pasted invoice gets stored uppercase,
+ // then check() treats its pending entry as non-lightning and evicts it
+ // without reversing — a failed send that never gets auto-refunded. Normalize
+ // once, up front, so every downstream use sees the same lowercase form.
+ pr = pr.replace(/\s/g, "").toLowerCase();
+
if (typeof amount !== "undefined") {
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
if (amount < 0 || amount > SATS || Number.isNaN(amount)) {
warn("invalid amount", amount);
fail("Invalid amount");
}
}
+ let decoded = await ln.decode(pr);
+
+ // A bolt12 offer (lno1...) isn't payable directly — fetch a real invoice
+ // from it over onion messages, then pay that. payerNote rides along in
+ // invreq_payer_note (NIP-177 bolt12 zaps put nostr:nip177:<intent-id> there).
+ if (decoded.type === "bolt12 offer") {
+ if (decoded.offer_currency)
+ fail("Currency-denominated offers are not supported");
+ if (!decoded.offer_amount_msat && !amount) fail("Amount required");
+
+ const { invoice } = await ln.fetchinvoice({
+ offer: pr,
+ amount_msat: decoded.offer_amount_msat ? undefined : amount * 1000,
+ payer_note: payerNote,
+ timeout: 60,
+ });
+
+ pr = invoice.replace(/\s/g, "").toLowerCase();
+ decoded = await ln.decode(pr);
+ }
+
let { type, invoice_amount_msat, amount_msat, invoice_node_id, payee } =
- await ln.decode(pr);
+ decoded;
if (type.includes("bolt12")) {
amount_msat = invoice_amount_msat;
payee = invoice_node_id;
}
- let minfee = 2;
- const { channels } = await ln.listpeerchannels();
- if (channels.some((c) => c.peer_id === payee)) minfee = 0;
+ const amt = amount_msat ? Math.round(amount_msat / 1000) : amount;
+
+ // Ask askrene up front what routing this will cost — the same query xpay
+ // runs — so the maxfee we reserve is the real fee, not a guess. A caller
+ // that names its own cap keeps it (finalize refunds whatever xpay doesn't
+ // spend), but a cap below the quoted fee is rejected here, before any
+ // debit, instead of failing inside xpay after one. If the quote itself
+ // fails (askrene unavailable, no route) fall back to the old heuristic and
+ // let xpay have a go.
+ let quoted: number | undefined;
+ try {
+ ({ fee: quoted } = await quoteRoutingFee({ pr, amount: amt }));
+ } catch (e: any) {
+ warn("route quote failed", pr.substr(-8), shortError(e?.message));
+ }
- fee = Math.max(parseInt(fee || 0), minfee);
- if (fee < 0) fail("Fee cannot be negative");
+ const explicit = Number.parseInt(fee);
+ if (Number.isFinite(explicit)) {
+ if (explicit < 0) fail("Fee cannot be negative");
+ if (quoted !== undefined && explicit < quoted)
+ fail(`Routing fee of ⚡️${quoted} required, max fee is ⚡️${explicit}`);
+ fee = explicit;
+ } else if (quoted !== undefined) {
+ fee = quoted;
+ } else {
+ let minfee = Math.max(5, Math.round(amt * 0.005));
+ const { channels } = await ln.listpeerchannels();
+ if (channels.some((c) => c.peer_id === payee)) minfee = 0;
+ fee = minfee;
+ }
const { pays } = await ln.listpays(pr);
if (pays.find((p) => p.status === "complete"))
@@ -556,6 +1047,17 @@ export const sendLightning = async ({
if (pays.find((p) => p.status === "pending"))
fail("Payment is already underway");
+ // A prior attempt at this same invoice may still be awaiting reconciliation
+ // by check() (its CLN parts resolved but the debit hasn't been reversed or
+ // finalized yet). The pending set is keyed by bolt11 and shared across
+ // attempts, so letting a retry proceed here means the retry's own
+ // reverse()/finalize() clears the shared entry and ORPHANS the first
+ // attempt's debit — check() never sees it again and the user is never
+ // refunded. Make the retry wait until reconciliation clears the entry
+ // (seconds to ~a minute).
+ if (await db.sIsMember("pending", pr))
+ fail("Previous attempt is still settling, please retry in a minute");
+
p = await debit({
hash: pr,
amount: amount_msat ? Math.round(amount_msat / 1000) : amount,
@@ -563,11 +1065,17 @@ export const sendLightning = async ({
memo,
user,
type: PaymentType.lightning,
+ maxTotal,
+ exempt,
});
await db.sAdd("pending", pr);
- l("paying lightning invoice", pr.substr(-8), amount, fee);
+ // Instrumentation: track which exit path the payment took so we can
+ // diagnose any future stuck "optimistic" cases (confirmed=true with no
+ // ref, no pending-set membership). Logged just before the function
+ // returns; if "outcome=unsettled" appears in the logs we have a leak.
+ let outcome = "unknown";
try {
const r = await ln.xpay({
@@ -575,20 +1083,133 @@ export const sendLightning = async ({
// bolt11: pr.replace(/\s/g, "").toLowerCase(),
amount_msat: amount_msat ? undefined : amount * 1000,
maxfee: fee * 1000,
- retry_for: 10,
+ // 30s wasn't enough to get past a drained zero-fee hop. Route-finding
+ // prefers free channels, so xpay would spend the whole budget retrying
+ // one 0-base/0-ppm channel that is permanently empty in our direction
+ // and time out ("64 attempts, temporary_channel_failure for the same
+ // scid") without ever trying the paths that work — Strike/Zap payments
+ // failed this way for every user. xpay is in ln.ts's NO_TIMEOUT set and
+ // nothing in front of it (fastify, Caddy, the UI fetch) imposes a
+ // deadline, so the longer budget runs to completion.
+ //
+ // COUPLED TO check(): that reversal sweep must keep skipping payments
+ // for at least twice this window, or it reverses a payment xpay is
+ // still driving and we refund one that settles (the LEAKED DEBIT
+ // losses). Change both together.
+ retry_for: 60,
+ layers: await xpayLayers(),
});
+ // Only log the xpay response when there's a real concern — no preimage
+ // at all (xpay failed silently and we'll need to reverse) is interesting;
+ // failed_parts WITH a preimage is normal multi-path retry behavior and
+ // not worth warning about. The failed_parts branch below handles the
+ // recovery without log spam in the success case.
+ if (!(r?.payment_preimage ?? r?.preimage)) {
+ warn("xpay returned no preimage", p.id, JSON.stringify({
+ status: r?.status,
+ failed_parts: r?.failed_parts,
+ parts: r?.parts,
+ amount_sent_msat: r?.amount_sent_msat,
+ amount_msat: r?.amount_msat,
+ }));
+ }
+
+ if (r.failed_parts) {
+ // xpay didn't throw but reported failed parts. Verify with listpays
+ // before deciding — for multi-path payments this is the common path
+ // and usually finalizes cleanly; the noisy warns only fire when
+ // listpays disagrees with xpay or finalize itself throws.
+ try {
+ const { pays } = await ln.listpays(pr);
+ const completed = pays.find((x) => x.status === "complete");
+ const failed = !pays.length || pays.every((x) => x.status === "failed");
+ if (completed) {
+ try { await finalize(completed, p); outcome = "finalized-via-listpays-after-failed_parts"; } catch (e: any) { warn("finalize threw in failed_parts branch", p.id, e?.message); outcome = "finalize-threw-failed_parts"; }
+ } else if (failed) {
+ warn("xpay failed_parts and listpays failed — reversing", p.id);
+ await reverse(p); outcome = "reversed-via-failed_parts";
+ } else {
+ warn("xpay failed_parts, listpays still pending — leaving for check()", p.id);
+ outcome = "pending-after-failed_parts";
+ }
+ } catch (verifyErr: any) {
+ warnThrottled("listpays verification failed (failed_parts branch)", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-failed_parts";
+ }
+ } else {
+ try {
+ p = await finalize(r, p);
+ outcome = "finalized";
+ } catch (e: any) {
+ warn("finalize threw despite no failed_parts", p.id, e?.message);
+ outcome = "finalize-threw";
+ }
+ }
+ } catch (e: any) {
+ err("failed to pay", pr.substr(-8), "xpay error:", shortError(e?.message));
try {
- if (!r.failed_parts) p = await finalize(r, p);
- } catch (e) {
- warn("failed to process payment", p.id);
+ const { pays } = await ln.listpays(pr);
+ warn("listpays after xpay-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? []));
+ const completed = pays.find((p) => p.status === "complete");
+ const failed = !pays.length || pays.every((p) => p.status === "failed");
+ if (completed) {
+ warn("payment completed despite error, finalizing", p.id);
+ try { await finalize(completed, p); outcome = "finalized-after-xpay-throw"; } catch (_) { outcome = "finalize-threw-after-xpay-throw"; }
+ } else if (failed) {
+ await reverse(p); outcome = "reversed-after-xpay-throw";
+ } else {
+ outcome = "pending-after-xpay-throw";
+ }
+ // else: pending — leave the debit in place; check() loop will reconcile
+ // once CLN confirms outcome. Reversing while still in flight would
+ // refund the user even though the payment may yet complete.
+ } catch (verifyErr: any) {
+ // listpays itself errored (CLN unreachable). We deliberately don't
+ // reverse here — the debit stays and pending stays in the reconciler
+ // set, so check() will retry once CLN is back. Surface the failure so
+ // a real outage doesn't go unnoticed.
+ warnThrottled("listpays verification failed", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-xpay-throw";
}
- } catch (e) {
- err("failed to pay", pr.substr(-8));
- reverse(p);
+ warn("sendLightning outcome", p.id, "=", outcome);
throw e;
}
+ // Leak guard: re-read the db record before deciding. If the payment has
+ // ref set in db, it's settled regardless of which code path threw —
+ // finalize() may have set ref via db.set and then thrown on a later line
+ // (e.g. balance.incrBy for the fee refund), but the payment itself is
+ // recorded as complete. Only treat as a real leak if ref is not in db.
+ const finalizedOutcomes = new Set([
+ "finalized",
+ "finalized-via-listpays-after-failed_parts",
+ "finalized-after-xpay-throw",
+ ]);
+ const safeNonFinalized = new Set([
+ "reversed-via-failed_parts",
+ "reversed-after-xpay-throw",
+ "pending-after-failed_parts",
+ "pending-after-xpay-throw",
+ "verify-failed-after-failed_parts",
+ "verify-failed-after-xpay-throw",
+ ]);
+ const persisted = await g(`payment:${p.id}`);
+ const refInDb = !!persisted?.ref;
+ if (refInDb || finalizedOutcomes.has(outcome)) {
+ // Only log when the outcome wasn't the boring success case — every
+ // payment goes through "finalized" so logging it is just noise. The
+ // recovery-via-fallback variants (finalized-via-listpays-*, etc.) are
+ // worth knowing about, so emit those.
+ if (outcome !== "finalized") {
+ l("sendLightning outcome", p.id, "=", outcome, refInDb && !finalizedOutcomes.has(outcome) ? "(ref set in db despite throw)" : "");
+ }
+ } else if (safeNonFinalized.has(outcome)) {
+ warn("sendLightning outcome", p.id, "=", outcome);
+ } else {
+ err("LEAKED DEBIT in sendLightning", p.id, "outcome=", outcome, "user=", user?.username, "amount=", amount, "fee=", fee);
+ }
+
return p;
};
@@ -598,6 +1219,7 @@ export const sendInternal = async ({
memo = undefined,
recipient,
sender,
+ maxTotal = undefined,
}) => {
if (!invoice)
invoice = await generate({
@@ -606,8 +1228,8 @@ export const sendInternal = async ({
});
const { hash } = invoice;
- const p = await debit({ hash, amount, memo, user: sender });
- await credit({ hash, amount, memo, ref: sender.id });
+ const p = await debit({ hash, amount, memo, user: sender, maxTotal });
+ await credit({ hash, amount, memo, ref: sender.id, tip: p.tip });
if (invoice.memo?.includes("9734")) {
const { invoices } = await ln.listinvoices({ invstring: hash });
@@ -628,7 +1250,16 @@ const getAddressType = async (a) => {
try {
await lq.getAddressInfo(a);
return PaymentType.liquid;
- } catch (e) {
+ } catch (e: any) {
+ // Distinguish "lq is down" from "address isn't liquid" so users get a useful
+ // message instead of "unrecognized address" when cs/lq is the actual problem.
+ if (
+ e?.message?.includes("Unable to connect") ||
+ e?.code === "ECONNREFUSED" ||
+ e?.code === "ETIMEDOUT"
+ ) {
+ fail("liquid temporarily unavailable");
+ }
fail("unrecognized address");
}
}
@@ -643,11 +1274,11 @@ export const build = async ({
user,
}) => {
const type = await getAddressType(address);
- if (!aid) aid = user.id;
+ aid = await requireAccount({ aid, user });
const node =
aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid });
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
if (amount < 0) fail("invalid amount");
const fees: any =
@@ -655,13 +1286,10 @@ export const build = async ({
? { fastestFee: 0.1, halfHourFee: 0.1, hourFee: 0.1 }
: await fetch(`${api[type]}/fees/recommended`).then((r) => r.json());
- fees.hourFee = fees.halfHourFee;
- fees.halfHourFee = fees.fastestFee;
-
if (type === PaymentType.bitcoin) {
- fees.fastestFee = Math.round(fees.fastestFee * 1.1);
- if (fees.fastestFee === fees.halfHourFee) fees.fastestFee++;
- if (fees.hourFee === fees.halfHourFee) fees.hourFee--;
+ fees.hourFee = fees.halfHourFee;
+ fees.halfHourFee = fees.fastestFee;
+ fees.fastestFee = Math.ceil(fees.fastestFee * 1.5);
}
if (!feeRate) {
@@ -723,7 +1351,12 @@ export const build = async ({
}
const inputs = [];
- const { vin } = await node.decodeRawTransaction(tx.hex);
+ const { vin, txid: built } = await node.decodeRawTransaction(tx.hex);
+
+ // Receipt for sendOnchain(): this account composed this exact tx. Bound to
+ // aid so one account can't replay another's build, and short-lived so a hex
+ // can't be banked for later.
+ await db.set(`build:${built}`, aid, { EX: 3600 });
for (const { txid, vout } of vin) {
const rawTx = await node.getRawTransaction(txid);
@@ -738,24 +1371,35 @@ export const build = async ({
inputs.push({ witnessUtxo, path });
}
+
return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract };
};
export const catchUp = async () => {
try {
const txns = [];
+ // Each chain is isolated: one being unreachable (e.g. lq when cs is down)
+ // must not stop the other chain's deposit-recovery loop.
for (const [type, n] of Object.entries({ bitcoin: bc, liquid: lq })) {
- txns.push(
- ...(await n.listTransactions("*", 10)).filter((tx) => {
- tx.type = type;
- return tx.category === "receive" && tx.confirmations > 0;
- }),
- );
+ try {
+ txns.push(
+ ...(await n.listTransactions("*", 100)).filter((tx) => {
+ tx.type = type;
+ return tx.category === "receive" && tx.confirmations > 0;
+ }),
+ );
+ } catch (e: any) {
+ warnThrottled(`catchUp ${type}`, e.message);
+ }
}
for (const { txid, type } of txns) {
try {
if (await db.zScore("seen", txid)) continue;
+ // walletnotify is the primary path; catchUp is a safety net. The
+ // double-call I added 2026-05-05 was for when walletnotify was broken
+ // on cs's lq — now that's fixed, a single call suffices and avoids
+ // contributing to /confirm race conditions.
await got.post(`http://localhost:${process.env.PORT || 3119}/confirm`, {
json: { txid, wallet: config[type].wallet, type },
});
@@ -771,66 +1415,7 @@ export const catchUp = async () => {
err("problem syncing", e.message);
}
- setTimeout(catchUp, 10000);
-};
-
-export const reconcile = async (account, initial = false) => {
- try {
- const { descriptors, id, uid, type } = account;
- const user = await getUser(uid);
- const node = rpc({ ...config[type], wallet: id });
-
- let total;
-
- if (initial) {
- const progress = await node.scanTxOutSet("status");
- if (progress) return setTimeout(() => reconcile(account, initial), 1000);
-
- const { total_amount } = await node.scanTxOutSet("start", descriptors);
- total = Math.round(total_amount * SATS);
- } else {
- total = Math.round((await node.getBalance({ minconf: 1 })) * SATS);
- }
-
- const { balanceAdjustment: memo } = t(user);
-
- const balance = await g(`balance:${id}`);
-
- const amount = Math.abs(total - balance);
- const hash = v4();
-
- if (total > balance) {
- const inv = {
- memo,
- type: PaymentType.reconcile,
- hash,
- amount,
- uid,
- aid: id,
- };
- await s(`invoice:${hash}`, inv);
- await credit({
- hash,
- amount,
- type: PaymentType.reconcile,
- aid: id,
- });
- } else if (total < balance) {
- await debit({
- aid: id,
- amount,
- hash: v4(),
- memo,
- user,
- type: PaymentType.reconcile,
- });
- }
- } catch (e) {
- console.log(e);
- warn("problem reconciling", e.message, account);
- if (e.message.includes("progress"))
- return setTimeout(() => reconcile(account, initial), 1000);
- }
+ setTimeout(catchUp, 30000);
};
export const check = async () => {
@@ -839,8 +1424,34 @@ export const check = async () => {
const payments = await db.sMembers("pending");
for (const pr of payments) {
+ // Case-insensitive: an uppercase-pasted invoice ("LNBC…") is still a
+ // lightning payment. Matching case-sensitively here used to evict such
+ // entries without reversing, stranding failed sends with no auto-refund.
+ if (!pr.toLowerCase().startsWith("ln")) {
+ await db.sRem("pending", pr);
+ continue;
+ }
const p = await getPayment(pr);
- if (!p || Date.now() - p.created < 10000) continue;
+ // Skip payments sendLightning may still be actively driving. xpay retries
+ // for 60s (retry_for: 60), during which listpays can momentarily show all
+ // attempts "failed" before the winning part lands. The old 10s threshold
+ // let check() reverse/refund such a payment mid-flight; it then completed,
+ // and sendLightning's finalize threw on the deleted record — refunding a
+ // payment that actually settled (the LEAKED DEBIT losses). Wait well past
+ // the retry window so sendLightning has finished finalize()/reverse() and
+ // removed it from `pending` before check() ever touches it. This is 2x
+ // retry_for, the same margin the 60s threshold gave the old 30s window —
+ // raise it with retry_for if that ever changes again.
+ // No record anywhere (main db or archive) means there's nothing left to
+ // reconcile — the debit was already reversed or the entry is a stale
+ // stray. Drop it: sendLightning now refuses to re-pay an invoice while
+ // its bolt11 sits in this set, so a dead entry would otherwise block
+ // that invoice forever.
+ if (!p) {
+ await db.sRem("pending", pr);
+ continue;
+ }
+ if (Date.now() - p.created < 120000) continue;
const { pays } = await ln.listpays(pr);
const failed = !pays.length || pays.every((p) => p.status === "failed");
@@ -851,13 +1462,16 @@ export const check = async () => {
else if (failed) await reverse(p);
} catch (e) {
err("failed to finalize", p.id, e.message);
+ if (e.message?.includes("already been reversed")) {
+ await db.sRem("pending", p.hash);
+ }
}
}
} catch (e) {
err("payment check failed", e.message);
}
- setTimeout(check, 2000);
+ setTimeout(check, 5000);
};
const finalize = async (r, p) => {
@@ -866,21 +1480,89 @@ const finalize = async (r, p) => {
if (!preimage) fail("missing preimage");
await db.sRem("pending", p.hash);
- l("payment completed", p.id, r.payment_preimage);
nwcNotify(p);
const maxfee = p.fee;
- const { amount_msat } = await ln.decode(p.hash);
- p.fee = Math.round((r.amount_sent_msat - amount_msat) / 1000);
+ // Compute the actual fee xpay paid (sent − invoice amount, both msat).
+ // Robust against zero-amount invoices (decoded amount_msat is undefined,
+ // fall back to r.amount_msat) and against CLN occasionally returning
+ // unexpected response shapes — if we can't compute a finite integer
+ // fee, keep p.fee at the originally-reserved maxfee so the subsequent
+ // refund delta is 0 instead of NaN (which would crash db.incrBy with
+ // "value is not an integer or out of range" and leave finalize in a
+ // partial state).
+ const decoded = await ln.decode(p.hash);
+ const invMsat = Number(decoded.amount_msat ?? r.amount_msat);
+ const sentMsat = Number(r.amount_sent_msat);
+ // Round UP: the routing fee was quoted and reserved in whole sats (a
+ // 301 msat route is a 1 sat maxfee — xpay's cap is exact and a few msat
+ // short fails the payment), so charge the sat that was quoted rather than
+ // rounding 301 msat to 0 and refunding it. Otherwise a "send everything"
+ // computed from the quote strands a single sat in the account.
+ const computedFee = Math.ceil((sentMsat - invMsat) / 1000);
+ if (!Number.isFinite(computedFee)) {
+ warn("finalize: non-finite fee compute, keeping reserved maxfee", p.id,
+ "sent_msat=", r.amount_sent_msat, "inv_msat=", decoded.amount_msat ?? r.amount_msat);
+ }
+ p.fee = Number.isFinite(computedFee) ? computedFee : maxfee;
p.ref = preimage;
+ // The HTLC settled (we have the preimage) — the send is now fully confirmed.
+ p.confirmed = true;
+
+ const current = await g(`payment:${p.id}`);
+ // The record is gone because a concurrent reverse() already refunded this
+ // payment — yet here we are finalizing it, so it actually COMPLETED on the
+ // network and the refund was wrong (coinos is now short the amount). Do NOT
+ // silently re-create the record; raise a clear, greppable error so the
+ // LEAKED DEBIT guard logs it for manual recovery instead of a cryptic
+ // null-deref. (The check()-window fix should make this path very rare.)
+ if (!current) fail(`finalize: payment ${p.id} reversed-then-completed (double-pay leak)`);
- if (!(await g(`payment:${p.id}`)).ref) {
+ if (!current.ref) {
await s(`payment:${p.id}`, p);
+ const refund = maxfee - p.fee;
+ if (Number.isFinite(refund) && Number.isInteger(refund)) {
+ await db.incrBy(`balance:${p.uid}`, refund);
+ } else {
+ warn("finalize: skipping fee refund (non-integer delta)", p.id,
+ "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund);
+ }
+ }
- l("refunding fee", maxfee, p.fee, maxfee - p.fee, p.ref);
- await db.incrBy(`balance:${p.uid}`, maxfee - p.fee);
+ return p;
+};
+
+// Keysend variant of finalize(). Keysend has no bolt11, so finalize()'s
+// `ln.decode(p.hash)` can't run — the real fee is instead the msat actually sent
+// minus the record's own amount. Otherwise identical: flip pending->confirmed,
+// set the preimage, refund the unused fee reserve, and refuse to re-create a
+// record a concurrent reverse() already removed (a settled-but-refunded payment
+// is a double-pay leak, surfaced for manual recovery rather than papered over).
+const finalizeKeysend = async (r, p, preimage) => {
+ await db.sRem("pending", p.hash); // keysend rarely enters the set; defensive
+
+ const maxfee = p.fee;
+ const sentMsat = Number(r.amount_sent_msat);
+ const actualFee = Math.ceil((sentMsat - Math.abs(p.amount) * 1000) / 1000);
+ p.fee = Number.isFinite(actualFee) && actualFee >= 0 ? actualFee : maxfee;
+ p.ref = preimage;
+ p.confirmed = true;
+
+ const current = await g(`payment:${p.id}`);
+ if (!current) fail(`finalizeKeysend: payment ${p.id} reversed-then-completed (double-pay leak)`);
+
+ if (!current.ref) {
+ await s(`payment:${p.id}`, p);
+ const refund = maxfee - p.fee;
+ if (Number.isFinite(refund) && Number.isInteger(refund)) {
+ await db.incrBy(`balance:${p.uid}`, refund);
+ } else {
+ warn("finalizeKeysend: skipping fee refund (non-integer delta)", p.id,
+ "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund);
+ }
}
+ nwcNotify(p);
return p;
};
@@ -889,7 +1571,13 @@ const reverse = async (p) => {
const total = Math.abs(p.amount) + p.fee + p.ourfee;
const ourfee = p.ourfee || 0;
- const credit = Math.round(total * config.fee[PaymentType.lightning]) - ourfee;
+ // Restore the free-tier credit consumed at debit time. Use the SAME rate the
+ // send was charged at — flagged `ln:nofree` accounts were charged the elevated
+ // rate, so reversing with the standard rate would push their credit negative.
+ const rate = (await g(`ln:nofree:${p.uid}`))
+ ? config.fee.lightningHigh
+ : config.fee.lightning;
+ const credit = Math.round(total * rate) - ourfee;
l("reversing", p.id, p.amount, p.fee, total, ourfee, credit);
@@ -909,27 +1597,70 @@ const reverse = async (p) => {
};
const freezeCheck = async () => {
- const funds = await ln.listfunds();
- const lnbalance = Math.round(
- funds.channels.reduce((a, b) => a + b.our_amount_msat, 0) / 1000,
- );
-
- const bcbalance = Math.round((await bc.getBalance()) * SATS);
- const { bitcoin } = await lq.getBalance();
- const lqbalance = Math.round(bitcoin * SATS);
-
- const lnthreshold = await g("lightning:threshold");
- const bcthreshold = await g("bitcoin:threshold");
- const lqthreshold = await g("liquid:threshold");
+ // Crash-resilient loop: ANY error in the body (an unhandled rejection in the
+ // limit-write section, a stuck withLimitLock, etc.) used to escape here and
+ // kill the loop silently (the global unhandledRejection handler is a no-op),
+ // freezing every ${type}:limit until someone noticed sends failing. Wrap the
+ // whole body so errors are logged and the next tick is ALWAYS scheduled.
+ try {
+ // Each asset is fetched independently so e.g. lq being unreachable doesn't
+ // prevent lightning and bitcoin limits from refreshing.
+ let lnbalance: number | undefined;
+ try {
+ const funds = await ln.listfunds();
+ // Only spendable channels — ONCHAIN/CLOSING funds are locked in pending
+ // sweeps and aren't usable for lightning sends, so counting them inflates
+ // the limit. CHANNELD_AWAITING_SPLICE is included so an in-flight splice
+ // doesn't briefly drop the limit to zero.
+ lnbalance = Math.round(
+ funds.channels
+ .filter((c) =>
+ c.state === "CHANNELD_NORMAL" || c.state === "CHANNELD_AWAITING_SPLICE",
+ )
+ .reduce((a, b) => a + b.our_amount_msat, 0) / 1000,
+ );
+ } catch (e: any) {
+ warnThrottled("freezeCheck lightning", e.message);
+ }
- await s("lightning:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0));
- await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0));
+ let bcbalance: number | undefined;
+ try {
+ bcbalance = Math.round((await bc.getBalance()) * SATS);
+ } catch (e: any) {
+ warnThrottled("freezeCheck bitcoin", e.message);
+ }
- await s("fund:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("ecash:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("bolt12:limit", Math.max(lnbalance - lnthreshold, 0));
+ let lqbalance: number | undefined;
+ try {
+ const { bitcoin } = await lq.getBalance();
+ lqbalance = Math.round(bitcoin * SATS);
+ } catch (e: any) {
+ warnThrottled("freezeCheck liquid", e.message);
+ }
- setTimeout(freezeCheck, 10000);
+ if (lnbalance !== undefined) {
+ const lnthreshold = await g("lightning:threshold");
+ const lim = Math.max(lnbalance - lnthreshold, 0);
+ for (const t of ["lightning", "fund", "ecash", "bolt12"]) {
+ await withLimitLock(t, async () => { await s(`${t}:limit`, lim); });
+ }
+ }
+ if (bcbalance !== undefined) {
+ const bcthreshold = await g("bitcoin:threshold");
+ await withLimitLock("bitcoin", async () => {
+ await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0));
+ });
+ }
+ if (lqbalance !== undefined) {
+ const lqthreshold = await g("liquid:threshold");
+ await withLimitLock("liquid", async () => {
+ await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0));
+ });
+ }
+ } catch (e: any) {
+ warn("freezeCheck: iteration failed, will retry next tick:", e?.message ?? String(e));
+ } finally {
+ setTimeout(freezeCheck, 10000);
+ }
};
freezeCheck();
diff --git a/lib/rates.ts b/lib/rates.ts
index dcb44464..21a075b3 100644
--- a/lib/rates.ts
+++ b/lib/rates.ts
@@ -8,6 +8,18 @@ import WebSocket from "ws";
export let rate;
let last;
let ws;
+
+// The Iranian market IRR is fetched on its own timer rather than on every binance
+// tick — fetching inline hammered the source and, when it's unreachable (recurring
+// geo-block / outage), failed + logged on every tick. We cache the last-good value
+// and apply it in onmessage; the tick loop never blocks on it.
+// Source: Wallex (api.wallex.ir) BTCTMN last price. We switched off Nobitex on
+// 2026-07-10 after apiv2.nobitex.ir became unreachable from our egress (its
+// Iranian IPs refuse foreign connections); Wallex's API is reachable and its
+// BTCTMN cross-checks against BTCUSDT × USDTTMN to <0.1%.
+let iranIrr = 0;
+let iranIrrTime = 0;
+let iranErrLogged = 0;
const connect = async () => {
if (ws && ws.readyState === 1 && Date.now() - last < 5000) return;
if (ws) ws.terminate() && (await sleep(Math.round(Math.random() * 1000)));
@@ -25,20 +37,20 @@ const connect = async () => {
rates[symbol] = msg.c * fx[symbol];
});
- try {
- rates.IRT = (
- (await got(
- "https://api.nobitex.ir/v2/orderbook/BTCIRT",
- ).json()) as any
- ).lastTradePrice;
- } catch (e) {}
+ // Apply the Iranian market IRR cached by updateIranRate() below (Wallex).
+ // iranIrr is stored in Rial, so IRR = iranIrr and Toman = Rial / 10. If the
+ // source has been unreachable for over an hour, we leave the fixer official
+ // IRR set in the fx loop above rather than serving an ever-staler market rate.
+ if (iranIrr > 0 && Date.now() - iranIrrTime < 60 * 60 * 1000) {
+ rates.IRR = iranIrr;
+ rates.IRT = iranIrr / 10;
+ }
rate = msg.c;
s("rate", rate);
s("rates", rates);
last = Date.now();
} catch (e) {
- console.log(e);
err("binance message error", e.message);
}
};
@@ -50,6 +62,31 @@ const connect = async () => {
return ws;
};
+// Fetch the Iranian market rate from Wallex on its own 60s timer, decoupled from
+// the binance tick loop. Wallex quotes BTCTMN in Toman, so IRR (Rial) = price*10.
+// Caches the last-good value; on failure (recurring geo-block / outage) throttles
+// error logging to once per 5 min instead of spamming on every attempt. A 5s
+// request timeout keeps a hung endpoint from piling up.
+const updateIranRate = async () => {
+ try {
+ const data = (await got("https://api.wallex.ir/v1/markets", {
+ timeout: { request: 5000 },
+ }).json()) as any;
+ const tmn = Number(data?.result?.symbols?.BTCTMN?.stats?.lastPrice);
+ if (tmn > 0) {
+ iranIrr = tmn * 10; // Toman -> Rial
+ iranIrrTime = Date.now();
+ }
+ } catch (e) {
+ if (Date.now() - iranErrLogged > 5 * 60 * 1000) {
+ err("Iran IRR/IRT rate fetch failed (wallex)", e.message);
+ iranErrLogged = Date.now();
+ }
+ }
+ setTimeout(updateIranRate, 60000);
+};
+updateIranRate();
+
export const getFx = async () => {
connect();
diff --git a/lib/register.ts b/lib/register.ts
index 0db5dfd0..a9e9d7f7 100644
--- a/lib/register.ts
+++ b/lib/register.ts
@@ -2,7 +2,9 @@ import config from "$config";
import countries from "$lib/countries";
import { db, s } from "$lib/db";
import { l, warn } from "$lib/logging";
-import { fail, getUser } from "$lib/utils";
+import { fail } from "$lib/utils";
+import { assertNameFree } from "$lib/names";
+import { isReserved } from "$lib/reserved";
import { bytesToHex, randomBytes } from "@noble/hashes/utils";
import { got } from "got";
import { getPublicKey, nip19 } from "nostr-tools";
@@ -19,20 +21,26 @@ export default async (user, ip) => {
username = username.toLowerCase().replace(/\s/g, "");
if (!valid.test(username))
fail("Usernames can only have letters and numbers");
+ if (isReserved(username)) fail("Invalid username");
+ if (username.includes("undefined")) fail("Invalid username");
const id = v4();
user.id = id;
const exists = await db.exists(`user:${username}`);
if (exists) fail(`Username ${username} taken`);
+ // A name the v3 registrar holds is taken too (see lib/names.ts).
+ await assertNameFree(username, pubkey);
if (password) {
user.password = await Bun.password.hash(password, {
algorithm: "bcrypt",
- cost: 4,
+ cost: 12,
});
}
+ user.created = Date.now();
+
user.currency = "USD";
if (config.ipregistry) {
try {
@@ -43,6 +51,7 @@ export default async (user, ip) => {
).json();
user.currency = countries[code];
+ user.country = code; // stored for analytics (signup geography)
} catch (e) {
warn("unable to detect country from IP", username);
}
@@ -85,8 +94,6 @@ export default async (user, ip) => {
await s(`app:${app.pubkey}`, app);
await db.sAdd(`${id}:apps`, app.pubkey);
- console.log("USER", user);
-
db.multi()
.set(`user:${id}`, JSON.stringify(user))
.set(`user:${username}`, id)
diff --git a/lib/reserved.ts b/lib/reserved.ts
new file mode 100644
index 00000000..df5f0d43
--- /dev/null
+++ b/lib/reserved.ts
@@ -0,0 +1,31 @@
+// Usernames nobody may register or rename into. A coinos username is also a
+// lightning address, so a name that reads as ours lets an account collect
+// payments meant for the service: "mint" was claimed within days of the ecash
+// redesign freeing it (2026-09-18) and took payments addressed to
+// [email protected] until it was renamed.
+//
+// Checked on registration (lib/register.ts) and on rename (routes/users.ts).
+// Names already held by the v3 registrar are refused separately, by
+// assertNameFree in lib/names.ts.
+export const reserved = [
+ "admin",
+ "administrator",
+ "ark",
+ "cash",
+ "coinos",
+ "ecash",
+ "fund",
+ "help",
+ "info",
+ "lightning",
+ "mint",
+ "pool",
+ "root",
+ "security",
+ "support",
+ "system",
+ "wallet",
+];
+
+export const isReserved = (username: string) =>
+ reserved.includes(String(username || "").toLowerCase().replace(/\s/g, ""));
diff --git a/lib/safe-fetch.ts b/lib/safe-fetch.ts
new file mode 100644
index 00000000..5b1059e9
--- /dev/null
+++ b/lib/safe-fetch.ts
@@ -0,0 +1,165 @@
+// SSRF-resistant fetch for user-supplied URLs (lnurl / lightning-address
+// resolution in routes/lnurl.ts). Rejects any host that resolves to a
+// loopback, private, link-local, CGNAT, or cloud-metadata address.
+//
+// Unlike a resolve-then-fetch guard (which re-resolves DNS when it actually
+// connects, leaving a DNS-rebinding TOCTOU window), this pins the socket to the
+// exact IP it validated: the connection's DNS lookup IS the validation, in one
+// callback, so a low-TTL rebind cannot swap in an internal IP after the check.
+// Redirects are followed manually and each hop is re-validated the same way.
+// Scheme is restricted to http(s); 8s timeout; 2MB streamed-body cap.
+import http from "node:http";
+import https from "node:https";
+import dns from "node:dns";
+import net from "node:net";
+
+const MAX_BYTES = 2_000_000;
+const MAX_REDIRECTS = 4;
+const TIMEOUT_MS = 8000;
+
+const blockedV4 = (ip: string): boolean => {
+ const p = ip.split(".").map(Number);
+ if (p.length !== 4 || p.some((n) => Number.isNaN(n))) return true;
+ const [a, b] = p;
+ if (a === 0) return true; // 0.0.0.0/8
+ if (a === 10) return true; // private
+ if (a === 127) return true; // loopback
+ if (a === 169 && b === 254) return true; // link-local + cloud metadata (169.254.169.254)
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
+ if (a === 192 && b === 168) return true; // private
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10 (incl. tailscale)
+ if (a >= 224) return true; // multicast / reserved
+ return false;
+};
+
+// Expand an IPv6 address to its 8 16-bit groups. Handles "::" and a trailing
+// dotted quad. new URL() re-serializes IPv6 hosts in hex, so "[::ffff:10.0.0.1]"
+// arrives here as "::ffff:a00:1" — matching on the text form is not enough.
+const v6Groups = (ip: string): number[] | null => {
+ let s = ip.toLowerCase().split("%")[0];
+ const quad = s.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
+ if (quad) {
+ const [a, b, c, d] = quad.slice(1).map(Number);
+ s = `${s.slice(0, quad.index)}${((a << 8) | b).toString(16)}:${((c << 8) | d).toString(16)}`;
+ }
+ const halves = s.split("::");
+ if (halves.length > 2) return null;
+ const part = (h: string) => (h ? h.split(":").map((x) => Number.parseInt(x, 16)) : []);
+ const head = part(halves[0]);
+ const tail = halves.length === 2 ? part(halves[1]) : [];
+ const fill = 8 - head.length - tail.length;
+ if (fill < 0 || (halves.length === 1 && fill !== 0)) return null;
+ const g = [...head, ...Array(fill).fill(0), ...tail];
+ return g.some((n) => Number.isNaN(n) || n < 0 || n > 0xffff) ? null : g;
+};
+
+const v4Of = (hi: number, lo: number) =>
+ `${hi >> 8}.${hi & 0xff}.${lo >> 8}.${lo & 0xff}`;
+
+const blockedV6 = (ip: string): boolean => {
+ const g = v6Groups(ip);
+ if (!g) return true;
+ const zero = (from: number, to: number) => g.slice(from, to).every((n) => n === 0);
+ if (zero(0, 8)) return true; // :: unspecified
+ if (zero(0, 7) && g[7] === 1) return true; // ::1 loopback
+ // Every form that carries an IPv4 address is judged by that address.
+ if (zero(0, 5) && g[5] === 0xffff) return blockedV4(v4Of(g[6], g[7])); // ::ffff:0:0/96 mapped
+ if (zero(0, 4) && g[4] === 0xffff && g[5] === 0) return blockedV4(v4Of(g[6], g[7])); // ::ffff:0:0:0/96 translated
+ if (zero(0, 6)) return blockedV4(v4Of(g[6], g[7])); // ::/96 IPv4-compatible
+ if (g[0] === 0x64 && g[1] === 0xff9b) return blockedV4(v4Of(g[6], g[7])); // 64:ff9b::/96, 64:ff9b:1::/48 NAT64
+ if (g[0] === 0x2002) return blockedV4(v4Of(g[1], g[2])); // 2002::/16 6to4
+ if (g[0] === 0x2001 && g[1] === 0) return true; // 2001::/32 Teredo
+ if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link-local
+ if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site-local
+ if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 ULA
+ if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
+ if (g[0] === 0x0100 && zero(1, 4)) return true; // 100::/64 discard
+ return false;
+};
+
+const isBlockedIp = (ip: string): boolean => {
+ if (net.isIPv4(ip)) return blockedV4(ip);
+ if (net.isIPv6(ip)) return blockedV6(ip);
+ return true; // not a recognizable IP -> block
+};
+
+// A drop-in for the Node dns.lookup used by http(s).request: resolves the host,
+// rejects the whole connection if ANY resolved address is blocked (so an
+// attacker can't return [public, private] and hope the stack picks the private
+// one), and otherwise hands the socket the first allowed address. Because the
+// address returned here is the one the socket connects to, there is no
+// resolve/connect gap for a rebind to slip through.
+const pinnedLookup = (
+ hostname: string,
+ options: any,
+ cb: (err: Error | null, address?: any, family?: number) => void,
+): void => {
+ // hostname may be an IP literal (redirect to a raw IP) — validate directly.
+ if (net.isIP(hostname)) {
+ if (isBlockedIp(hostname)) return cb(new Error(`blocked host ${hostname}`));
+ const fam = net.isIPv6(hostname) ? 6 : 4;
+ return cb(null, options?.all ? [{ address: hostname, family: fam }] : hostname, fam);
+ }
+ dns.lookup(hostname, { all: true }, (err, addrs: any[]) => {
+ if (err) return cb(err);
+ if (!addrs?.length) return cb(new Error(`cannot resolve ${hostname}`));
+ const bad = addrs.find((a) => isBlockedIp(a.address));
+ if (bad) return cb(new Error(`blocked host ${hostname} -> ${bad.address}`));
+ const a = addrs[0];
+ if (options?.all) cb(null, [{ address: a.address, family: a.family }]);
+ else cb(null, a.address, a.family);
+ });
+};
+
+const fetchOnce = (url: string, redirectsLeft: number): Promise<any> =>
+ new Promise((resolve, reject) => {
+ let u: URL;
+ try { u = new URL(url); } catch { return reject(new Error("invalid url")); }
+ if (u.protocol !== "http:" && u.protocol !== "https:")
+ return reject(new Error("unsupported scheme"));
+
+ // CRITICAL: Node skips the custom `lookup` when the URL host is already an
+ // IP literal, so pinnedLookup alone would not see a raw-IP SSRF target.
+ // Validate any literal host up front (strip IPv6 brackets first).
+ const literal = u.hostname.replace(/^\[|\]$/g, "");
+ if (net.isIP(literal) && isBlockedIp(literal))
+ return reject(new Error(`blocked host ${literal}`));
+
+ const mod = u.protocol === "https:" ? https : http;
+ const req = mod.request(
+ url,
+ { method: "GET", lookup: pinnedLookup as any, headers: { accept: "application/json" } },
+ (res) => {
+ const status = res.statusCode || 0;
+
+ // manual redirect handling — re-validate each hop via pinnedLookup
+ if (status >= 300 && status < 400 && res.headers.location) {
+ res.resume(); // drain
+ if (redirectsLeft <= 0) return reject(new Error("too many redirects"));
+ let next: string;
+ try { next = new URL(res.headers.location, url).toString(); }
+ catch { return reject(new Error("bad redirect target")); }
+ return resolve(fetchOnce(next, redirectsLeft - 1));
+ }
+
+ let len = 0;
+ const chunks: Buffer[] = [];
+ res.on("data", (c: Buffer) => {
+ len += c.length;
+ if (len > MAX_BYTES) { req.destroy(); reject(new Error("response too large")); return; }
+ chunks.push(c);
+ });
+ res.on("end", () => {
+ try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
+ catch { reject(new Error("invalid json response")); }
+ });
+ },
+ );
+ req.setTimeout(TIMEOUT_MS, () => req.destroy(new Error("request timed out")));
+ req.on("error", reject);
+ req.end();
+ });
+
+// Fetch a user-supplied URL with SSRF protection and return the parsed JSON
+// body. Throws on a bad scheme/URL or a host that resolves into a blocked range.
+export const safeGot = async (url: string): Promise<any> => fetchOnce(url, MAX_REDIRECTS);
diff --git a/lib/sockets.ts b/lib/sockets.ts
index 028551ed..610d38fd 100644
--- a/lib/sockets.ts
+++ b/lib/sockets.ts
@@ -1,9 +1,21 @@
+import config from "$config";
import store from "$lib/store";
import jwt from "jsonwebtoken";
import { v4 } from "uuid";
import { err, warn } from "$lib/logging";
import { fail, getUser } from "$lib/utils";
+// Verify the token's signature — never trust jwt.decode() here. A decode-only
+// gate let anyone forge {id: <victim uid>} (alg:none or any key) and register
+// their socket under the victim's uid, receiving every emit for that account.
+const verifyToken = (token) => {
+ try {
+ return jwt.verify(token, config.jwt);
+ } catch (e) {
+ return null;
+ }
+};
+
const code = 1000;
const all = {};
const subscriptions = [];
@@ -23,6 +35,9 @@ export const emit = (uid, type, data) => {
if (!store.sockets[uid]) return;
for (const id in store.sockets[uid]) {
const ws = store.sockets[uid][id];
+ // Read-only sockets (POS terminals logged in with a "-ro" token) only
+ // get payment notifications, mirroring the HTTP read-only whitelist.
+ if (ws.readonly && type !== "payment") continue;
ws.send(JSON.stringify({ type, data }));
}
};
@@ -44,9 +59,16 @@ export const broadcast = (type, data) => {
const track = async (ws, token) => {
const { id } = ws;
- const { id: uid } = jwt.decode(token);
+ let { id: uid } = verifyToken(token) || {};
if (!uid) fail("Invalid JWT token");
+
+ // Read-only tokens (see users.ro) carry "<uid>-ro". The HTTP auth layer
+ // strips the suffix for whitelisted routes; do the same here so a POS
+ // device can log in and receive payment events, but nothing else.
+ const readonly = uid.endsWith("-ro");
+ if (readonly) uid = uid.slice(0, -3);
+
const user = await getUser(uid);
if (!user) fail(`User not found ${uid}`);
@@ -61,6 +83,7 @@ const track = async (ws, token) => {
store.sockets[uid][id] = ws;
users[id] = uid;
ws.user = user;
+ ws.readonly = readonly;
};
Bun.serve({
@@ -97,7 +120,7 @@ Bun.serve({
case "login":
try {
- if (!data || data === "null" || !jwt.decode(data))
+ if (!data || data === "null" || !verifyToken(data))
return ws.close(code, `bad token ${data}`);
await track(ws, data);
diff --git a/lib/spend-budget.ts b/lib/spend-budget.ts
new file mode 100644
index 00000000..3f2a70ac
--- /dev/null
+++ b/lib/spend-budget.ts
@@ -0,0 +1,39 @@
+const number = (value: unknown) => {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : 0;
+};
+
+export const debitTotal = ({
+ amount,
+ tip = 0,
+ fee = 0,
+ ourfee = 0,
+}: {
+ amount: unknown;
+ tip?: unknown;
+ fee?: unknown;
+ ourfee?: unknown;
+}) => number(amount) + number(tip) + number(fee) + number(ourfee);
+
+// Enforce a caller-specific spending allowance at debit's final choke point,
+// after mutable invoice metadata (notably an internal invoice tip) and fees have
+// been captured. Checking earlier lets a recipient mutate the tip between the
+// NWC budget check and the ledger debit.
+export const assertWithinSpendLimit = ({
+ amount,
+ tip = 0,
+ fee = 0,
+ ourfee = 0,
+ maxTotal,
+}: {
+ amount: unknown;
+ tip?: unknown;
+ fee?: unknown;
+ ourfee?: unknown;
+ maxTotal?: number;
+}) => {
+ const total = debitTotal({ amount, tip, fee, ourfee });
+ if (maxTotal !== undefined && total > maxTotal)
+ throw new Error(`Budget exceeded: ${total} of ${maxTotal} remaining`);
+ return total;
+};
diff --git a/lib/square.ts b/lib/square.ts
new file mode 100644
index 00000000..272d9872
--- /dev/null
+++ b/lib/square.ts
@@ -0,0 +1,55 @@
+import config from "$config";
+import { g, s } from "$lib/db";
+import { fiat } from "$lib/utils";
+import { SquareClient } from "square";
+import { v4 } from "uuid";
+
+const { appId, environment } = config.square;
+
+export const squarePayment = async (p, user) => {
+ let square = await g(`${user.id}:square`);
+ if (!(square && user.syncSquare)) return;
+
+ let client = new SquareClient({
+ environment,
+ });
+
+ const { refreshToken } = square;
+
+ square = await client.oAuth.obtainToken({
+ clientId: appId,
+ grantType: "refresh_token",
+ refreshToken,
+ });
+
+ await s(`${user.id}:square`, square);
+
+ const { accessToken } = square;
+
+ client = new SquareClient({
+ token: accessToken,
+ environment,
+ });
+
+ const locs = await client.locations.list({});
+
+ await client.payments.create({
+ sourceId: "EXTERNAL",
+ idempotencyKey: v4(),
+ amountMoney: {
+ amount: BigInt(parseFloat(fiat(p.amount, p.rate).toFixed(2)) * 100),
+ currency: p.currency,
+ },
+ tipMoney: p.tip
+ ? {
+ amount: BigInt(parseFloat(fiat(p.tip, p.rate).toFixed(2)) * 100),
+ currency: p.currency,
+ }
+ : undefined,
+ autocomplete: true,
+ externalDetails: { type: "OTHER", source: `Coinos payment ${p.id}` },
+ locationId: locs.locations[0].id,
+ referenceId: v4(),
+ note: `Coinos payment ${p.id}`,
+ });
+};
diff --git a/lib/types.ts b/lib/types.ts
index e878dfb8..802fe995 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -15,7 +15,6 @@ export interface User {
follows: string;
haspin: string;
hasprinter: string;
- hidepay: string;
id: string;
index: string;
keys: string;
diff --git a/lib/upload.ts b/lib/upload.ts
index ed06d7e0..bad9735e 100644
--- a/lib/upload.ts
+++ b/lib/upload.ts
@@ -1,31 +1,68 @@
import { createHash } from "crypto";
import { writeFileSync } from "fs";
-import { err } from "$lib/logging";
+import { db } from "$lib/db";
+import { err, warn } from "$lib/logging";
import { bail, fail } from "$lib/utils";
+import { convert } from "$lib/image";
import { fileTypeFromBuffer } from "file-type";
-const sharp = require("sharp");
+// Only the formats whose decoders we are willing to expose to arbitrary
+// authenticated input. Decoding happens in a throwaway child process
+// (lib/image.ts) since 2026-09-26, when a stream of corrupt HEIF files
+// crash-looped the server via heap corruption inside libheif —
+// but a crashing decoder is still a decoder we'd rather not run at all.
+// HEIF/AVIF/TIFF/SVG/PDF never get past the sniff. The previous check let
+// any image/* subtype through.
+const ALLOWED = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]);
+
+// Per-account budget. Avatars/banners change a handful of times, ever; the
+// fuzzer was pushing 10+ a minute.
+const RATE_MAX = 10;
+const RATE_WINDOW_S = 600;
+
+const clientIp = (req) => req.headers["cf-connecting-ip"] || req.ip;
export default async (req, res) => {
+ const { user } = req;
try {
const {
params: { type },
} = req;
+ // A `blacklist` freeze only blocks sends; the JWT still works everywhere
+ // else, which is how a frozen account kept crashing us. Uploads are pure
+ // attack surface for a frozen account, so refuse them outright.
+ const uname = user?.username?.toLowerCase().trim();
+ const blacklisted =
+ (await db.sIsMember("blacklist", user.id)) ||
+ (await db.sIsMember("blacklist", uname));
+ const whitelisted = await db.sIsMember("whitelist", uname);
+ if (blacklisted && !whitelisted) {
+ warn(`UPLOAD_REJECTED frozen ${user.username} ${clientIp(req)}`);
+ fail("unauthorized");
+ }
+
+ const rk = `upload:rl:${user.id}`;
+ const n = await db.incr(rk);
+ if (n === 1) await db.expire(rk, RATE_WINDOW_S);
+ if (n > RATE_MAX) {
+ warn(`UPLOAD_REJECTED ratelimit ${user.username} ${clientIp(req)} ${n}`);
+ fail("too many uploads, try again later");
+ }
+
const data = await req.file();
let buf = await data.toBuffer();
- const [format, ext] = (await fileTypeFromBuffer(buf)).mime.split("/");
-
- if (format !== "image" && !["jpg", "jpeg", "png"].includes(ext))
+ const ft = await fileTypeFromBuffer(buf);
+ if (!ft || !ALLOWED.has(ft.mime)) {
+ warn(
+ `UPLOAD_REJECTED type ${user.username} ${clientIp(req)} ${ft?.mime || "unknown"} ${buf.length}`,
+ );
fail("unsupported file type");
+ }
const w = type === "banner" ? 1920 : 240;
- buf = await sharp(buf, { failOnError: false })
- .rotate()
- .resize(w)
- .webp()
- .toBuffer();
+ buf = await convert(buf, w);
const hash = createHash("sha256").update(buf).digest("hex");
@@ -34,7 +71,7 @@ export default async (req, res) => {
res.send({ hash });
} catch (e) {
- err("problem uploading", e.message);
+ err("problem uploading", user?.username, e.message);
bail(res, e.message);
}
};
diff --git a/lib/utils.ts b/lib/utils.ts
index 018dc92e..539258a4 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,5 +1,5 @@
import config from "$config";
-import { g } from "$lib/db";
+import { g, gf } from "$lib/db";
import locales from "$lib/locales/index";
const { URL } = process.env;
@@ -57,15 +57,15 @@ export const getUser = async (username, fields = undefined) => {
};
export const getInvoice = async (hash) => {
- let iid = await g(`invoice:${hash}`);
+ let iid = await gf(`invoice:${hash}`);
if (iid?.id) iid = iid.id;
else if (iid?.hash) iid = iid.hash;
- return await g(`invoice:${iid}`);
+ return await gf(`invoice:${iid}`);
};
export const getPayment = async (id) => {
- let p = await g(`payment:${id}`);
- if (typeof p === "string") p = await g(`payment:${p}`);
+ let p = await gf(`payment:${id}`);
+ if (typeof p === "string") p = await gf(`payment:${p}`);
return p;
};
@@ -172,9 +172,10 @@ export const fields = [
"currency",
"display",
"id",
- "hidepay",
"lud16",
"memoPrompt",
+ // a v3-migrated account: the names registrar owns this name for receiving
+ "migrated",
"npub",
"picture",
"prompt",
diff --git a/lib/v3forward.ts b/lib/v3forward.ts
new file mode 100644
index 00000000..9a263acd
--- /dev/null
+++ b/lib/v3forward.ts
@@ -0,0 +1,133 @@
+import { db, g } from "$lib/db";
+import { quoteRoutingFee } from "$lib/lnquote";
+import { l, shortError, warn } from "$lib/logging";
+import { PaymentType } from "$lib/types";
+
+// v3 forwarding bridge. A user who migrated their name to coinos v3 still has
+// their legacy account, and payments keep arriving there: old BOLT12 offers,
+// old bolt11 invoices, old bitcoin/liquid deposit addresses. Each confirmed
+// receive of that kind is paid on, as it lands, to the name's v3 lightning
+// address (the registrar mints the invoice on the ASP's node, and its
+// forwarder delivers to the wallet's Ark mailbox even while the wallet is
+// closed). Only new receives move — a balance already sitting in the legacy
+// account stays put — and the forward costs the user the routing fee only
+// (none at all over the direct channels to the ASP's node).
+//
+// "Migrated" needs proof, since this moves money people sent to the LEGACY
+// account: the registrar holds the name either for the legacy account's own
+// nostr key (the identity-takeover rule) or for the v3 wallet the legacy
+// account's own session named in a migration grant (`granted`). A name that
+// merely exists on v3 is never forwarded to.
+
+const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io";
+const DOMAIN = "coinos.io";
+const TYPES = [PaymentType.lightning, PaymentType.bolt12, PaymentType.bitcoin, PaymentType.liquid];
+const RETRY_KEY = "v3fwd:retry"; // hash pid -> {uid, amount, tries, next}
+const RETRY_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 12 * 3600_000];
+
+const holderCache = new Map<string, { at: number; pk: string | null; granted: boolean }>();
+async function v3Holder(username: string): Promise<{ pk: string | null; granted: boolean }> {
+ const name = username.toLowerCase();
+ const hit = holderCache.get(name);
+ // this runs on every legacy receive: a holder is re-checked each minute,
+ // a name nobody holds every ten (claims change on the order of days)
+ if (hit && Date.now() - hit.at < (hit.pk ? 60_000 : 600_000)) return hit;
+ const r = await fetch(`${NAMES_URL}/name/${encodeURIComponent(name)}?domain=${DOMAIN}`,
+ { signal: AbortSignal.timeout(2000) });
+ if (!r.ok) throw new Error(`registrar said ${r.status}`);
+ const body: any = await r.json();
+ const pk = body?.taken && typeof body.pubkey === "string" ? body.pubkey : null;
+ const hit2 = { at: Date.now(), pk, granted: !!pk && body.granted === true };
+ holderCache.set(name, hit2);
+ return hit2;
+}
+
+// The name's v3 address, if this legacy account has moved there.
+export async function v3Destination(user): Promise<string | null> {
+ if (!user?.username || !user?.pubkey || user.username.length > 64) return null;
+ const { pk, granted } = await v3Holder(user.username);
+ return pk && (granted || pk === user.pubkey) ? user.username.toLowerCase() : null;
+}
+
+async function invoiceFor(name: string, sats: number): Promise<string> {
+ const lnurl = `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${DOMAIN}`;
+ const pr: any = await (await fetch(lnurl, { signal: AbortSignal.timeout(5000) })).json();
+ if (pr?.tag !== "payRequest" || !pr.callback) throw new Error("no payRequest from registrar");
+ const msat = sats * 1000;
+ if (msat < (pr.minSendable || 0)) throw Object.assign(new Error(`below the v3 minimum (${pr.minSendable / 1000} sats)`), { permanent: true });
+ if (pr.maxSendable && msat > pr.maxSendable) throw Object.assign(new Error(`above the v3 maximum (${pr.maxSendable / 1000} sats)`), { permanent: true });
+ const sep = pr.callback.includes("?") ? "&" : "?";
+ const inv: any = await (await fetch(`${pr.callback}${sep}amount=${msat}`, { signal: AbortSignal.timeout(10_000) })).json();
+ if (!inv?.pr) throw new Error(inv?.reason || "registrar returned no invoice");
+ return inv.pr;
+}
+
+// Pay `amount` sats of the user's legacy balance on to their v3 address,
+// routing fee included in it.
+async function forward({ user, amount, pid, send }) {
+ const name = await v3Destination(user);
+ if (!name) return null;
+ let pr = await invoiceFor(name, amount);
+ let { fee } = await quoteRoutingFee({ pr, amount });
+ if (fee > 0) {
+ if (fee >= amount) throw Object.assign(new Error(`routing fee ${fee} exceeds ${amount}`), { permanent: true });
+ pr = await invoiceFor(name, amount - fee);
+ ({ fee } = await quoteRoutingFee({ pr, amount: amount - fee }));
+ }
+ const w = await send({ user, pr, fee, memo: `Forwarded to ${name}@${DOMAIN} (coinos v3)` });
+ l("v3 forward", user.username, amount, "fee", fee, "payment", pid, "->", w?.id);
+ return w;
+}
+
+// Called from completePayment for every receive. Fire-and-forget: never
+// blocks or fails the credit. Returns true when the receive was the bridge's
+// (so legacy autowithdraw stands down for it).
+export async function maybeForwardToV3(p, user, send): Promise<boolean> {
+ try {
+ if (!p?.confirmed || !TYPES.includes(p.type) || !(p.amount > 0)) return false;
+ if ((p.aid || p.uid) !== user?.id) return false; // sub-accounts stay put
+ if (!(await v3Destination(user))) return false;
+ // once per payment: completePayment runs twice for on-chain deposits
+ const fresh = await db.set(`v3fwd:claimed:${p.id}`, "1", { NX: true, EX: 30 * 86400 });
+ if (!fresh) return true;
+ forward({ user, amount: p.amount, pid: p.id, send }).catch(async (e) => {
+ warn("v3 forward failed", user.username, p.amount, shortError(e?.message));
+ if (!e?.permanent) await queueRetry(p.id, user.id, p.amount, 0);
+ });
+ return true;
+ } catch (e: any) {
+ warn("v3 forward check failed", user?.username, shortError(e?.message));
+ return false;
+ }
+}
+
+async function queueRetry(pid: string, uid: string, amount: number, tries: number) {
+ if (tries >= RETRY_MS.length) {
+ warn("v3 forward gave up", uid, pid, amount, "— stays in the legacy balance");
+ return;
+ }
+ await db.hSet(RETRY_KEY, pid, JSON.stringify({ uid, amount, tries, next: Date.now() + RETRY_MS[tries] }));
+}
+
+// One process at a time works the retry queue (a short redis lock).
+export async function retryV3Forwards(send) {
+ const lock = await db.set("v3fwd:lock", "1", { NX: true, EX: 120 });
+ if (!lock) return;
+ try {
+ const all = await db.hGetAll(RETRY_KEY);
+ for (const [pid, raw] of Object.entries(all)) {
+ const e = JSON.parse(raw as string);
+ if (Date.now() < e.next) continue;
+ await db.hDel(RETRY_KEY, pid);
+ const user = await g(`user:${e.uid}`);
+ try {
+ if (!(await forward({ user, amount: e.amount, pid, send }))) continue;
+ } catch (err: any) {
+ warn("v3 forward retry failed", user?.username, e.amount, shortError(err?.message));
+ if (!err?.permanent) await queueRetry(pid, e.uid, e.amount, e.tries + 1);
+ }
+ }
+ } finally {
+ await db.del("v3fwd:lock");
+ }
+}
diff --git a/lib/webhooks.ts b/lib/webhooks.ts
index 2a888991..d724ccdf 100644
--- a/lib/webhooks.ts
+++ b/lib/webhooks.ts
@@ -12,6 +12,13 @@ export const callWebhook = async (invoice, payment) => {
const { amount, confirmed, hash, memo } = payment;
l("calling webhook", webhook, amount, hash, address, text);
+ // TLS verification stays ON. This body carries `secret` — the shared
+ // value the merchant uses to authenticate the notification — so with
+ // rejectUnauthorized:false any party able to intercept the connection
+ // could present its own certificate, harvest the secret, and then forge
+ // "payment received" callbacks to that merchant. A merchant on a
+ // self-signed cert now fails here instead, which the catch below logs;
+ // the payment itself is unaffected.
const res = await got.post(webhook, {
json: {
address,
@@ -23,7 +30,6 @@ export const callWebhook = async (invoice, payment) => {
text,
secret,
},
- https: { rejectUnauthorized: false },
});
return res;
}
diff --git a/lib/whitelist.ts b/lib/whitelist.ts
index 53fd231f..f305f516 100644
--- a/lib/whitelist.ts
+++ b/lib/whitelist.ts
@@ -14,9 +14,9 @@ export default [
"fresh",
"followers",
"follows",
+ "hasnsec",
"haspin",
"hasprinter",
- "hidepay",
"id",
"index",
"keys",
@@ -25,11 +25,10 @@ export default [
"locked",
"locktime",
"memoPrompt",
+ "migrated",
"nip5",
"notify",
"npub",
- "nsec",
- "nwc",
"payments",
"picture",
"profile",