From 598a142e852b4682888aceac0d2d8dc39e0f981c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 29 Sep 2026 08:37:39 -0700 Subject: lnurl: budget unauthenticated invoice generation per recipient GET /pay/:user/:amount and the lnurl callback create a CLN invoice and an entry in the recipient's invoice list for anyone holding the link, which a scanner on rotating IPs can do without tripping per-IP limits. Cap fresh invoices at 120 per recipient per hour (reused invoices don't count). Also fail cleanly for unknown usernames and skip invoice ids whose records no longer exist, instead of throwing on the null and breaking the pay page. Throttle the resulting lnurlp warnings per username. Co-Authored-By: Claude Fable 5.1 --- routes/lnurl.ts | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) (limited to 'routes') diff --git a/routes/lnurl.ts b/routes/lnurl.ts index a1ee22e5..0472d758 100644 --- a/routes/lnurl.ts +++ b/routes/lnurl.ts @@ -1,6 +1,6 @@ import { db, g, gf, s } from "$lib/db"; import { generate } from "$lib/invoices"; -import { err, warn } from "$lib/logging"; +import { err, quiet, suppressed, warn } from "$lib/logging"; import { serverPubkey2 } from "$lib/nostr"; import { SATS, bail, fail, getInvoice, getUser } from "$lib/utils"; import { bech32 } from "bech32"; @@ -18,6 +18,24 @@ const LNURL_TTL = 7 * 24 * 60 * 60; const { URL } = process.env; const host = URL.split("/").at(-1); const fiveMinutes = 1000 * 60 * 5; + +// Unauthenticated invoice generation (GET /pay/:user/:amount, GET /lnurl/:id) +// runs for whoever holds the link, so it needs a per-target budget: a scanner +// spread across a large proxy pool can mint junk invoices on many accounts +// (each a CLN invoice plus a :invoices entry) while staying invisible to +// per-IP limits. 120 fresh invoices per target per hour is far +// above any static-QR merchant's scan rate; reusing an existing invoice (the +// own-invoice path in pay(), the pinned invoice in lnurl()) doesn't count. +const unauthInvoiceBudget = async (uid: string) => { + const key = `invbudget:${uid}`; + const n = await db.incr(key); + if (n === 1) await db.expire(key, 3600); + if (n > 120) { + const q = quiet(`invbudget:${uid}`); + if (q !== null) warn("invoice budget exceeded", uid, n, suppressed(q)); + fail("Too many payment requests for this user, try again later"); + } +}; // v3 payment addresses (coinos v3 / halwallet) are registered here. const NAMES_URL = process.env.NAMES_URL || "https://names.coinos.io"; @@ -142,8 +160,11 @@ export default { tag: "payRequest", }); } catch (e) { - if (!e.message.includes("found")) - warn("problem generating lnurlp request", username, e.message); + if (!e.message.includes("found")) { + const n = quiet(`lnurlp:${username}`, 5 * 60 * 1000); + if (n !== null) + warn("problem generating lnurlp request", username, e.message, suppressed(n)); + } bail(res, e.message); } }, @@ -181,6 +202,7 @@ export default { } } + if (!iid) await unauthInvoiceBudget(user.id); const invoice = iid ? await gf(`invoice:${iid}`) : await generate({ @@ -239,12 +261,14 @@ export default { const { amount, username } = req.params; try { const user = await getUser(username); + if (!user) fail(`User ${username} not found`); const invoices = await db.lRange(`${user.id}:invoices`, 0, 10); let invoice; for (const iid of invoices) { const i = await getInvoice(iid); + if (!i) continue; const paid = i.amount > 0 && i.received >= i.amount; const old = Date.now() - i.created > fiveMinutes; if (paid) break; @@ -256,6 +280,7 @@ export default { if (invoice) { if (amount?.startsWith("+")) { + await unauthInvoiceBudget(user.id); const tip = invoice.amount * amount.split("+")[1]; invoice = await generate({ invoice: { @@ -266,6 +291,7 @@ export default { }); } } else { + await unauthInvoiceBudget(user.id); invoice = await generate({ invoice: { amount, @@ -302,8 +328,11 @@ export default { tag: "payRequest", }); } catch (e) { - if (!e.message.includes("found")) - warn("problem generating lnurlp request", username, e.message); + if (!e.message.includes("found")) { + const n = quiet(`lnurlp:${username}`, 5 * 60 * 1000); + if (n !== null) + warn("problem generating lnurlp request", username, e.message, suppressed(n)); + } bail(res, e.message); } }, -- cgit v1.2.3