aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-29 08:37:39 -0700
committerAdam Soltys <[email protected]>2026-09-29 08:37:47 -0700
commit598a142e852b4682888aceac0d2d8dc39e0f981c (patch)
tree11100d5bc444004962786323f3b160150ac6ab63
parent4900ecb19ab9c6d279c8771b834fcb29e526a1e0 (diff)
downloadcoinos-server-598a142e852b4682888aceac0d2d8dc39e0f981c.tar.gz
coinos-server-598a142e852b4682888aceac0d2d8dc39e0f981c.zip
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 <[email protected]>
-rw-r--r--routes/lnurl.ts39
1 files changed, 34 insertions, 5 deletions
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);
}
},