diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/app.ts | 159 | ||||
| -rw-r--r-- | lib/auth.ts | 72 | ||||
| -rw-r--r-- | lib/changeid.ts | 121 | ||||
| -rw-r--r-- | lib/db.ts | 43 | ||||
| -rw-r--r-- | lib/ecash.ts | 200 | ||||
| -rw-r--r-- | lib/health.ts | 194 | ||||
| -rw-r--r-- | lib/image-worker.ts | 29 | ||||
| -rw-r--r-- | lib/image.ts | 50 | ||||
| -rw-r--r-- | lib/invoices.ts | 226 | ||||
| -rw-r--r-- | lib/lightning.ts | 383 | ||||
| -rw-r--r-- | lib/ln.ts | 244 | ||||
| -rw-r--r-- | lib/lnquote.ts | 303 | ||||
| -rw-r--r-- | lib/locations.ts | 3 | ||||
| -rw-r--r-- | lib/logging.ts | 40 | ||||
| -rw-r--r-- | lib/names.ts | 37 | ||||
| -rw-r--r-- | lib/nostr.ts | 43 | ||||
| -rw-r--r-- | lib/notes.ts | 1 | ||||
| -rw-r--r-- | lib/notifications.ts | 74 | ||||
| -rw-r--r-- | lib/nwc.ts | 1033 | ||||
| -rw-r--r-- | lib/payments.ts | 1101 | ||||
| -rw-r--r-- | lib/rates.ts | 53 | ||||
| -rw-r--r-- | lib/register.ts | 15 | ||||
| -rw-r--r-- | lib/reserved.ts | 31 | ||||
| -rw-r--r-- | lib/safe-fetch.ts | 165 | ||||
| -rw-r--r-- | lib/sockets.ts | 27 | ||||
| -rw-r--r-- | lib/spend-budget.ts | 39 | ||||
| -rw-r--r-- | lib/square.ts | 55 | ||||
| -rw-r--r-- | lib/types.ts | 1 | ||||
| -rw-r--r-- | lib/upload.ts | 59 | ||||
| -rw-r--r-- | lib/utils.ts | 13 | ||||
| -rw-r--r-- | lib/v3forward.ts | 133 | ||||
| -rw-r--r-- | lib/webhooks.ts | 8 | ||||
| -rw-r--r-- | lib/whitelist.ts | 5 |
33 files changed, 4386 insertions, 574 deletions
@@ -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; +} @@ -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); } } @@ -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); } }; @@ -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", |
