aboutsummaryrefslogtreecommitdiffstats
path: root/routes
diff options
context:
space:
mode:
Diffstat (limited to 'routes')
-rw-r--r--routes/ecash.ts146
-rw-r--r--routes/email.ts91
-rw-r--r--routes/info.ts16
-rw-r--r--routes/invoices.ts103
-rw-r--r--routes/lnurl.ts269
-rw-r--r--routes/nostr.ts98
-rw-r--r--routes/payments.ts643
-rw-r--r--routes/preimages.ts113
-rw-r--r--routes/rates.ts3
-rw-r--r--routes/square.ts92
-rw-r--r--routes/users.ts861
11 files changed, 1794 insertions, 641 deletions
diff --git a/routes/ecash.ts b/routes/ecash.ts
index 96783cc4..995a8dce 100644
--- a/routes/ecash.ts
+++ b/routes/ecash.ts
@@ -1,40 +1,21 @@
-import { db, g, s } from "$lib/db";
-import { check, claim, get, mint } from "$lib/ecash";
-import { err, l } from "$lib/logging";
-import { credit, debit } from "$lib/payments";
-import { emit } from "$lib/sockets";
-import { bail, fail, getInvoice } from "$lib/utils";
+import { check, get, redeem } from "$lib/ecash";
+import { err } from "$lib/logging";
+import { bail, fail, getInvoice, getUser } from "$lib/utils";
import { getEncodedToken } from "@cashu/cashu-ts";
-import { v4 } from "uuid";
-
-import { PaymentType } from "$lib/types";
-const { ecash: type } = PaymentType;
-
-Error.stackTraceLimit = 100; // Set this to the desired limit
-
-const sendCash = async ({ amount, user }) => {
- const id = v4();
- const hash = v4();
-
- const p = await debit({ hash, amount, user, type });
- const token = await mint(parseInt(amount));
- p.memo = id;
- const { id: pid } = p;
- await s(`payment:${pid}`, p);
- s(`cash:${id}`, token);
-
- return { id, token, pid };
-};
export default {
- async save(req, res) {
+ // Stateless status lookup for a token the client is holding. This replaced
+ // an unauthenticated POST that stored any submitted string under a fresh id
+ // forever (thousands of forged and foreign tokens accumulated); `get` below
+ // still serves the ids that were handed out while it existed.
+ async status(req, res) {
const {
body: { token },
} = req;
try {
- const id = v4();
- await s(`cash:${id}`, token);
- res.send({ id });
+ if (typeof token !== "string" || !token.startsWith("cashu"))
+ fail("Invalid token");
+ res.send(await check(token));
} catch (e) {
err(e.message);
bail(res, e.message);
@@ -47,6 +28,7 @@ export default {
} = req;
try {
const token = await get(id);
+ if (!token) fail("Ecash not found");
const status = await check(token);
res.send({ token, status });
} catch (e) {
@@ -61,108 +43,34 @@ export default {
user,
} = req;
try {
- const amount = await claim(token);
-
- let memo;
- const hash = v4();
- const { currency, id: uid } = user;
- const rates = await g("rates");
- await s(`invoice:${hash}`, {
- currency,
- id: hash,
- hash,
- rate: rates[currency],
- uid,
- received: 0,
- });
-
- await credit({ hash, amount, memo, ref: user.id, type });
-
- res.send({ ok: true });
+ const amount = await redeem({ token, user });
+ res.send({ ok: true, amount });
} catch (e) {
err(e.message);
bail(res, e.message);
}
},
- async mint(req, res) {
- const { body, user } = req;
- const amount = parseInt(body.amount);
- res.send(await sendCash({ amount, user }));
- },
-
- async melt(req, res) {
- let {
- body: { amount, bolt11: hash, preimage },
- user,
- } = req;
- try {
- amount = Math.round(amount / 1000);
- const ref = preimage;
- const { lightning: type } = PaymentType;
- if (user.username !== "mint") fail("unauthorized");
- const { id: uid, currency } = user;
- const ourfee = await db.debit(
- `balance:${uid}`,
- `credit:${type}:${uid}`,
- "Insufficient funds",
- amount || 0,
- 0,
- 0,
- 0,
- 0
- );
-
- const rates = await g("rates");
- const rate = rates[currency];
-
- if (ourfee.err) fail(ourfee.err);
-
- const id = v4();
- const p = {
- id,
- amount: -amount,
- hash,
- ourfee,
- uid,
- confirmed: true,
- rate,
- currency,
- type,
- ref,
- created: Date.now(),
- };
-
- await s(`payment:${hash}`, id);
- await s(`payment:${id}`, p);
- await db.lPush(`${uid}:payments`, id);
-
- l(user.username, "sent", type, amount);
- emit(user.id, "payment", p);
-
- res.send(p);
- } catch (e) {
- bail(res, e.message);
- }
- },
-
+ // NUT-18 payment-request transport: a wallet POSTs proofs for one of our
+ // ecash-type invoices, which are melted into a Lightning invoice for the
+ // invoice's owner under the same id.
async receive(req, res) {
try {
const { id, proofs, mint, memo } = req.body;
- const { uid: ref } = await getInvoice(id);
-
- const amount = await claim(
- getEncodedToken({
- mint,
- proofs,
- }),
- );
+ const invoice = await getInvoice(id);
+ if (!invoice) fail("Invoice not found");
+ const user = await getUser(invoice.uid);
+ if (!user) fail("User not found");
- await credit({ hash: id, amount, memo, ref, type });
+ await redeem({
+ token: getEncodedToken({ mint, proofs }),
+ user,
+ invoice,
+ memo,
+ });
res.send({ id });
} catch (e) {
- console.log(e);
err(e.message);
bail(res, e.message);
}
diff --git a/routes/email.ts b/routes/email.ts
index 9f0a8228..749fba5e 100644
--- a/routes/email.ts
+++ b/routes/email.ts
@@ -1,61 +1,78 @@
-import { bail } from "$lib/utils";
+import { bail, fail } from "$lib/utils";
import got from "got";
import config from "$config";
import { l, warn } from "$lib/logging";
import { SESClient } from "@aws-sdk/client-ses";
import { SendEmailCommand } from "@aws-sdk/client-ses";
+// reCAPTCHA v3 never challenges: siteverify returns success:true for any
+// well-formed token and reports how human the request looked in `score`.
+// Checking `success` alone let a headless scanner submit freely.
+const MIN_SCORE = 0.5;
+
+const escapeHtml = (s: string) =>
+ s
+ .replace(/&/g, "&")
+ .replace(/</g, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&#39;");
+
+// Strip CR/LF and other control characters from single-line header values.
+const oneLine = (s: string) => s.replace(/[\x00-\x1f\x7f]+/g, " ").trim();
+
export default {
- async send({ body }, res) {
+ async send({ body, headers }, res) {
try {
const { email, message, username, token: response } = body;
+ if (typeof message !== "string" || !message.trim()) fail("Message required");
+ if (typeof email !== "string" || !email.trim()) fail("Email required");
+ if (username != null && typeof username !== "string") fail("Invalid username");
+ if (typeof response !== "string" || !response) fail("failed captcha");
const Charset = "UTF-8";
const { recaptcha: secret } = config;
- const { success } = await got
+ const remoteip = headers?.["cf-connecting-ip"];
+ const { success, score, action } = await got
.post("https://www.google.com/recaptcha/api/siteverify", {
- form: {
- secret,
- response,
- },
+ form: { secret, response, ...(remoteip && { remoteip }) },
+ timeout: { request: 6000 },
})
- .json();
+ .json<any>();
- if (success || response === config.adminpass) {
- body.token = undefined;
+ if (!success || action !== "submit" || !(score >= MIN_SCORE)) {
+ warn("support request rejected", email, "score", score, "action", action);
+ fail("failed captcha");
+ }
- warn("support request from", email);
- const client = new SESClient({ region: "us-east-2" });
- await client.send(
- new SendEmailCommand({
- Destination: {
- CcAddresses: [],
- ToAddresses: [config.support],
+ l("support request from", email, "score", score);
+ const name = username ? oneLine(username).slice(0, 100) : "";
+ const client = new SESClient({ region: "us-east-2" });
+ await client.send(
+ new SendEmailCommand({
+ Destination: {
+ CcAddresses: [],
+ ToAddresses: [config.support],
+ },
+ Message: {
+ Body: {
+ Html: { Charset, Data: escapeHtml(message).replace(/\n/g, "<br>") },
+ Text: { Charset, Data: message },
},
- Message: {
- Body: {
- Html: { Charset, Data: message.replace(/\n/g, "<br>") },
- Text: { Charset, Data: message },
- },
- Subject: {
- Charset,
- Data:
- body.subject ||
- `Support Request${username ? ` From ${username}` : ""}`,
- },
+ Subject: {
+ Charset,
+ Data: `Support Request${name ? ` From ${name}` : ""}`,
},
- ReplyToAddresses: [email],
- Source: config.support,
- }),
- );
+ },
+ ReplyToAddresses: [oneLine(email)],
+ Source: config.support,
+ }),
+ );
- res.send({ ok: true });
- } else {
- bail(res, "failed captcha");
- }
+ res.send({ ok: true });
} catch (e) {
- console.log(e);
+ bail(res, e.message);
}
},
};
diff --git a/routes/info.ts b/routes/info.ts
index c6d03e4d..4ab11872 100644
--- a/routes/info.ts
+++ b/routes/info.ts
@@ -1,9 +1,15 @@
import { archive } from "$lib/db";
-import { db, g } from "$lib/db";
+import { db } from "$lib/db";
+import { getHealthStatus } from "$lib/health";
import ln from "$lib/ln";
-import { getDecodedToken } from "@cashu/cashu-ts";
export default {
+ async health(_, res) {
+ const status = getHealthStatus();
+ const httpStatus = status.healthy ? 200 : 503;
+ res.status(httpStatus).send(status);
+ },
+
async balances(_, res) {
let total = 0;
@@ -21,13 +27,7 @@ export default {
);
const lnwallet = parseInt(funds.outputs.reduce((a, b) => a + b.value, 0));
- const cash = getDecodedToken(await g("cash")).proofs.reduce(
- (a, b) => a + b.amount,
- 0,
- );
-
const info = {
- cash,
lnchannel,
lnwallet,
};
diff --git a/routes/invoices.ts b/routes/invoices.ts
index ecac713c..b8d06bba 100644
--- a/routes/invoices.ts
+++ b/routes/invoices.ts
@@ -1,25 +1,30 @@
import config from "$config";
-import { db } from "$lib/db";
+import { db, g, gf, s } from "$lib/db";
import { generate } from "$lib/invoices";
import { err } from "$lib/logging";
-import { bail, fields, getInvoice, getUser } from "$lib/utils";
+import { bail, fail, fields, getInvoice, getUser } from "$lib/utils";
import rpc from "@coinos/rpc";
export default {
async get(req, res) {
- const {
- params: { id },
- } = req;
- const invoice = await getInvoice(id);
+ try {
+ const {
+ params: { id },
+ } = req;
+ if (id === "undefined") fail("invalid id");
+ const invoice = await getInvoice(id);
- if (invoice) {
- invoice.secret = undefined;
- invoice.user = await getUser(invoice.uid, fields);
+ if (invoice) {
+ invoice.secret = undefined;
+ invoice.user = await getUser(invoice.uid, fields);
- invoice.items ||= [];
+ invoice.items ||= [];
+ }
+ if (invoice) res.send(invoice);
+ else fail("invoice not found");
+ } catch (e) {
+ bail(res, e.message);
}
- if (invoice) res.send(invoice);
- else res.code(500).send("invoice not found");
},
async create(req, res) {
@@ -28,17 +33,61 @@ export default {
if (body.user) user = body.user;
if (req.user.username === user.username) invoice.own = true;
+ else invoice.own = false;
try {
+ // A supplied id that already exists is an UPDATE of that record —
+ // generate() rewrites it wholesale. Only the authenticated owner may do
+ // that. Without this, an anonymous pay-page session passing
+ // body.user = merchant could rewrite the amount/memo of a merchant's
+ // live invoice. generate() separately refuses to move an invoice to a
+ // different uid.
+ if (invoice?.id) {
+ const existing = await getInvoice(invoice.id);
+ if (existing && existing.uid !== req.user.id) fail("invoice not found");
+ }
const result = await generate({ invoice, user });
res.send(result);
} catch (e) {
- err(
- "problem generating invoice",
- req.user?.username,
- body.user?.username,
- e.message,
- );
+ bail(res, e.message);
+ }
+ },
+
+ async update(req, res) {
+ try {
+ const { id } = req.params;
+ const { body } = req;
+ const { tip, webhook, secret } = body.invoice;
+
+ let invoice = await gf(`invoice:${id}`);
+ if (!invoice) fail("invoice not found");
+ const user = await g(`user:${invoice.uid}`);
+
+ if (typeof tip !== "undefined") {
+ const t = Number.parseInt(tip);
+ if (!Number.isSafeInteger(t) || t < 0 || t > 2_100_000_000_000_000)
+ fail("Invalid tip");
+ // Defense-in-depth: never change the tip once the invoice is paid/
+ // settled. The primary mint guard is on the credit side (it uses the
+ // tip the sender was actually debited); this stops post-settlement
+ // tampering. The endpoint stays open by design — payers set tips on
+ // merchants' invoices before paying — so we do NOT require ownership.
+ if (invoice.received > 0 || invoice.settled)
+ fail("Invoice already settled");
+ invoice.tip = t;
+ }
+
+ if (webhook && secret) {
+ if (invoice.uid !== req.user?.id) fail("Unauthorized");
+ invoice.webhook = webhook;
+ invoice.secret = secret;
+ }
+
+ invoice = await generate({ invoice, user });
+ await s(`invoice:${id}`, invoice);
+
+ res.send(invoice);
+ } catch (e) {
bail(res, e.message);
}
},
@@ -46,13 +95,29 @@ export default {
async list(req, res) {
const { id } = req.user;
let invoices = await db.lRange(`${id}:invoices`, 0, -1);
- invoices = await Promise.all(invoices.map((i) => getInvoice(i)));
+ invoices = (await Promise.all(invoices.map((i) => getInvoice(i)))).filter(
+ Boolean,
+ );
res.send(invoices);
},
async sign(req, res) {
try {
const { address, message, type = "bitcoin" } = req.body;
+
+ // Sign only with an address the CALLER owns. Users have no per-user keys
+ // in this custodial design — every address belongs to the server hot
+ // wallet — so without this any authenticated account could have the node
+ // sign an arbitrary message under any address the wallet controls
+ // (another user's deposit address, a change address, a cold-storage
+ // address published as ours), which is a proof-of-control forgery
+ // primitive. The legitimate flow (coinos.io/sign, for Ocean offers that
+ // need a signable on-chain address) mints a fresh invoice address for
+ // the caller and posts that back, so it always satisfies this check.
+ const invoice = await getInvoice(address);
+ if (!invoice || (invoice.uid !== req.user.id && invoice.aid !== req.user.id))
+ fail("unauthorized");
+
const node = rpc(config[type]);
if (config[type].walletpass)
diff --git a/routes/lnurl.ts b/routes/lnurl.ts
index 16f366d4..7f04db5d 100644
--- a/routes/lnurl.ts
+++ b/routes/lnurl.ts
@@ -1,20 +1,72 @@
-import { db, g, s } from "$lib/db";
+import { db, g, gf, s } from "$lib/db";
import { generate } from "$lib/invoices";
-import ln from "$lib/ln";
-import { err, warn } from "$lib/logging";
-import { serverPubkey } from "$lib/nostr";
-import { credit } from "$lib/payments";
-import { bail, fail, getInvoice, getUser } from "$lib/utils";
+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";
-import got from "got";
+import { safeGot } from "$lib/safe-fetch";
+import { verifyEvent } from "nostr-tools";
import { v4 } from "uuid";
import { PaymentType } from "$lib/types";
+// A payRequest callback id is fetched once, minutes after the metadata
+// request, and never again; without an expiry these one-shot pointers were
+// ~45% of all keys in the main db.
+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";
+
+// Does the v3 registrar serve this name? Returns its payRequest, or null.
+// Cached briefly: claims change on the order of days, and this sits on the
+// payment path for every coinos.io address lookup. Only definitive answers
+// (payRequest / 404) are cached — a sick registrar shouldn't be remembered.
+const registrarCache = new Map();
+const REGISTRAR_TTL = 60_000;
+async function registrarLookup(name) {
+ const hit = registrarCache.get(name);
+ if (hit && Date.now() - hit.at < REGISTRAR_TTL) return hit.body;
+ try {
+ const r = await fetch(
+ `${NAMES_URL}/.well-known/lnurlp/${encodeURIComponent(name)}?domain=${host}`,
+ { signal: AbortSignal.timeout(3000) },
+ );
+ if (r.ok) {
+ const body: any = await r.json();
+ if (body?.tag === "payRequest") {
+ registrarCache.set(name, { at: Date.now(), body });
+ return body;
+ }
+ }
+ if (r.status === 404) registrarCache.set(name, { at: Date.now(), body: null });
+ } catch (e) {
+ warn("names registrar lookup failed for", name, (e as any).message);
+ }
+ return null;
+}
+
export default {
async encode(req, res) {
const {
@@ -26,7 +78,7 @@ export default {
.replace(/\s/g, "")}`;
try {
- const r = await got(url).json();
+ const r = await safeGot(url);
if (r.tag !== "payRequest") fail("not an ln address");
} catch (e) {
const m = `failed to lookup lightning address ${address}`;
@@ -47,7 +99,7 @@ export default {
bech32.fromWords(bech32.decode(text, 20000).words),
).toString();
- const r = await got(url).json();
+ const r = await safeGot(url);
res.send(r);
} catch (e) {
bail(res, e.message);
@@ -60,15 +112,36 @@ export default {
query: { minSendable = 1000, maxSendable = 100000000000 },
} = req;
try {
- const user = await getUser(
- username
- .replace("lightning:", "")
- .replace(/\s/g, "")
- .replace("=", "")
- .toLowerCase(),
- );
+ const name = username
+ .replace("lightning:", "")
+ .replace(/\s/g, "")
+ .replace("=", "")
+ .toLowerCase();
- if (!user) fail(`User ${username} not found`);
+ // Resolution order for [email protected] — this has flip-flopped twice, so
+ // the invariant, in full:
+ // 1. A name CLAIMED in the v3 registrar is served by it. That is where
+ // migrated accounts live — their legacy user record still exists
+ // here, so "local account exists" does NOT mean the name is ours —
+ // and also v3-native custom names, which have no local account and
+ // only a npub1* Cloudflare rule redirecting for them today.
+ // 2. Otherwise a local account is served locally. Never key this on
+ // `migrated`: register.ts stamps that flag on essentially every
+ // account (it means "don't reserve this name for v3", not "user
+ // moved away").
+ // 3. Neither → not found.
+ // No loop: step 1 defers only for names the registrar HAS, and the
+ // registrar never queries this endpoint for those. If the registrar is
+ // unreachable we fall through and serve the local account — a briefly
+ // stale destination beats an unpayable address.
+ const v3 = await registrarLookup(name);
+ if (v3) return res.send(v3);
+
+ const user = await getUser(name);
+ // Evicted accounts can't receive either: stops a lightning address that
+ // a scam page points victims at from issuing new invoices.
+ if (!user || (await db.sIsMember("evicted", user.id)))
+ fail(`User ${username} not found`);
const { id: uid } = user;
const metadata = JSON.stringify([
@@ -77,21 +150,24 @@ export default {
]);
const id = v4();
- await s(`lnurl:${id}`, uid);
+ await s(`lnurl:${id}`, uid, LNURL_TTL);
res.send({
allowsNostr: true,
minSendable,
maxSendable,
metadata,
- nostrPubkey: serverPubkey,
+ nostrPubkey: serverPubkey2,
commentAllowed: 512,
callback: `${URL}/api/lnurl/${id}`,
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);
}
},
@@ -102,22 +178,27 @@ export default {
query: { amount, comment, nostr },
} = req;
try {
+ const iid = await g(`lnurl:${id}:invoice`);
const uid = await g(`lnurl:${id}`);
const user = await getUser(uid);
- if (!user) fail("user not found");
+
+ if (!user || (await db.sIsMember("evicted", user.id)))
+ fail("user not found");
let { username } = user;
username = username.replace(/\s/g, "").toLowerCase();
- const memo = comment ?? `Paying ${username}@${host}`;
let metadata = JSON.stringify([
- ["text/plain", memo],
+ ["text/plain", `Paying ${username}@${host}`],
["text/identifier", `${username}@${host}`],
]);
if (nostr) {
try {
const event = JSON.parse(decodeURIComponent(nostr));
- // TODO: validate the event
+ // NIP-57: must be a signed kind-9734 zap request. Reject anything
+ // else so we can't be tricked into storing a forged zap receipt.
+ if (event.kind !== 9734 || !verifyEvent(event))
+ throw new Error("invalid zap request");
await s(`zap:${id}`, event);
metadata = nostr;
} catch (e) {
@@ -125,17 +206,44 @@ export default {
}
}
- const { id: iid, text: pr } = await generate({
- invoice: {
- amount: Math.round(amount / 1000),
- memo: metadata,
- type: PaymentType.lightning,
- },
- user,
+ if (!iid) await unauthInvoiceBudget(user.id);
+ const invoice = iid
+ ? await gf(`invoice:${iid}`)
+ : await generate({
+ invoice: {
+ amount: Math.round(amount / 1000),
+ memo: metadata,
+ type: PaymentType.lightning,
+ },
+ user,
+ });
+
+ if (comment) {
+ // Enforce the commentAllowed:512 we advertise in the lnurlp response.
+ // This overwrite happens after generate()'s own memo validation, so an
+ // uncapped comment would otherwise reach credit() at settlement time.
+ invoice.memo = String(comment).slice(0, 512);
+ await s(`invoice:${invoice.id}`, invoice);
+ }
+
+ res.send({
+ pr: invoice.text,
+ routes: [],
+ verify: `${URL}/api/lnurl/verify/${invoice.id}`,
});
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
- res.send({ pr, routes: [], verify: `${URL}/api/lnurl/verify/${iid}` });
+ async proxy(req, res) {
+ const { url } = req.query;
+ if (!url) return bail(res, "url required");
+ try {
+ const r = await safeGot(url);
+ res.send(r);
} catch (e) {
+ warn("lnurl proxy failed", url, e.message);
bail(res, e.message);
}
},
@@ -154,29 +262,49 @@ export default {
},
async pay(req, res) {
- const { username } = req.params;
+ 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 (i.own && !(paid || old)) {
+ if (paid) break;
+ if (i.own && !old) {
invoice = i;
break;
}
}
- if (!invoice)
+ if (invoice) {
+ if (amount?.startsWith("+")) {
+ await unauthInvoiceBudget(user.id);
+ const tip = invoice.amount * amount.split("+")[1];
+ invoice = await generate({
+ invoice: {
+ ...invoice,
+ tip,
+ },
+ user,
+ });
+ }
+ } else {
+ await unauthInvoiceBudget(user.id);
invoice = await generate({
invoice: {
+ amount,
+ prompt: user.prompt,
type: PaymentType.lightning,
},
user,
});
+ }
const { id: uid } = user;
@@ -186,71 +314,30 @@ export default {
]);
const id = v4();
- await s(`lnurl:${id}`, uid);
- const total = (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000;
+
+ const total =
+ (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000;
+
+ await s(`lnurl:${id}`, uid, LNURL_TTL);
+ if (total > 0) await s(`lnurl:${id}:invoice`, invoice.id, LNURL_TTL);
res.send({
allowsNostr: true,
- minSendable: total,
- maxSendable: invoice.amount ? total : undefined,
+ minSendable: invoice.amount ? total : 1000,
+ maxSendable: invoice.amount ? total : 10 * 1000 * SATS,
metadata,
- nostrPubkey: serverPubkey,
+ nostrPubkey: serverPubkey2,
commentAllowed: 512,
callback: `${URL}/api/lnurl/${id}`,
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);
}
},
-
- // async withdraw(req, res) {
- // try {
- // const { pr, k1: id } = req.query;
- // const invoice = await getInvoice(pr);
- // const { amount_msat } = await ln.decode(pr);
- // const amount = Math.ceil(amount_msat / 1000);
- // await db.debit(`fund:${id}`, "", amount, 0, 0, 0);
- // let p;
- // if (invoice) {
- // const { hash } = invoice;
- // p = await credit({
- // hash,
- // amount,
- // memo: id,
- // ref: id,
- // type: PaymentType.fund,
- // });
- // } else {
- // await ln.xpay({
- // invstring: pr.replace(/\s/g, "").toLowerCase(),
- // maxfee: 0,
- // retry_for: 10,
- // });
- // const rates = await g("rates");
- // p = {
- // id: v4(),
- // amount: -amount,
- // hash: pr,
- // confirmed: true,
- // rate: rates.USD,
- // currency: "USD",
- // type: PaymentType.fund,
- // ref: id,
- // created: Date.now(),
- // };
- // await s(`payment:${p.id}`, p);
- // await s(`payment:${pr}`, p);
- // }
- //
- // await db.lPush(`fund:${id}:payments`, p.id);
- // res.send({ status: "OK" });
- // } catch (e) {
- // console.log(e);
- // warn("lnurlw failed", e.message);
- // res.send({ status: "ERROR", reason: "Withdrawal failed" });
- // }
- // },
};
diff --git a/routes/nostr.ts b/routes/nostr.ts
index 9c61bf16..3b28076a 100644
--- a/routes/nostr.ts
+++ b/routes/nostr.ts
@@ -21,6 +21,36 @@ import { decode } from "nostr-tools/nip19";
import type { ProfilePointer } from "nostr-tools/nip19";
import { getZapEndpoint, makeZapRequest } from "nostr-tools/nip57";
+// Amount and payer of a zap receipt. NIP-57 kind 9735: amount comes from
+// decoding the paid bolt11, payer from the embedded 9734 zap request in the
+// description tag. NIP-177 kind 9736 (BOLT12 zaps): amount is an explicit
+// msat tag, and the receipt is payer-published with the signed 9737 intent in
+// the description tag.
+const parseZap = async (ev) => {
+ const { kind, tags, pubkey } = ev;
+ const description = tags.find((t) => t[0] === "description")?.[1];
+
+ let payer;
+ try {
+ payer = JSON.parse(description).pubkey;
+ } catch (e) {}
+ payer ||= pubkey;
+
+ let amount = 0;
+ if (kind === 9736) {
+ const msat = Number.parseInt(tags.find((t) => t[0] === "amount")?.[1]);
+ if (msat > 0) amount = Math.round(msat / 1000);
+ } else {
+ try {
+ const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
+ const { amount_msat } = await ln.decode(bolt11);
+ if (amount_msat) amount = Math.round(amount_msat / 1000);
+ } catch (e) {}
+ }
+
+ return { amount, pubkey: payer };
+};
+
export default {
async event(req, res) {
try {
@@ -35,6 +65,7 @@ export default {
if (event) return res.send(event);
event = await get({ ids: [id] });
+ if (!event) return res.code(404).send("Not found");
if (!full) return res.send(event);
const parts = parseContent(event);
@@ -45,23 +76,14 @@ export default {
)
.map(({ value }) => value.pubkey);
- const zapEvents = await scan({ kinds: [9735], "#e": [id] });
+ const zapEvents = await scan({ kinds: [9735, 9736], "#e": [id] });
const zaps = [];
- for (const { tags } of zapEvents) {
- const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
- const description = tags.find((t) => t[0] === "description")?.[1];
- const { pubkey } = JSON.parse(description);
- let amount = 0;
- try {
- const { amount_msat } = await ln.decode(bolt11);
- if (amount_msat) amount = Math.round(amount_msat / 1000);
- } catch (e) {}
-
- zaps.push({ amount, pubkey });
+ for (const zapEvent of zapEvents) {
+ zaps.push(await parseZap(zapEvent));
}
pubkeys.push(event.pubkey);
- pubkeys.push(...zaps.map((z) => z.pubkey));
+ pubkeys.push(...zaps.map((z) => z.pubkey).filter((p) => p));
pubkeys = [...new Set(pubkeys)];
const profiles = await scan({ kinds: [0], authors: pubkeys });
const found = profiles.map((p) => p.pubkey);
@@ -107,7 +129,6 @@ export default {
res.send(event);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -158,7 +179,6 @@ export default {
res.send(thread);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -168,24 +188,16 @@ export default {
let { id } = req.params;
if (id.startsWith("nevent")) id = decode(id).data.id;
if (id.startsWith("note")) id = decode(id).data;
- const filter = { kinds: [9735], "#e": [id] };
- let events = await scan(filter);
- if (!events.length) {
- await sync("wss://relay.primal.net", filter);
- events = await scan(filter);
- }
+ // q() scans the local strfry and falls back to primal (the old code
+ // called a `sync` helper that never existed and threw ReferenceError
+ // whenever the local relay had no receipts)
+ const filter = { kinds: [9735, 9736], "#e": [id] };
+ const events = await q(filter);
if (!events.length) return res.send([]);
const zaps = [];
- for (const { tags } of events) {
- const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
- const description = tags.find((t) => t[0] === "description")?.[1];
- const { pubkey } = JSON.parse(description);
- let amount = 0;
- try {
- const { amount_msat } = await ln.decode(bolt11);
- if (amount_msat) amount = Math.round(amount_msat / 1000);
- } catch (e) {}
+ for (const zapEvent of events) {
+ const { amount, pubkey } = await parseZap(zapEvent);
let user;
if (pubkey) {
@@ -198,7 +210,6 @@ export default {
res.send(zaps.filter((z) => z.amount > 0));
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -217,7 +228,6 @@ export default {
res.send({});
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -244,7 +254,6 @@ export default {
res.send(events);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -319,7 +328,6 @@ export default {
res.send(followers);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -330,7 +338,6 @@ export default {
// res.send({ followers: 0, follows: 0 });
res.send(await getCount(pubkey));
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -341,7 +348,21 @@ export default {
} = req;
let names = {};
if (name) {
- names = { [name]: (await getUser(name, fields)).pubkey };
+ // v3 registrar first: a claimed name's NIP-05 belongs to its wallet key.
+ // Legacy accounts answer as before when the registrar doesn't know it.
+ try {
+ const r = await fetch(
+ `${process.env.NAMES_URL || "https://names.coinos.io"}/.well-known/nostr.json?name=${encodeURIComponent(name)}`,
+ { signal: AbortSignal.timeout(3000) },
+ );
+ if (r.ok) {
+ const j = await r.json();
+ if (j?.names?.[name]) return res.send({ names: { [name]: j.names[name] } });
+ }
+ } catch {}
+ const u = await getUser(name, fields);
+ if (!u) return res.send({ names: {} }); // unknown name: empty per NIP-05, not a 500
+ names = { [name]: u.pubkey };
} else {
const records = await db.sMembers("nip5");
for (const s of records) {
@@ -379,9 +400,7 @@ export default {
});
res.send(event);
- } catch (e) {
- console.log(e);
- }
+ } catch (e) {}
},
async zap(req, res) {
@@ -399,7 +418,6 @@ export default {
res.send(json);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
diff --git a/routes/payments.ts b/routes/payments.ts
index 66a19e53..cfb34af7 100644
--- a/routes/payments.ts
+++ b/routes/payments.ts
@@ -1,9 +1,12 @@
import config from "$config";
import api from "$lib/api";
import { requirePin } from "$lib/auth";
-import { db, g, s } from "$lib/db";
+import { archive, db, g, gf, s, sa } from "$lib/db";
+import { generate, getUserOffer } from "$lib/invoices";
+import { replay } from "$lib/lightning";
import ln from "$lib/ln";
-import { err, l, warn } from "$lib/logging";
+import { platformFee, quoteMax, quoteRoutingFee } from "$lib/lnquote";
+import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging";
import mqtt from "$lib/mqtt";
import {
build,
@@ -31,6 +34,19 @@ import rpc from "@coinos/rpc";
import got from "got";
import { v4 } from "uuid";
+// Funds are closed. The mechanism let a caller pull unbacked balance out of a
+// fund (the /take drain, 2026-08-25) and its deposit path never had a
+// kill-switch of its own, so with `fund:disabled` set users could still pay
+// INTO funds they were then unable to withdraw from. Every value-moving entry
+// point — deposit (POST /payments with `fund`), withdrawal (POST /take) and
+// pre-authorization (POST /authorize) — now refuses unconditionally, rather
+// than depending on a redis flag someone can clear by accident.
+//
+// Read paths are deliberately left working: GET /fund/:id and the manager
+// endpoints still serve existing funds so balances stay visible and can be
+// settled out of band.
+const fundsClosed = () => fail("Funds are no longer supported");
+
export default {
async info(_, res) {
res.send(await ln.getinfo());
@@ -43,8 +59,21 @@ export default {
const balance = await g(`balance:${user.id}`);
try {
+ // Reject non-string payreq/hash up front. A client sending a number/object
+ // here otherwise reaches a decoder that calls `.startsWith` on it and throws
+ // an opaque "n.startsWith is not a function" (logged bare via the catch
+ // below). A malformed *string* is fine — it fails later with a clear error.
+ if (payreq != null && typeof payreq !== "string")
+ fail("Invalid payment request");
+ if (hash != null && typeof hash !== "string") fail("Invalid invoice");
+ // Invoices are keyed lowercase. An uppercase payreq (QR-scanned) missed
+ // the lookup below and was paid over cl to ourselves instead of
+ // internally; the listener then found the sender's payment:<bolt11>
+ // pointer and skipped crediting the recipient.
+ if (payreq) payreq = payreq.replace(/\s/g, "").toLowerCase();
+
if (typeof amount !== "undefined") {
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
if (amount < 0 || amount > SATS || Number.isNaN(amount))
fail("Invalid amount");
}
@@ -56,7 +85,7 @@ export default {
const invoice = await getInvoice(payreq || hash);
const recipient = invoice ? await getUser(invoice.uid) : undefined;
if (payreq) {
- if (invoice && recipient.username !== "mint") {
+ if (invoice) {
if (invoice.aid === user.id) fail("Cannot send to self");
hash = payreq;
if (!amount) ({ amount } = invoice);
@@ -75,23 +104,19 @@ export default {
sender: user,
});
} else if (fund) {
- p = await debit({
- hash,
- amount,
- memo: fund,
- user,
- type: PaymentType.fund,
- });
- await db.incrBy(`fund:${fund}`, amount);
- await db.lPush(`fund:${fund}:payments`, p.id);
- l("funded fund", fund);
+ fundsClosed();
}
}
res.send(p);
} catch (e) {
- warn(user.username, "payment failed", amount, balance, hash, payreq);
- err(e.message);
+ // One line per account per 5 min: frozen/empty throwaway accounts retry
+ // failed sends in a loop and otherwise dominate the log.
+ const n = quiet(`payfail:${user.id}`, 5 * 60 * 1000);
+ if (n !== null) {
+ warn(user.username, "payment failed", amount, balance, hash, payreq, suppressed(n));
+ err(shortError(e.message));
+ }
bail(res, e.message);
}
},
@@ -99,25 +124,42 @@ export default {
async list(req, res) {
let {
user: { id },
- query: { aid, start, end, limit, offset },
+ query: { aid, start, end, limit, offset, received },
} = req;
if (!aid || aid === "undefined") aid = id;
- limit = parseInt(limit);
- offset = parseInt(offset) || 0;
+ const index = await db.lPos(`${id}:accounts`, aid);
+ if (index === null) fail("unauthorized");
- const range = !limit || start || end ? -1 : limit - 1;
- let payments = (await db.lRange(`${aid || id}:payments`, 0, range)) || [];
+ limit = Number.parseInt(limit);
+ offset = Number.parseInt(offset) || 0;
+
+ const range = !limit || received || start || end ? -1 : limit - 1;
+ const listKey = `${aid || id}:payments`;
+ let payments = (await db.lRange(listKey, 0, range)) || [];
+
+ if (range === -1) {
+ const archived = (await archive.lRange(listKey, 0, -1)) || [];
+ payments = [...new Set([...payments, ...archived])];
+ } else if (limit) {
+ const needed = Math.max(0, limit + offset - payments.length);
+ if (needed > 0) {
+ const archived =
+ (await archive.lRange(listKey, 0, limit + offset - 1)) || [];
+ payments = [...new Set([...payments, ...archived])];
+ }
+ }
payments = (
await Promise.all(
payments.map(async (pid) => {
- const p = await g(`payment:${pid}`);
+ const p = await gf(`payment:${pid}`);
if (!p) {
warn("user", id, "missing payment", pid);
- await db.lRem(`${aid || id}:payments`, 0, pid);
- return p;
+ return;
}
+ if (p.revertedDuplicate) return;
+ if (received && p.amount < 0) return;
if (p.created < start || p.created > end) return;
if (p.type === PaymentType.internal)
p.with = await getUser(p.ref, fields);
@@ -133,7 +175,7 @@ export default {
[b.currency]: {
tips: (a[b.currency] ? a[b.currency].tips : 0) + (b.tip || 0),
fiatTips: (
- parseFloat(a[b.currency] ? a[b.currency].fiatTips : 0) +
+ Number.parseFloat(a[b.currency] ? a[b.currency].fiatTips : 0) +
((b.tip || 0) * b.rate) / SATS
).toFixed(2),
sats:
@@ -143,7 +185,7 @@ export default {
(b.fee || 0) -
(b.ourfee || 0),
fiat: (
- parseFloat(a[b.currency] ? a[b.currency].fiat : 0) +
+ Number.parseFloat(a[b.currency] ? a[b.currency].fiat : 0) +
(((b.amount || 0) +
((b.amount > 0 ? b.tip : -b.tip) || 0) -
(b.fee || 0) -
@@ -171,9 +213,9 @@ export default {
const p = await getPayment(hash);
if (p?.type === PaymentType.internal)
p.with = await getUser(p.ref, fields);
+ if (p?.type === PaymentType.fund) p.with = await getUser(p.uid, fields);
res.send(p);
} catch (e) {
- console.log(e);
err("failed to get payment", e.message);
bail(res, e.message);
}
@@ -185,14 +227,19 @@ export default {
user,
} = req;
try {
+ // Node alias lookup, refreshed hourly. Only the id→alias map is kept:
+ // the old code stored the full listnodes output (~10MB for 16k nodes)
+ // as an array, so the `last` stamp it hung on the array was dropped by
+ // JSON.stringify and the condition was inverted anyway — every send
+ // page view refetched and rewrote the whole thing.
const hour = 1000 * 60 * 60;
- let nodes = await g("nodes");
- const { last } = nodes || {};
-
- if (!last || last > Date.now() - hour) {
- ({ nodes } = await ln.listnodes());
- nodes.last = Date.now();
- await s("nodes", nodes);
+ let cache = await g("nodes");
+ if (!cache?.aliases || !cache.last || cache.last < Date.now() - hour) {
+ const { nodes } = await ln.listnodes();
+ const aliases = {};
+ for (const n of nodes) if (n.alias) aliases[n.nodeid] = n.alias;
+ cache = { last: Date.now(), aliases };
+ await s("nodes", cache);
}
const decoded = await ln.decode(payreq);
@@ -200,23 +247,30 @@ export default {
let amount_msat;
let payee;
- if (decoded.type.includes("bolt12")) {
+ if (decoded.type === "bolt12 offer") {
+ ({ offer_amount_msat: amount_msat } = decoded);
+ payee = decoded.offer_issuer_id || decoded.offer_node_id;
+ } else if (decoded.type.includes("bolt12")) {
({ invoice_amount_msat: amount_msat, invoice_node_id: payee } =
decoded);
} else ({ amount_msat, payee } = decoded);
- const node = nodes.find((n) => n.nodeid === payee);
- const alias = node ? node.alias : payee.substr(0, 12);
+ const alias = cache.aliases[payee] || (payee || "").substr(0, 12);
- const amount = Math.round(amount_msat / 1000);
+ const amount = Math.round((amount_msat || 0) / 1000);
let ourfee = Math.round(amount * config.fee[PaymentType.lightning]);
const credit = await g(`credit:lightning:${user.id}`);
const covered = Math.min(credit, ourfee) || 0;
ourfee -= covered;
- res.send({ alias, amount, ourfee });
+ res.send({
+ alias,
+ amount,
+ ourfee,
+ type: decoded.type,
+ description: decoded.offer_description || decoded.description,
+ });
} catch (e) {
- console.log(e);
err("problem parsing", e.message);
bail(res, e.message);
}
@@ -224,20 +278,47 @@ export default {
async fund(req, res) {
const {
- params: { name },
+ params: { id },
} = req;
- const amount = await g(`fund:${name}`);
+ let amount = await g(`fund:${id}`);
+ let fid = id;
+
+ // A rotated fund no longer exists at its OLD id (security fix 2026-08-25:
+ // fund secret uids were rotated to invalidate an exfiltrated id list). Serve
+ // the new fund ONLY to an authenticated MANAGER of it — never anonymously or
+ // to a non-owner. An open old->new redirect would defeat the rotation, since
+ // the attacker holds the leaked OLD ids; gating on manager membership means a
+ // replayed old id reveals nothing unless you already control the fund. The
+ // route's `optional` auth populates req.user without requiring it. Bearer
+ // funds (no managers) are intentionally not recoverable this way.
+ if (typeof amount === "undefined" || amount === null) {
+ const rotatedTo = await g(`fund:rotated:${id}`);
+ const uid = req.user?.id;
+ if (rotatedTo && uid && (await db.sIsMember(`fund:${rotatedTo}:managers`, uid))) {
+ fid = rotatedTo;
+ amount = await g(`fund:${rotatedTo}`);
+ }
+ }
+
if (typeof amount === "undefined" || amount === null)
return bail(res, "fund not found");
- let payments = (await db.lRange(`fund:${name}:payments`, 0, -1)) || [];
- payments = await Promise.all(payments.map((hash) => g(`payment:${hash}`)));
+
+ let payments = (await db.lRange(`fund:${fid}:payments`, 0, -1)) || [];
+ payments = await Promise.all(payments.map((hash) => gf(`payment:${hash}`)));
await Promise.all(
payments.map(async (p: any) => (p.user = await getUser(p.uid, fields))),
);
payments = payments.filter((p) => p);
- res.send({ amount, payments });
+
+ const authorization = await g(`authorization:${fid}`);
+ res.send({
+ amount,
+ authorization: authorization?.amount,
+ payments,
+ ...(fid !== id ? { id: fid, rotatedFrom: id } : {}),
+ });
},
// async withdraw(req, res) {
@@ -258,38 +339,134 @@ export default {
// });
// },
+ async authorize(req, res) {
+ fundsClosed();
+
+ const { id: uid } = req.user;
+ const { id, fiat, currency, amount } = req.body;
+
+ const managers = await db.sMembers(`fund:${id}:managers`);
+ if (managers.length && !managers.includes(uid)) fail("Unauthorized");
+
+ // The authorization's fiat/currency is the ONLY ceiling on how much a later
+ // /take can pull from the authorizer (cap = sats(fiat / rates[currency])).
+ // Reject a NaN/zero/non-finite rate or a non-positive fiat so the ceiling
+ // can never be turned into an astronomically large or non-finite value.
+ const rates = await g("rates");
+ const rate = Number(rates?.[currency]);
+ const fiatAmount = Number(fiat);
+ if (!Number.isFinite(rate) || rate <= 0) fail("Invalid currency");
+ if (!Number.isFinite(fiatAmount) || fiatAmount <= 0) fail("Invalid amount");
+
+ const authorization = {
+ uid,
+ currency,
+ fiat: fiatAmount,
+ amount: Number.parseInt(amount) || 0,
+ };
+
+ await s(`authorization:${id}`, authorization);
+ res.send({});
+ },
+
async take(req, res) {
let {
body: { id, amount, invoice: iid },
user,
} = req;
try {
- const { id: uid } = user;
- amount = parseInt(amount);
- if (amount < 0) fail("Invalid amount");
-
- const managers = await db.sMembers(`fund:${id}:managers`);
- if (managers.length && !managers.includes(user.id)) fail("Unauthorized");
+ fundsClosed();
- const result: any = await db.debit(`fund:${id}`, "", "Insufficient funds", amount, 0, 0, 0, 0);
- if (result.err) fail(result.err);
+ amount = Number.parseInt(amount);
+ if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount");
const rates = await g("rates");
- const { currency } = user;
- const rate = rates[currency];
if (!iid) {
- iid = v4();
- await s(`invoice:${iid}`, {
- currency,
- id: iid,
- hash: iid,
- rate,
- uid,
- received: 0,
+ const inv = await generate({
+ invoice: { amount, type: "lightning" },
+ user,
});
+ iid = inv.id;
}
+ const authorization = await g(`authorization:${id}`);
+ if (authorization && !authorization.claimed) {
+ const { currency, fiat } = authorization;
+ // Bound the take to the authorized fiat value using a VALIDATED fiat
+ // rate. A non-finite/zero rate or a crypto-denominated authorization
+ // (rate ~1) would otherwise make sats(fiat / rate) an astronomically
+ // large ceiling, defeating the cap (the drain vector).
+ const rate = Number(rates?.[currency]);
+ const fiatAmount = Number(fiat);
+ if (!Number.isFinite(rate) || rate <= 0)
+ fail("Invalid authorization currency");
+ if (!Number.isFinite(fiatAmount) || fiatAmount <= 0)
+ fail("Invalid authorization amount");
+ const cap = sats(fiatAmount / rate);
+ if (!Number.isFinite(cap) || cap <= 0) fail("Invalid authorization");
+ amount = Math.min(amount, cap);
+ if (!Number.isFinite(amount) || amount <= 0) fail("Invalid amount");
+
+ // Atomic single-use claim. The read above is not a lock: concurrent
+ // POST /take for the same authorization all pass the `!claimed` gate and,
+ // without an atomic gate, each would debit the authorizer and fund the
+ // pool — redeeming a single-use authorization N times (COINOS-3). SET NX
+ // lets exactly one caller win the claim; the rest skip the funding.
+ const claimed = await db.set(`authorization:${id}:claimed`, user.id, {
+ NX: true,
+ });
+ if (claimed) {
+ // Mark the record claimed only once the funding has actually landed,
+ // and release the claim if it throws — otherwise a failed funding
+ // (an insufficient balance, a server limit) burns the authorization
+ // permanently: the key is set, the fund never gets the money, and
+ // every later /take skips the funding block.
+ try {
+ const sender = await getUser(authorization.uid);
+ if (!sender) fail("authorizer not found");
+
+ const { hash } = await generate({
+ invoice: { amount, type: "lightning" },
+ user: sender,
+ });
+
+ const { id: pid } = await debit({
+ hash,
+ amount,
+ memo: id,
+ user: sender,
+ type: PaymentType.fund,
+ });
+
+ await db.incrBy(`fund:${id}`, amount);
+ await db.lPush(`fund:${id}:payments`, pid);
+
+ authorization.claimed = true;
+ await s(`authorization:${id}`, authorization);
+ l("funded fund", id);
+ } catch (e) {
+ await db.del(`authorization:${id}:claimed`);
+ throw e;
+ }
+ }
+ }
+
+ const managers = await db.sMembers(`fund:${id}:managers`);
+ if (managers.length && !managers.includes(user.id)) fail("Unauthorized");
+
+ const result: any = await db.debit(
+ `fund:${id}`,
+ "",
+ "Insufficient funds",
+ amount,
+ 0,
+ 0,
+ 0,
+ 0,
+ );
+ if (result.err) fail(result.err);
+
const payment = await credit({
aid: user.id,
hash: iid,
@@ -301,7 +478,7 @@ export default {
await db.lPush(`fund:${id}:payments`, payment.id);
- res.send({ payment });
+ res.send(payment);
} catch (e) {
warn("problem withdrawing from fund", user.username, e.message);
bail(res, e.message);
@@ -313,9 +490,9 @@ export default {
const ids = await db.sMembers(`fund:${name}:managers`);
- const managers = await Promise.all(
- ids.map(async (id) => await getUser(id)),
- );
+ const managers = (await Promise.all(
+ ids.map(async (id) => await getUser(id, fields)),
+ )).filter(Boolean);
res.send(managers);
},
@@ -333,17 +510,42 @@ export default {
await db.sAdd(k, user.id);
}
- const u = await getUser(username);
+ const u = await getUser(username, fields);
if (!u) fail("User not found");
const { id: uid } = u;
await db.sAdd(k, uid);
const ids = await db.sMembers(k);
- if (!managers.length)
- managers = await Promise.all(ids.map(async (id) => await getUser(id)));
+ const resolved = (await Promise.all(
+ ids.map(async (id) => await getUser(id, fields)),
+ )).filter(Boolean);
- res.send(managers);
+ res.send(resolved);
+ },
+
+ async deleteManager(req, res) {
+ try {
+ const { name } = req.params;
+ const { id: uid } = req.body;
+ const { user } = req;
+
+ const k = `fund:${name}:managers`;
+ let managers = await db.sMembers(k);
+
+ if (managers.length) {
+ if (!managers.includes(user.id)) fail("Unauthorized");
+ }
+
+ await db.sRem(k, uid);
+
+ const ids = await db.sMembers(k);
+ managers = await Promise.all(
+ ids.map(async (id) => await getUser(id, fields)),
+ );
+
+ res.send(managers);
+ } catch (e) {}
},
async confirm(req, res) {
@@ -358,11 +560,34 @@ export default {
let aid;
if (!hot) aid = wallet;
+ // Change-recredit guard (2026-08-18). If our own wallet contributed inputs
+ // to this transaction, a "send" detail is present. In that case every
+ // receive output is our own CHANGE or an internal move — never an external
+ // deposit — so it must not be credited: doing so mints phantom balance
+ // (the withdrawal-change re-credit exploit that drove the L-C creep). A
+ // genuine coinos->coinos transfer is settled off-chain in the send path
+ // (credit() resolves getInvoice(hash) and books an internal transfer), so
+ // an on-chain receive funded by our own spend is only ever change.
+ const weSpent = details.some((d: any) => d.category === "send");
+
for (const { address, amount, asset, category, vout } of details) {
if (!address) continue;
if (type === PaymentType.liquid && asset !== config.liquid.btc)
continue;
+ // Kill switch: `liquid:deposits:disabled` stops every Liquid receive
+ // from being credited or confirmed (sends still get their
+ // confirmation bookkeeping below). Set during a security incident;
+ // clear the key to re-enable.
+ if (
+ type === PaymentType.liquid &&
+ category !== "send" &&
+ (await g("liquid:deposits:disabled"))
+ ) {
+ warn("liquid deposit blocked (disabled)", txid, vout, sats(amount), address);
+ continue;
+ }
+
if (category === "send") {
const p = await getPayment(txid);
if (!p) continue;
@@ -379,11 +604,42 @@ export default {
continue;
}
+ // See weSpent above: a receive output on a transaction our wallet funded
+ // is change, not a deposit. Skip it so we never re-credit our own funds.
+ // Log the blocked amount as a tripwire: sums the phantom credit prevented
+ // and surfaces any continued exploitation attempts.
+ if (weSpent) {
+ if (sats(amount) >= 300)
+ warn("blocked change re-credit", txid, vout, sats(amount), address);
+ continue;
+ }
+
const p = await getPayment(`${txid}:${vout}`);
+ // A pointer whose payment record is gone (account deleted, record
+ // overwritten) means this output was already credited once. Never
+ // treat it as a first sighting.
+ if (
+ !p &&
+ ((await db.exists(`payment:${txid}:${vout}`)) ||
+ (await db.exists(`credited:${txid}:${vout}`)))
+ ) {
+ warn("blocked re-credit of seen output", txid, vout, sats(amount), address);
+ continue;
+ }
+
if (!p) {
const invoice = await getInvoice(address);
if (!hot && aid !== invoice?.aid) continue;
+ if (sats(amount) < 300) continue;
+ // Atomic first-seen guard (COINOS-2). The getPayment() read above is not
+ // a lock: concurrent callers (walletnotify, catchUp, bulk /confirm
+ // sweeps — /confirm is unauthenticated) can all read p==null for one
+ // deposit and each run credit(), double-crediting the pending balance
+ // AND inflating the spendable credit:<net>:<uid> fee accumulator. Mirror
+ // the confirm-stage lock so only the first caller credits.
+ const firstlock = `firstseenlock:${txid}:${vout}`;
+ if (!(await db.set(firstlock, "1", { NX: true, EX: 60 }))) continue;
await credit({
hash: address,
amount: sats(amount),
@@ -393,15 +649,30 @@ export default {
});
} else if (confirmations >= 1) {
const id = `payment:${txid}:${vout}`;
+ // Atomic guard against concurrent /confirm callers double-crediting:
+ // walletnotify, catchUp, and bulk sweeps can all fire for the same
+ // txid:vout in parallel. Without this, two callers can each read
+ // p.confirmed=false and each run the multi() that increments balance.
+ const lockKey = `confirmlock:${txid}:${vout}`;
+ const acquired = await db.set(lockKey, "1", { NX: true, EX: 60 });
+ if (!acquired) continue;
+
const p = await getPayment(`${txid}:${vout}`);
- if (!p) return db.sAdd("missed", id);
- if (p.confirmed) return;
+ if (!p) {
+ await db.sAdd("missed", id);
+ await db.del(lockKey);
+ return;
+ }
+ if (p.confirmed) {
+ await db.del(lockKey);
+ return;
+ }
const invoice = await getInvoice(address);
const { id: iid } = invoice;
p.confirmed = true;
- invoice.received += parseInt(invoice.pending);
+ invoice.received += Number.parseInt(invoice.pending);
invoice.pending = 0;
l("confirming", id, p.id, p.amount);
@@ -414,13 +685,20 @@ export default {
.incrBy(`balance:${p.aid || p.uid}`, p.amount)
.exec();
+ // Mirror the now-confirmed record into arc so a future bulk sweep
+ // finds it via gf() fallback even if the main-db pointer is wiped
+ // (see feedback_apr29_double_credit_incident.md).
+ await sa(`payment:${p.id}`, p);
+ await sa(`invoice:${iid}`, invoice);
+
+ await db.del(lockKey);
+
const user = await g(`user:${p.uid}`);
await completePayment(invoice, p, user);
}
}
res.send({});
} catch (e) {
- console.log(e);
warn(`problem processing ${txid}`);
bail(res, e.message);
}
@@ -454,7 +732,9 @@ export default {
res.send(p);
} catch (e) {
- warn(user.username, "payment failed", e.message);
+ const n = quiet(`payfail:${user.id}`, 5 * 60 * 1000);
+ if (n !== null)
+ warn(user.username, "payment failed", shortError(e.message), suppressed(n));
res.code(500).send(e.message);
}
},
@@ -464,11 +744,10 @@ export default {
body: { secret },
} = req;
try {
- if (secret !== config.adminpass) fail("unauthorized");
+ if (!config.adminpass || secret !== config.adminpass) fail("unauthorized");
await s("freeze", true);
res.send("ok");
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -479,7 +758,8 @@ export default {
user,
} = req;
try {
- const p = await g(`payment:${id}`);
+ const p = await gf(`payment:${id}`);
+ if (!p) fail("Payment not found");
if (p.uid !== user.id) fail("unauthorized");
emit(user.id, "payment", p);
@@ -488,6 +768,7 @@ export default {
mqtt.publish(
username,
`pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}`,
+ { qos: 1 },
);
res.send({ ok: true });
@@ -498,10 +779,11 @@ export default {
async lnaddress(req, res) {
let {
- params: { lnaddress, amount, fee = 5000 },
+ params: { lnaddress, amount },
body,
user,
} = req;
+ const { fee } = body;
try {
lnaddress = decodeURIComponent(lnaddress);
await requirePin({ body, user });
@@ -525,32 +807,22 @@ export default {
let p;
if (payee === id) {
p = await debit({ hash: pr, amount, memo, user });
- await credit({ hash: pr, amount, memo, ref: user.id });
+ await credit({ hash: pr, amount, memo, ref: user.id, tip: p.tip });
} else p = await sendLightning({ user, pr, amount, fee, memo });
res.send(p);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
- async gateway(req, res) {
- const {
- body: { short_channel_id, webhook },
- } = req;
-
- await s(short_channel_id, webhook);
- res.send({ ok: true });
- },
-
async replace(req, res) {
const {
body: { id },
user,
} = req;
try {
- const p = await g(`payment:${id}`);
+ const p = await gf(`payment:${id}`);
if (!p) fail("Payment not found");
if (p.uid !== user.id) fail("unauthorized");
@@ -610,8 +882,191 @@ export default {
res.send(await ln.decode(bolt11));
},
+ // Up-front cost of a lightning send: the routing fee askrene quotes for this
+ // invoice (the same answer xpay will get) plus the platform fee debit() will
+ // charge. With `max`, instead solve for the largest amount whose amount +
+ // fees exactly clears the account balance — the "send everything" case where
+ // guessing a maxfee always left dust behind or bounced on insufficient funds.
+ async quote(req, res) {
+ const { body, user } = req;
+ try {
+ let { payreq, amount, max, aid, ceiling } = body;
+ if (typeof payreq !== "string" || !payreq.trim())
+ fail("Invalid payment request");
+ payreq = payreq.replace(/\s/g, "").toLowerCase();
+
+ if (!aid) aid = user.id;
+ else if (typeof aid !== "string") fail("Invalid account");
+ else if (aid !== user.id) {
+ const pos = await db.lPos(`${user.id}:accounts`, aid);
+ if (pos == null) fail("account not found");
+ }
+
+ const parseSats = (v, name) => {
+ if (v === undefined || v === null || v === "") return undefined;
+ const n = Number.parseInt(v);
+ if (Number.isNaN(n) || n < 0 || n > SATS) fail(`Invalid ${name}`);
+ return n;
+ };
+ amount = parseSats(amount, "amount");
+ ceiling = parseSats(ceiling, "ceiling");
+ const balance = Number.parseInt(await g(`balance:${aid}`)) || 0;
+
+ // Paying another coinos user never leaves the ledger: no routing, no
+ // platform fee, the whole balance is sendable.
+ const invoice = await getInvoice(payreq);
+ if (invoice) {
+ const recipient = await getUser(invoice.uid);
+ if (recipient?.username !== "mint") {
+ const a = max ? balance : (amount ?? invoice.amount);
+ return res.send({
+ amount: a,
+ fee: 0,
+ ourfee: 0,
+ total: a,
+ balance,
+ internal: true,
+ });
+ }
+ }
+
+ let decoded = await ln.decode(payreq);
+ let pr = payreq;
+ let fetched: string | undefined;
+
+ // An offer isn't routable by itself — the blinded paths live in the
+ // invoice fetched from it. Fetch one to learn them; in max mode that's a
+ // placeholder amount, and the final invoice is fetched once we know the
+ // real one so the caller can pay exactly what we quoted.
+ const fetchFromOffer = async (a: number) => {
+ const { invoice } = await ln.fetchinvoice({
+ offer: payreq,
+ amount_msat: decoded.offer_amount_msat ? undefined : a * 1000,
+ timeout: 60,
+ });
+ return invoice.replace(/\s/g, "").toLowerCase();
+ };
+
+ if (decoded.type === "bolt12 offer") {
+ if (decoded.offer_currency)
+ fail("Currency-denominated offers are not supported");
+ const offerAmount = decoded.offer_amount_msat
+ ? Math.round(decoded.offer_amount_msat / 1000)
+ : undefined;
+ if (offerAmount) max = false;
+ const a = offerAmount ?? amount ?? (max ? balance : undefined);
+ if (!a) fail("Amount required");
+ pr = fetched = await fetchFromOffer(a);
+ decoded = await ln.decode(pr);
+ }
+
+ let invAmount: number | undefined;
+ if (decoded.type === "bolt12 invoice")
+ invAmount = decoded.invoice_amount_msat
+ ? Math.round(decoded.invoice_amount_msat / 1000)
+ : undefined;
+ else
+ invAmount = decoded.amount_msat
+ ? Math.round(decoded.amount_msat / 1000)
+ : undefined;
+
+ if (max) {
+ // The invoice's own amount is ignored here: an LNURL caller has to
+ // name an amount to get any invoice at all, so it probes with one at
+ // the ceiling, takes the amount we solve for, and fetches the real
+ // invoice for that. Anything that pays a fixed-amount invoice with
+ // a different amount fails at send time, not here.
+ const q = await quoteMax({ pr, uid: user.id, aid, ceiling });
+ // Offer: the placeholder invoice carried the balance, not the answer.
+ if (fetched && q.amount !== invAmount) {
+ fetched = await fetchFromOffer(q.amount);
+ // Re-quote against the real invoice: same paths, so same fee, but
+ // never hand back a number we didn't verify against what gets paid.
+ const { fee } = await quoteRoutingFee({ pr: fetched, amount: q.amount });
+ if (fee !== q.fee) fail("Routing fee changed, please try again");
+ }
+ l("quote max", user.username, q.amount, "fee", q.fee, "ourfee", q.ourfee, "balance", q.balance);
+ return res.send({ ...q, ...(fetched ? { payreq: fetched } : {}) });
+ }
+
+ const a = invAmount ?? amount;
+ if (!a) fail("Amount required");
+ if (invAmount && amount && amount !== invAmount)
+ fail("Amount does not match invoice");
+ const { fee, parts } = await quoteRoutingFee({ pr, amount: a });
+ const ourfee = await platformFee({ uid: user.id, aid, amount: a, fee });
+ l("quote", user.username, a, "fee", fee, "ourfee", ourfee, "parts", parts);
+ res.send({
+ amount: a,
+ fee,
+ ourfee,
+ parts,
+ total: a + fee + ourfee,
+ balance,
+ ...(fetched ? { payreq: fetched } : {}),
+ });
+ } catch (e) {
+ warn("problem quoting", user.username, shortError(e.message));
+ bail(res, e.message);
+ }
+ },
+
+ // The user's standing bolt12 offer (lno1...) — reusable receive code they
+ // can publish (e.g. in a nostr kind 10058 list for bolt12 zaps)
+ async offer(req, res) {
+ try {
+ res.send(await getUserOffer(req.user));
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
async fetchinvoice(req, res) {
- const { amount, offer } = req.body;
- res.send(await ln.fetchinvoice(offer, amount ? amount * 1000 : null));
+ const { amount, offer, payer_note } = req.body;
+ res.send(
+ await ln.fetchinvoice({
+ offer,
+ amount_msat: amount ? amount * 1000 : undefined,
+ payer_note,
+ timeout: 60,
+ }),
+ );
+ },
+
+ async auth(req, res) {
+ console.log(req.query);
+ res.send(req.query);
+ },
+
+ async order(req, res) {
+ console.log(req.body);
+ res.send(req.body);
+ },
+
+ async sendinvoice(req, res) {
+ try {
+ const { user } = req;
+ const { invreq } = req.body;
+
+ const { amount_msat, bolt12, pay_index } = await ln.sendinvoice({
+ invreq,
+ label: v4(),
+ });
+
+ await generate({
+ invoice: {
+ amount: Math.round(amount_msat / 1000),
+ type: "bolt12",
+ bolt12,
+ },
+ user,
+ });
+
+ const p = await replay(pay_index);
+
+ res.send(p);
+ } catch (e) {
+ bail(res, e.message);
+ }
},
};
diff --git a/routes/preimages.ts b/routes/preimages.ts
new file mode 100644
index 00000000..5e7c484f
--- /dev/null
+++ b/routes/preimages.ts
@@ -0,0 +1,113 @@
+import { createHash } from "node:crypto";
+import { db, g } from "$lib/db";
+import { parseEntry } from "$lib/invoices";
+import { bail, fail } from "$lib/utils";
+
+// Merchants pre-load payment preimages here, then have buyers create invoices
+// with usePreimage: true — each such invoice consumes the next queued preimage
+// so its payment hash is sha256(preimage). Once paid, the preimage is exposed
+// on the invoice record, letting a static storefront sell decryption secrets
+// with no backend of its own. An optional per-batch price — sats, or a fiat
+// amount converted at invoice-creation time — is enforced at invoice creation;
+// creation is unauthenticated, so without it a buyer could name their own
+// price for the secret.
+
+const MAX_QUEUE = 1000;
+const key = (uid) => `${uid}:preimages`;
+const sha256 = (hex) =>
+ createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex");
+
+export default {
+ async add(req, res) {
+ try {
+ const { preimages, price = null, fiat = null } = req.body;
+ let { currency = null } = req.body;
+ if (!Array.isArray(preimages) || !preimages.length)
+ fail("preimages array required");
+ if (
+ !preimages.every(
+ (p) => typeof p === "string" && /^[0-9a-f]{64}$/i.test(p),
+ )
+ )
+ fail("preimages must be 64-character hex strings");
+ if (price !== null && fiat !== null)
+ fail("specify price (sats) or fiat, not both");
+ if (price !== null && (!Number.isInteger(price) || price <= 0))
+ fail("price must be a positive integer amount of sats");
+ if (fiat !== null) {
+ if (typeof fiat !== "number" || !(fiat > 0))
+ fail("fiat must be a positive number");
+ currency ||= req.user.currency;
+ const rates = await g("rates");
+ if (!rates?.[currency]) fail(`unsupported currency ${currency}`);
+ } else {
+ currency = null;
+ }
+
+ const k = key(req.user.id);
+ const existing = await db.lRange(k, 0, -1);
+ const seen = new Set(existing.map((e) => parseEntry(e).preimage));
+ const fresh = [];
+ for (let p of preimages) {
+ p = p.toLowerCase();
+ if (seen.has(p)) continue;
+ // A preimage that already settled (or backs a live invoice) can't be
+ // credited again — reject it rather than let a buyer pay for nothing.
+ if (
+ (await db.exists(`credited:${p}`)) ||
+ (await db.exists(`preimage:${sha256(p)}`))
+ )
+ fail("preimage already used");
+ seen.add(p);
+ fresh.push(p);
+ }
+
+ if (existing.length + fresh.length > MAX_QUEUE)
+ fail(`queue limited to ${MAX_QUEUE} preimages`);
+ if (fresh.length)
+ await db.rPush(
+ k,
+ fresh.map((preimage) =>
+ JSON.stringify({ preimage, price, fiat, currency }),
+ ),
+ );
+
+ res.send({
+ queued: existing.length + fresh.length,
+ added: fresh.map((preimage) => ({
+ preimage,
+ hash: sha256(preimage),
+ price,
+ fiat,
+ currency,
+ })),
+ });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
+ async list(req, res) {
+ try {
+ const entries = await db.lRange(key(req.user.id), 0, -1);
+ res.send({
+ queued: entries.length,
+ preimages: entries.map((e) => {
+ const { preimage, price, fiat, currency } = parseEntry(e);
+ return { preimage, hash: sha256(preimage), price, fiat, currency };
+ }),
+ });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
+ async clear(req, res) {
+ try {
+ await db.del(key(req.user.id));
+ res.send({ queued: 0 });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+};
diff --git a/routes/rates.ts b/routes/rates.ts
index 67c111d7..e532ec78 100644
--- a/routes/rates.ts
+++ b/routes/rates.ts
@@ -12,6 +12,7 @@ export default {
},
async index(_, res) {
- res.send(await g("rates"));
+ const { date, fx, ...rates } = await g("rates");
+ res.send(rates);
},
};
diff --git a/routes/square.ts b/routes/square.ts
new file mode 100644
index 00000000..fb06e082
--- /dev/null
+++ b/routes/square.ts
@@ -0,0 +1,92 @@
+import * as crypto from "crypto";
+import config from "$config";
+import { db, g, s } from "$lib/db";
+import { generate } from "$lib/invoices";
+import { SATS, bail, getUser } from "$lib/utils";
+import { sha256 } from "@noble/hashes/sha256";
+import { utf8ToBytes } from "@noble/hashes/utils";
+import { base64urlnopad as base64 } from "@scure/base";
+import { SquareClient } from "square";
+import { v4 } from "uuid";
+
+const { scopes, url, appId, clientSecret, environment } = config.square;
+
+export default {
+ async connect(req, res) {
+ const { user } = req;
+
+ if (await db.exists(`${user.id}:square`)) {
+ return res.send("connected");
+ }
+
+ const codeVerifier = base64.encode(crypto.randomBytes(32));
+ await s(`${user.id}:codeVerifier`, codeVerifier);
+
+ const challenge = base64.encode(sha256(utf8ToBytes(codeVerifier)));
+
+ const state = base64.encode(crypto.randomBytes(12));
+ const scope = scopes.join("+");
+
+ res.send(
+ `${url}oauth2/authorize?client_id=${appId}&session=false&scope=${scope}&state=${state}&code_challenge=${challenge}`,
+ );
+ },
+
+ async auth(req, res) {
+ const { user } = req;
+
+ const codeVerifier = await g(`${user.id}:codeVerifier`);
+
+ const client = new SquareClient({
+ environment,
+ });
+
+ const { code } = req.query;
+
+ try {
+ const result = await client.oAuth.obtainToken({
+ code,
+ clientId: appId,
+ grantType: "authorization_code",
+ codeVerifier,
+ });
+
+ await s(`${user.id}:square`, result);
+ await s(result.merchantId, user.id);
+ res.send({});
+ } catch (e) {}
+ },
+
+ async payment(req, res) {
+ try {
+ const { body } = req;
+ const { data, type, merchant_id } = body;
+ const { payment } = data.object;
+
+ if (
+ type === "payment.created" &&
+ (payment.source_type === "CASH" || payment.source_type === "EXTERNAL")
+ ) {
+ const {
+ amount_money: { amount, currency },
+ } = payment;
+ const rates = await g("rates");
+ const rate = rates[currency];
+ const uid = await g(merchant_id);
+ const user = await getUser(uid);
+ const invoice = {
+ amount: Math.round(((amount / 100) * SATS) / rate),
+ own: true,
+ prompt: user.prompt,
+ type: "lightning",
+ };
+
+ await generate({ invoice, user });
+ }
+
+ res.send({});
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+};
diff --git a/routes/users.ts b/routes/users.ts
index 34c2a655..77ddbdc6 100644
--- a/routes/users.ts
+++ b/routes/users.ts
@@ -1,21 +1,28 @@
-import { createReadStream } from "node:fs";
-import { appendFile, unlink } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import config from "$config";
import { requirePin } from "$lib/auth";
-import { db, g, ga, s } from "$lib/db";
+import { db, g, gf, s } from "$lib/db";
import { err, l, warn } from "$lib/logging";
+import { isDefaultName } from "$lib/default-names";
+import { upgradeLegacyNsec } from "$lib/legacynsec";
import { mail, templates } from "$lib/mail";
-import { getNostrUser, getProfile, serverPubkey } from "$lib/nostr";
-import { reconcile } from "$lib/payments";
+import { getNostrUser, getProfile, serverPubkey2 } from "$lib/nostr";
+import deleteUser from "$lib/deluser";
import register from "$lib/register";
+import { assertNameFree } from "$lib/names";
+import { isReserved } from "$lib/reserved";
import { emit } from "$lib/sockets";
import upload from "$lib/upload";
import { bail, fail, fields, getUser, pick } from "$lib/utils";
import whitelist from "$lib/whitelist";
-import rpc from "@coinos/rpc";
+
import { $ } from "bun";
+import got from "got";
import jwt from "jsonwebtoken";
import { getPublicKey, nip19, verifyEvent } from "nostr-tools";
+import { hexToBytes } from "@noble/hashes/utils";
import { authenticator } from "otplib";
import { v4 } from "uuid";
@@ -25,6 +32,116 @@ import type { ProfilePointer } from "nostr-tools/nip19";
const { host } = new URL(process.env.URL);
const relay = encodeURIComponent(config.publicRelay);
+// reCAPTCHA v3 is score-based: siteverify answers success:true for any
+// validly minted token, bot or not, and puts the verdict in `score` (0 = bot,
+// 1 = human). Checking `success` alone let a headless-browser signup wave
+// through on 2026-10-18 with a fresh token on every request. `actions` and
+// the hostname check stop a token minted for another page or site from being
+// replayed here.
+const verifyRecaptcha = async (
+ response,
+ req?,
+ { actions = [] as string[], minScore = 0 } = {},
+) => {
+ const { recaptcha: secret } = config;
+ if (!secret) return true;
+
+ // Skip captcha for Tor users accessing via .onion
+ const reqHost = req?.headers?.["x-forwarded-host"] || req?.headers?.["host"] || "";
+ if (reqHost.endsWith(".onion")) return true;
+
+ // Skip captcha for allowlisted User-Agents (prefix match)
+ const uaRaw = req?.headers?.["user-agent"] || req?.headers?.["User-Agent"];
+ const ua = Array.isArray(uaRaw) ? uaRaw[0] : uaRaw;
+ if (typeof ua === "string") {
+ const prefixes = await db.sMembers("nocaptcha_ua");
+ const uaLower = ua.toLowerCase();
+ if (prefixes.some((p) => uaLower.startsWith(p.toLowerCase()))) {
+ return true;
+ }
+ }
+
+ // Check for valid API key to bypass captcha
+ const apiKey = req?.headers?.["x-api-key"];
+ if (apiKey && (await db.sIsMember("apikeys", apiKey))) {
+ return true;
+ }
+
+ const { username } = req.body;
+ if (username &&
+ await db.sIsMember("nocaptcha", username.toLowerCase().replace(/\s/g, ""))
+ )
+ return true;
+
+ if (!response) return false;
+
+ try {
+ const ip = req?.headers?.["cf-connecting-ip"] || req?.socket?.remoteAddress;
+ const { success, score, action, hostname } = await got
+ .post("https://www.google.com/recaptcha/api/siteverify", {
+ form: {
+ secret,
+ response,
+ remoteip: ip,
+ },
+ // Bound the wait so a slow/unreachable Google can't hang the login.
+ timeout: { request: 6000 },
+ })
+ .json();
+ // A real verdict from Google: fail CLOSED only when the captcha itself is
+ // invalid (success === false).
+ if (!!config.adminpass && response === config.adminpass) return true;
+ if (!success) return false;
+ if (hostname && hostname !== host && !hostname.endsWith(`.${host}`)) {
+ warn("recaptcha wrong hostname", ip, hostname);
+ return false;
+ }
+ if (actions.length && !actions.includes(action)) {
+ warn("recaptcha wrong action", ip, action);
+ return false;
+ }
+ if (action === "register") l("recaptcha register score", ip, score);
+ if (typeof score === "number" && score < minScore) {
+ warn("recaptcha low score", ip, action, score);
+ return false;
+ }
+ return true;
+ } catch (e) {
+ // Fail OPEN on an INFRASTRUCTURE error (network blip, timeout, Google 5xx).
+ // Reaching Google is not part of authentication; failing closed here turned
+ // a transient coinos<->Google connectivity hiccup into a login outage —
+ // users got "failed captcha" until it cleared (2026-08-25 incident). The
+ // password and the per-IP login rate limits (ipCount>30 / ipFailures>20 ->
+ // 429) still gate the request; captcha is only bot-mitigation, so allowing
+ // it through when the verifier is unreachable is the safer failure mode.
+ warn(
+ "recaptcha verify unreachable — allowing login (fail-open)",
+ (e as any)?.code ?? (e as any)?.message ?? String(e),
+ );
+ return true;
+ }
+};
+
+// Cloudflare Turnstile, on trial next to reCAPTCHA for the signup page:
+// reCAPTCHA v3 scores the 2026-10 farm 0.9, so this only logs its verdict
+// until we've seen whether it tells the farm apart. Returns null when the
+// verifier can't be reached so callers can tell "unknown" from "failed".
+const verifyTurnstile = async (token, ip) => {
+ if (!config.turnstile || !token) return null;
+ try {
+ const r: any = await got
+ .post("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
+ form: { secret: config.turnstile, response: token, remoteip: ip },
+ timeout: { request: 6000 },
+ })
+ .json();
+ return r;
+ } catch (e) {
+ warn("turnstile verify unreachable", (e as any)?.code ?? String(e));
+ return null;
+ }
+};
+
export default {
upload,
@@ -32,21 +149,10 @@ export default {
const { user } = req;
try {
user.balance = await g(`balance:${user.id}`);
- user.locked = await ga(`balance:${user.id}`);
-
- if (user.locked) {
- const blacklisted = await db.sIsMember(
- "blacklist",
- user?.username?.toLowerCase().trim(),
- );
-
- const whitelisted = await db.sIsMember(
- "whitelist",
- user?.username?.toLowerCase().trim(),
- );
-
- if (!blacklisted || whitelisted) user.locked = 0;
- }
+ // The password-encrypted nostr key is only handed out by /login, after
+ // the password (and 2FA) check. A bearer token alone mustn't be enough
+ // to fetch it and brute-force or decrypt it offline.
+ user.hasnsec = !!(user.nsec || user.cipher);
user.prompt = !!user.prompt;
if (user.pubkey) user.npub = nip19.npubEncode(user.pubkey);
@@ -58,42 +164,6 @@ export default {
}
},
- async list(req, res) {
- const { user } = req;
- if (!user.admin) fail("unauthorized");
-
- const users = [];
-
- for await (const k of db.scanIterator({ MATCH: "balance:*" })) {
- const uid = k.split(":")[1];
- const user = await getUser(uid);
-
- if (!user) {
- await db.del(`balance:${uid}`);
- continue;
- }
-
- user.balance = await g(k);
-
- const payments = await db.lRange(`${uid}:payments`, 0, -1);
-
- let total = 0;
- for (const pid of payments) {
- const p = await g(`payment:${pid}`);
- if (!p) continue;
- total += p.amount;
- if (p.amount < 0)
- total -= (p.fee || 0) + (p.ourfee || 0) + (p.tip || 0);
- else total += p.tip || 0;
- }
-
- user.expected = total;
- users.push(user);
- }
-
- res.send(users);
- },
-
async get(req, res) {
let {
params: { key },
@@ -115,8 +185,7 @@ export default {
const user = await getNostrUser(key);
res.send(pick(user, fields));
} catch (e) {
- err("problem getting user", key, e.message);
- res.code(500).send(e.message);
+ res.code(404).send("User not found");
}
},
@@ -127,7 +196,128 @@ export default {
if (!body.user) fail("no user object provided");
let { user } = body;
- const fields = ["pubkey", "password", "username", "picture", "fresh"];
+ // Per-IP registration cap. Nothing else throttles /register (the
+ // recaptcha check only runs on login; the fastify limiters cover
+ // /login and /send), so one VPN exit created ~500 empty accounts at
+ // 3/min on 2026-09-08 without tripping anything. Five an hour is far
+ // above any household; an internal/private source IP (proxy without
+ // the Cloudflare header) is exempt so a misrouted request can't lock
+ // everyone out. Counted before validation so failed attempts count too.
+ if (ip && !/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|::1$|fc|fd)/.test(ip)) {
+ const k = `register:ip:${ip}`;
+ const n = await db.incr(k);
+ if (n === 1) await db.expire(k, 3600);
+ if (n > 5) {
+ warn("registration cap hit", ip, n);
+ fail("Too many accounts created from this address, try again later");
+ }
+
+ // Same idea one level up: a residential proxy pool hands out a fresh
+ // address per request, but they cluster in a few ISP ranges. All
+ // signups site-wide normally run ~25/hour, so 10/hour from one IPv4
+ // /16 or IPv6 /48 only bites a farm.
+ const range = ip.includes(":")
+ ? ip.split(":").slice(0, 3).join(":")
+ : ip.split(".").slice(0, 2).join(".");
+ const rk = `register:range:${range}`;
+ const rn = await db.incr(rk);
+ if (rn === 1) await db.expire(rk, 3600);
+ if (rn > 10) {
+ warn("registration range cap hit", ip, range, rn);
+ fail("Too many accounts created from this network, try again later");
+ }
+
+ // The farm came back spread over ~90 ranges an hour, each staying
+ // under the hourly cap, so also cap a range per day. Counted on
+ // attempts that got past the hourly caps.
+ const dk = `register:range:day:${range}`;
+ const dn = await db.incr(dk);
+ if (dn === 1) await db.expire(dk, 86400);
+ if (dn > 5) {
+ warn("registration daily range cap hit", ip, range, dn);
+ fail("Too many accounts created from this network, try again later");
+ }
+ }
+
+ // Forged browser User-Agent. Every engine that reports AppleWebKit also
+ // reports the "(KHTML, like Gecko)" token right behind it, so a UA that
+ // claims AppleWebKit without it was assembled by hand. Across 4.8M
+ // logged requests this matches 2,366: the c<epoch> registration bot
+ // (1,203 accounts from 596 rotating residential IPs — which is exactly
+ // why the per-IP cap above never touched it), PetalBot, Bytespider, and
+ // nothing else. Real browsers carry the token; non-browser clients that
+ // register legitimately (python-requests, curl, damus, our own Bun SSR)
+ // never claim AppleWebKit at all, so none of them match.
+ const ua = String(headers["user-agent"] || "");
+ if (ua.includes("AppleWebKit/") && !ua.includes("KHTML")) {
+ warn("registration blocked, forged user-agent", ip, ua.slice(0, 80));
+ fail("Registration unavailable");
+ }
+
+ // The signup form has always sent a recaptcha token and /register has
+ // always discarded it — it isn't even in the `fields` pick below — so
+ // the captcha only ever gated login. Verify it when one is supplied.
+ // Supplied-and-invalid is the only rejection: an ABSENT token still
+ // passes, because half of all registrations are API clients that never
+ // had one and requiring it would break them. verifyRecaptcha keeps its
+ // own bypasses (onion host, x-api-key, allowlisted UA) and fails open
+ // when Google is unreachable.
+ // Our own signup page (SSR, Bun UA) always sends a token on the
+ // clearnet; only the .onion page, which reaches us without a
+ // cf-connecting-ip, and the fund sweep (internal x-api-key, bypassed
+ // inside verifyRecaptcha) legitimately don't. So from the web UI an
+ // absent token is a scripted submit, not an API client.
+ if (!user.recaptcha && ip && ua.startsWith("Bun/")) {
+ if (!(await verifyRecaptcha(undefined, req))) {
+ warn("registration blocked, missing captcha", ip, user.username);
+ fail("Failed captcha");
+ }
+ }
+
+ if (
+ user.recaptcha &&
+ !(await verifyRecaptcha(user.recaptcha, req, {
+ actions: ["register"],
+ minScore: 0.5,
+ }))
+ ) {
+ warn("registration blocked, failed captcha", ip, user.username);
+ fail("Failed captcha");
+ }
+
+ if (ip && ua.startsWith("Bun/")) {
+ const t = await verifyTurnstile(user.turnstile, ip);
+ l(
+ "turnstile register",
+ ip,
+ user.username,
+ !user.turnstile
+ ? "missing"
+ : t
+ ? `${t.success ? "pass" : "fail"} ${t.hostname || ""} ${(t["error-codes"] || []).join(",")}`
+ : "unverified",
+ );
+ }
+
+ // Site-wide cap on signups that kept the auto-filled animal+NN name.
+ // The 2026-10 farm passes reCAPTCHA (0.9) and Cloudflare's challenge
+ // from thousands of residential IPs, but never changes the default
+ // name; real users keep it ~8 times a day. Hitting the cap only asks
+ // for a different name.
+ if (isDefaultName(user.username)) {
+ const k = `register:default:${Math.floor(Date.now() / 3600000)}`;
+ const n = await db.incr(k);
+ if (n === 1) await db.expire(k, 7200);
+ if (n > 10) {
+ warn("registration default-name cap hit", ip, user.username, n);
+ fail("Please choose your own username");
+ }
+ }
+
+ // No pubkey: nothing here proves the caller holds that key, and register()
+ // points `user:<pubkey>` at the new account. Nostr accounts come from
+ // /nostrAuth, which verifies a signed challenge first.
+ const fields = ["password", "username", "picture", "fresh"];
user = await register(pick(user, fields), ip);
const payload = { id: user.id };
@@ -135,7 +325,7 @@ export default {
l("registered new user", user.username);
- res.send({ ...pick(user, whitelist), sk: user.sk, token });
+ res.send({ ...pick(user, whitelist), nsec: user.nsec, sk: user.sk, token });
} catch (e) {
err("problem registering", e.message);
res.code(500).send(e.message);
@@ -152,9 +342,12 @@ export default {
return res.code(401).send("2fa required");
}
+ // Fresh seed on disable so a seed that leaked while 2FA was on is dead
+ // when it's re-enabled.
user.twofa = false;
+ user.otpsecret = authenticator.generateSecret();
await s(`user:${id}`, user);
- emit(username, "user", user);
+ emit(username, "user", pick(user, whitelist));
emit(username, "otpsecret", user.otpsecret);
l("disabled 2fa", username);
res.send({});
@@ -171,7 +364,7 @@ export default {
if (isValid) {
user.twofa = true;
await s(`user:${id}`, user);
- emit(username, "user", user);
+ emit(username, "user", pick(user, whitelist));
} else {
return res.code(500).send("Invalid token");
}
@@ -179,7 +372,6 @@ export default {
l("enabled 2fa", username);
res.send({});
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -199,32 +391,40 @@ export default {
const valid = /^[\p{L}\p{N}]{2,24}$/u;
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");
let exists;
let { pubkey } = body;
if (pubkey) {
pubkey = pubkey.trim();
+ if (pubkey.startsWith("npub")) pubkey = nip19.decode(pubkey).data;
exists = await getUser(pubkey);
+ const un = user.username.toLowerCase().replace(/\s/g, "");
const existingUsername = exists?.username
?.toLowerCase()
.replace(/\s/g, "");
- if (exists && username !== existingUsername) {
- warn("key in use", pubkey, existingUsername);
+ if (exists && un !== existingUsername) {
+ warn("key in use", pubkey, username, existingUsername);
if (exists.anon) await db.del(`user:${pubkey}`);
else fail("Key in use by another account");
}
const event = JSON.parse(body.event);
const challenge = event.tags.find((t) => t[0] === "challenge")[1];
- const c = await g(`challenge:${challenge}`);
+ // Single-use: GETDEL claims the challenge atomically, so the identical
+ // signed event can't be replayed within the 5-minute TTL (it is also
+ // written to the request log, so a log reader could otherwise reuse it).
+ // Burn it on read rather than on success — a failed attempt costs the
+ // client one extra GET /challenge, and two racing replays cannot both win.
+ const c = await db.getDel(`challenge:${challenge}`);
if (!c) fail("Invalid or expired challenge");
if (!verifyEvent(event) || event.pubkey !== pubkey)
fail("Invalid signature or challenge mismatch.");
pubkey = pubkey.replace(/\s*/g, "");
- if (pubkey.startsWith("npub")) pubkey = nip19.decode(pubkey).data;
if (pubkey.length !== 64) fail(`Invalid pubkey ${pubkey}`);
await db.del(`user:${user.pubkey}`);
user.pubkey = pubkey;
@@ -237,18 +437,20 @@ export default {
if (user.pin === "delete") user.pin = undefined;
if (username) {
- exists = await db.exists(`user:${username}`);
const currentUsername = user.username.replace(/\s/g, "").toLowerCase();
+ if (username !== currentUsername) {
+ exists = await db.exists(`user:${username}`);
- if (currentUsername !== username && exists) {
- err("username taken", username, currentUsername);
- fail("Username taken");
- } else if (username) {
- if (currentUsername !== username)
+ if (exists) {
+ err("username taken", username, currentUsername);
+ fail("Username taken");
+ } else {
+ // A name the v3 registrar holds is taken too (see lib/names.ts).
+ await assertNameFree(username, user.pubkey);
l("changing username", currentUsername, username);
-
- await db.del(`user:${currentUsername}`);
- user.username = username;
+ await db.del(`user:${currentUsername}`);
+ user.username = username;
+ }
}
}
@@ -265,6 +467,7 @@ export default {
"language",
"locktime",
"memoPrompt",
+ "migrated",
"nip5",
"notify",
"nsec",
@@ -281,23 +484,28 @@ export default {
"twofa",
];
+ if (body.email) {
+ user.verified = false;
+ user.notify = false;
+ }
+
for (const a of attributes) {
if (typeof body[a] !== "undefined") user[a] = body[a];
}
user.fresh = false;
- user.tip = Math.max(0, Math.min(1000, parseInt(user.tip)));
+ user.tip = Math.max(0, Math.min(1000, Number.parseInt(user.tip)));
if (password && password === confirm) {
user.password = await Bun.password.hash(password, {
algorithm: "bcrypt",
- cost: 4,
+ cost: 12,
});
}
user.haspin = !!user.pin;
if (user.destination) user.destination = user.destination.trim();
- await s(`user:${user.pubkey}`, user.id);
+ if (user.pubkey) await s(`user:${user.pubkey}`, user.id);
await s(
`user:${user.username.toLowerCase().replace(/\s/g, "")}`,
user.id,
@@ -306,8 +514,8 @@ export default {
await s(`user:${user.id}`, user);
if (user.nip5) await db.sAdd("nip5", `${user.username}:${user.pubkey}`);
- emit(user.id, "user", user);
- res.send({ user });
+ emit(user.id, "user", pick(user, whitelist));
+ res.send({ user: pick(user, whitelist) });
} catch (e) {
warn("failed to update", user.username, e.message);
bail(res, e.message);
@@ -316,22 +524,61 @@ export default {
async login(req, res) {
try {
- let { username, password, token: twofa } = req.body;
+ let { username, password, token: twofa, recaptcha } = req.body;
+ const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress;
+
+ const ipKey = `ip:${ip}:login`;
+ const ipCount = await db.incr(ipKey);
+ if (ipCount === 1) await db.expire(ipKey, 10);
+ if (ipCount > 30) return res.code(429).send({});
+
+ // Fail closed: an unset adminpass must never authenticate, and an omitted
+ // password field must never coincide with an unset value (undefined ===
+ // undefined). Both the configured value and the supplied one must be
+ // non-empty and match exactly.
+ const isAdmin =
+ !!config?.adminpass && !!password && password === config.adminpass;
+
+ if (!isAdmin) {
+ const recaptchaOk = await verifyRecaptcha(recaptcha, req, {
+ actions: ["login", "register"],
+ minScore: 0.3,
+ });
+ if (!recaptchaOk) {
+ return res.code(401).send("failed captcha");
+ }
+ }
username = username.toLowerCase().replace(/\s/g, "");
+ username = username.split("@")[0];
+
+ const ipFailKey = `ip:${ip}:login:fail`;
+ const ipFailures = await g(ipFailKey);
+ if (!isAdmin && ipFailures > 20) return res.code(429).send({});
+
let user = await getUser(username);
- if (password !== config?.adminpass) {
- if (
- !user ||
- !user.password ||
- !(await Bun.password.verify(password, user.password))
- ) {
- // warn("invalid username or password attempt", username);
- await appendFile(
- "failedlogins.txt",
- `${Date.now()} : ${username} : ${password}\n`,
- );
+ if (!isAdmin) {
+ // changeid leaves the literal "reset" in place of a hash, so no
+ // password can ever match. Say so, letting the client point the
+ // owner at support instead of a generic login failure. This runs
+ // after the captcha and the per-IP failure cap, and counts as a
+ // failure itself.
+ if (user?.password === "reset") {
+ await db.incrBy(ipFailKey, 1);
+ if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600);
+ return res.code(401).send("account reset");
+ }
+
+ let verified = false;
+ try {
+ if (user?.password)
+ verified = await Bun.password.verify(password, user.password);
+ } catch (e) {}
+
+ if (!user || !verified) {
+ await db.incrBy(ipFailKey, 1);
+ if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600);
return res.code(401).send({});
}
@@ -340,17 +587,49 @@ export default {
(typeof twofa === "undefined" ||
!authenticator.check(twofa, user.otpsecret))
) {
+ await db.incrBy(ipFailKey, 1);
+ if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600);
return res.code(401).send("2fa required");
}
+
+ // Transparent bcrypt upgrade: legacy accounts were hashed at cost 4
+ // (~1,300 guesses/s). Now that we hold the verified plaintext, re-hash
+ // at the current cost so the stored hash strengthens on next login.
+ const cost = Number.parseInt(
+ user.password.match(/^\$2[aby]\$(\d{2})\$/)?.[1] ?? "0",
+ 10,
+ );
+ let changed = false;
+ if (cost < 12) {
+ user.password = await Bun.password.hash(password, {
+ algorithm: "bcrypt",
+ cost: 12,
+ });
+ changed = true;
+ }
+
+ if (await upgradeLegacyNsec(user, password)) changed = true;
+ if (changed) await s(`user:${user.id}`, user);
}
+ // Evicted accounts can't use a token anyway (see lib/auth.ts), so don't
+ // mint one — checked after the password so it can't be used to probe.
+ if (!isAdmin && (await db.sIsMember("evicted", user.id)))
+ return res.code(403).send("account disabled");
+
if (username !== "coinos")
l("logged in", username, req.headers["cf-connecting-ip"]);
const payload = { id: user.id };
const token = jwt.sign(payload, config.jwt);
- res.cookie("token", token, { expires: new Date(Date.now() + 432000000) });
- user = pick(user, whitelist);
+ res.cookie("token", token, {
+ expires: new Date(Date.now() + 432000000),
+ path: "/",
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ });
+ user = { ...pick(user, whitelist), hasnsec: !!(user.nsec || user.cipher), nsec: user.nsec };
res.send({ user, token });
} catch (e) {
err("login error", e.message, req.socket.remoteAddress);
@@ -366,9 +645,18 @@ export default {
async nostrAuth(req, res) {
try {
- const { event, challenge, twofa } = req.body;
+ const { event, challenge, twofa, recaptcha } = req.body;
const ip = req.headers["cf-connecting-ip"];
- const c = await g(`challenge:${challenge}`);
+ const recaptchaOk = await verifyRecaptcha(recaptcha, req, {
+ actions: ["login", "register"],
+ minScore: 0.3,
+ });
+ if (!recaptchaOk) {
+ return res.code(401).send("failed captcha");
+ }
+ // Single-use — see the GETDEL note on the other challenge consumer. The
+ // captcha is checked above, so a captcha failure doesn't burn a challenge.
+ const c = await db.getDel(`challenge:${challenge}`);
const { pubkey: key, kind } = event;
if (kind !== 27235) fail("Invalid event");
if (!c) fail("Invalid or expired login challenge");
@@ -401,25 +689,21 @@ export default {
}
const { username } = user;
- if (
- await db.sIsMember(
- "compromised",
- username.replace(/\s/g, "").toLowerCase(),
- )
- ) {
- warn("compromised nostr auth attempt");
- fail("unauthorized");
- }
-
l("nostr login", username, ip);
const payload = { id: user.id };
const token = jwt.sign(payload, config.jwt);
- res.cookie("token", token, { expires: new Date(Date.now() + 432000000) });
- user = pick(user, whitelist);
+ res.cookie("token", token, {
+ expires: new Date(Date.now() + 432000000),
+ path: "/",
+ httpOnly: true,
+ secure: true,
+ sameSite: "lax",
+ });
+ user = { ...pick(user, whitelist), hasnsec: !!(user.nsec || user.cipher) };
res.send({ user, token });
} catch (e) {
- err("login error", e.message, req.socket.remoteAddress);
+ err("nostr login error", e.message, req.socket.remoteAddress);
res.code(401).send({});
}
},
@@ -441,7 +725,6 @@ export default {
await db.sAdd(`${id}:subscriptions`, JSON.stringify(subscription));
res.send(subscription);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -453,7 +736,6 @@ export default {
await db.sRem(`${id}:subscriptions`, JSON.stringify(subscription));
res.send(subscription);
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -473,6 +755,42 @@ export default {
}
},
+ // The encrypted nostr key, for clients that need to reveal or sign with it
+ // after a session that didn't start with a password login. Re-checking the
+ // password keeps a bearer token alone from fetching it (see /me).
+ async nsec(req, res) {
+ const {
+ body: { password },
+ user,
+ } = req;
+ const ip = req.headers["cf-connecting-ip"] || req.socket.remoteAddress;
+ const ipFailKey = `ip:${ip}:login:fail`;
+
+ try {
+ if (!user.nsec && !user.cipher) fail("nsec not available");
+ if ((await g(ipFailKey)) > 20) return res.code(429).send({});
+
+ let verified = false;
+ try {
+ if (user.password && password)
+ verified = await Bun.password.verify(password, user.password);
+ } catch (e) {}
+
+ if (!verified) {
+ await db.incrBy(ipFailKey, 1);
+ if ((await db.ttl(ipFailKey)) < 0) await db.expire(ipFailKey, 600);
+ return res.code(401).send("invalid password");
+ }
+
+ if (await upgradeLegacyNsec(user, password)) await s(`user:${user.id}`, user);
+ if (!user.nsec) fail("nsec could not be decrypted");
+
+ res.send({ nsec: user.nsec });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
async pin(req, res) {
const {
body: { pin },
@@ -484,6 +802,10 @@ export default {
async otpsecret(req, res) {
try {
await requirePin(req);
+ // Only needed to enrol. Once 2FA is on, the seed never leaves the
+ // server again — otherwise a stolen token on a PIN-less account is
+ // enough to mint valid codes.
+ if (req.user.twofa) fail("2FA is already enabled");
const { otpsecret, username } = req.user;
res.send({ secret: otpsecret, username });
} catch (e) {
@@ -506,7 +828,7 @@ export default {
for (const { ref } of (
await Promise.all(
- payments.reverse().map(async (id) => await g(`payment:${id}`)),
+ payments.reverse().map(async (id) => await gf(`payment:${id}`)),
)
).filter((p) => p && p.type === PaymentType.internal && p.ref)) {
if (ref === id) continue;
@@ -550,6 +872,7 @@ export default {
params: { username },
headers: { authorization },
} = req;
+ fail("Unauthorized");
username = username.toLowerCase();
if (!authorization?.includes(config.admin))
return res.code(401).send("unauthorized");
@@ -569,47 +892,100 @@ export default {
res.send({});
},
+ // Authenticated self-delete: wipes every key tied to the calling user via
+ // lib/deluser (the same sweep as scripts/delall.ts, offers and archive
+ // included). Gated by typing the username (works for password and nostr
+ // accounts alike) plus the PIN when one is set, and refuses while a balance
+ // remains so a user can't accidentally destroy funds.
+ async deleteSelf(req, res) {
+ try {
+ const { user } = req;
+ const { id, username } = user;
+
+ const confirm = (req.body?.confirm || "").toString().trim().toLowerCase();
+ if (confirm !== username.toLowerCase())
+ fail("Type your username to confirm account deletion");
+ await requirePin(req);
+
+ // Allow deletion through dust (often too small to withdraw); only block
+ // a meaningful balance so nobody destroys real funds by accident. Sum
+ // EVERY account (main + sub-accounts) — the sweep wipes each
+ // sub-account's balance:/pending: keys unconditionally, so a guard that
+ // only checked the main account let a user with funds parked in a
+ // sub-account pass and silently destroy them.
+ const aids = await db.lRange(`${id}:accounts`, 0, -1);
+ if (!aids.includes(id)) aids.push(id);
+ let total = 0;
+ for (const aid of aids) {
+ total += Number(await db.get(`balance:${aid}`)) || 0;
+ total += Number(await db.get(`pending:${aid}`)) || 0;
+ }
+ if (total > 10000)
+ fail("Withdraw your balance before deleting your account");
+
+ const { deleted, disabled } = await deleteUser(user);
+
+ l("user self-deleted", username, id, `${deleted} keys, ${disabled} offers`);
+ try {
+ res.clearCookie("token");
+ } catch {}
+ res.send({ deleted: true });
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
async reset(req, res) {
const {
body: { code, username, password },
user: u,
} = req;
- const admin = u?.admin;
- let id;
- let user;
+ try {
+ let id;
+ let user;
- if (admin) {
+ // Non-admins must not be able to tell this admin-only endpoint exists.
+ // Log the probe (so exploit-watch can flag it) but return a generic 404
+ // identical to an unregistered route — previously it returned "disabled",
+ // which confirmed the endpoint AND its admin-gating to attackers.
+ if (u?.username !== config.admin) {
+ err("password reset failed disabled", req.headers["cf-connecting-ip"]);
+ return res
+ .code(404)
+ .send({ message: "Route POST:/reset not found", error: "Not Found", statusCode: 404 });
+ }
id = await g(`user:${username.toLowerCase().replace(/\s/g, "")}`);
user = await g(`user:${id}`);
- } else {
- id = await g(`reset:${code}`);
- user = await g(`user:${id}`);
- }
- if (!user) fail("user not found");
+ if (!user) fail("user not found");
- warn(
- "password reset",
- user.username,
- code,
- req.headers["cf-connecting-ip"],
- );
+ warn(
+ "password reset",
+ user.username,
+ code,
+ req.headers["cf-connecting-ip"],
+ );
- try {
user.pin = null;
user.nsec = null;
+ user.cipher = null;
+ user.salt = null;
user.password = await Bun.password.hash(password, {
algorithm: "bcrypt",
- cost: 4,
+ cost: 12,
});
await s(`user:${id}`, user);
await db.del(`reset:${code}`);
res.send(pick(user, whitelist));
+
+ for await (const k of db.scanIterator({ MATCH: "ip:*:login:fail" })) {
+ await db.del(k);
+ }
} catch (e) {
- err("password reset failed", e.message);
+ err("password reset failed", e.message, req.headers["cf-connecting-ip"]);
bail(res, e.message);
}
},
@@ -685,7 +1061,7 @@ export default {
await s(`user:${id}`, user);
await s(`email:${email.toLowerCase()}`, id);
- res.send(pick(user, whitelist));
+ res.send(pick(user, fields));
} catch (e) {
bail(res, e.message);
}
@@ -716,28 +1092,13 @@ export default {
}
},
- async hidepay(req, res) {
- const {
- body: { username },
- } = req;
- const u = await getUser(username);
- u.hidepay = true;
- await s(`user:${u.id}`, u);
- res.send({});
- },
-
- async unlimit(req, res) {
- const {
- body: { username },
- } = req;
- const u = await getUser(username);
- u.unlimited = true;
- await s(`user:${u.id}`, u);
- res.send({});
- },
-
async account(req, res) {
const { id } = req.params;
+ const { id: uid } = req.user;
+
+ const pos = await db.lPos(`${uid}:accounts`, id);
+ if (pos == null) fail("account not found");
+
const account = await g(`account:${id}`);
if (account) account.balance = await g(`balance:${id}`);
res.send(account);
@@ -758,45 +1119,17 @@ export default {
res.send(accounts.reverse());
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
async createAccount(req, res) {
try {
- const { fingerprint, pubkey, name, seed, type } = req.body;
+ const { name, type } = req.body;
const { user } = req;
- const { id: uid } = user;
const id = v4();
- const account = { id, name, seed, type, uid, descriptors: [] };
-
- let node = rpc(config[type]);
-
- await node.createWallet({
- wallet_name: id,
- descriptors: true,
- disable_private_keys: true,
- load_on_startup: true,
- });
-
- node = rpc({ ...config[type], wallet: id });
-
- for (const i of [0, 1]) {
- const desc = `wpkh([${fingerprint}]${pubkey}/${i}/*)`;
- const { checksum } = await node.getDescriptorInfo(desc);
- account.descriptors.push({
- desc: `${desc}#${checksum}`,
- range: [0, 100],
- next_index: 0,
- timestamp: "now",
- internal: i === 1,
- active: true,
- });
- }
-
- await node.importDescriptors(account.descriptors);
+ const account = { id, name, type, uid: user.id };
await db
.multi()
@@ -806,10 +1139,6 @@ export default {
.lPush(`${user.id}:accounts`, id)
.exec();
- reconcile(account, true);
-
- await appendFile("/bitcoin.conf", `wallet=${id}\n`);
-
res.send(account);
} catch (e) {
bail(res, e.message);
@@ -835,17 +1164,10 @@ export default {
try {
const { id } = req.body;
const { id: uid } = req.user;
- const { type } = await g(`account:${id}`);
+ const account = await g(`account:${id}`);
const pos = await db.lPos(`${uid}:accounts`, id);
- if (!(type && pos != null)) fail("account not found");
-
- try {
- const node = rpc({ ...config[type], wallet: id });
- await node.unloadWallet(id);
- } catch (e) {
- warn("failed to unload wallet", id);
- }
+ if (!(account && pos != null)) fail("account not found");
await db
.multi()
@@ -857,39 +1179,44 @@ export default {
res.send({ ok: true });
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
- async printer(req, res) {
- const { ssid, key, username, password } = req.body;
- const dir = "./printer";
- const path = `${dir}/config.txt`;
-
+ async flash(req, res) {
+ // Build in a per-request scratch dir. A shared ./printer/config.txt and
+ // ./littlefs.img let two overlapping requests hand one user the other's
+ // wifi key and API token.
+ const dir = await mkdtemp(join(tmpdir(), "flash-"));
try {
- await unlink(path);
- await appendFile(path, `${ssid}\n`, "utf8");
- await appendFile(path, `${key}\n`, "utf8");
- await appendFile(path, `${username}\n`, "utf8");
- await appendFile(path, `${password}\n`, "utf8");
-
- const output = "./littlefs.img";
- await $`./mklittlefs -c ${dir} -p 256 -b 4096 -s 0x20000 ${output}`;
-
+ const { ssid, key, token } = req.body;
+ if (!ssid || !key || !token) fail("ssid, key and token required");
+ const cfg = `${ssid.trim()}\n${key.trim()}\n${token.trim()}\n`;
+ const src = join(dir, "fs");
+ const img = join(dir, "littlefs.img");
+ await mkdir(src);
+ await writeFile(join(src, "config.txt"), cfg, "utf8");
+ await $`./mklittlefs -c ${src} -p 256 -b 4096 -s 0x20000 ${img}`;
res.header("Content-Type", "application/octet-stream");
- res.header("Content-Disposition", "attachment; filename=littlefs.img");
- console.log("sending back");
- return res.send(createReadStream(output));
- } catch (error) {
- res.code(500).send({ error: "Failed to generate LittleFS image" });
+ // Read it fully before the finally below removes the directory.
+ return res.send(await readFile(img));
+ } catch (e) {
+ bail(res, e.message);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
}
},
async app(req, res) {
const { pubkey } = req.params;
const { user } = req;
+ if (!/^[0-9a-f]{64}$/.test(pubkey))
+ return res.code(404).send({ error: "connection not found" });
const app = await g(`app:${pubkey}`);
+ // Missing records must 404, not crash into a 500: clients (Damus one-click
+ // setup) probe this endpoint and treat 404 as "not configured yet, create
+ // one" — anything else aborts their whole connection flow.
+ if (!app) return res.code(404).send({ error: "connection not found" });
if (app.uid !== user.id) fail("unauthorized");
const lud16 = `${user.username}@${host}`;
@@ -898,13 +1225,13 @@ export default {
const payments = await Promise.all(
pids.map(async (pid) => {
- const p = await g(`payment:${pid}`);
- p.user = await g(`user:${p.uid}`);
+ const p = await gf(`payment:${pid}`);
+ if (p) p.user = pick((await g(`user:${p.uid}`)) || {}, fields);
return p;
}),
);
- app.nwc = `nostr+walletconnect://${serverPubkey}?relay=${relay}&secret=${app.secret}&lud16=${lud16}`;
+ app.nwc = `nostr+walletconnect://${serverPubkey2}?relay=${relay}&secret=${app.secret}&lud16=${lud16}`;
app.payments = payments.filter((p) => p);
res.send(app);
@@ -913,26 +1240,31 @@ export default {
async apps(req, res) {
const { user } = req;
const pubkeys = await db.sMembers(`${user.id}:apps`);
- const apps = await Promise.all(pubkeys.map((p) => g(`app:${p}`)));
+ // Drop set members whose app record was deleted (e.g. an invalidation
+ // sweep) so one stale pubkey can't 500 the whole listing.
+ const apps = (await Promise.all(pubkeys.map((p) => g(`app:${p}`)))).filter(
+ Boolean,
+ );
const lud16 = `${user.username}@${host}`;
await Promise.all(
apps.map(async (a) => {
if (a.secret)
- a.nwc = `nostr+walletconnect://${serverPubkey}?relay=${relay}&secret=${a.secret}&lud16=${lud16}`;
+ a.nwc = `nostr+walletconnect://${serverPubkey2}?relay=${relay}&secret=${a.secret}&lud16=${lud16}`;
const pids = await db.lRange(`${a.pubkey}:payments`, 0, -1);
let payments = await Promise.all(
- pids.map((pid) => g(`payment:${pid}`)),
+ pids.map((pid) => gf(`payment:${pid}`)),
);
payments = payments.filter((p) => p);
a.spent = payments.reduce(
(a, b) =>
a +
- (Math.abs(parseInt(b.amount || 0)) +
- parseInt(b.fee || 0) +
- parseInt(b.ourfee || 0)),
+ (Math.abs(Number.parseInt(b.amount || 0)) +
+ Number.parseInt(b.tip || 0) +
+ Number.parseInt(b.fee || 0) +
+ Number.parseInt(b.ourfee || 0)),
0,
);
}),
@@ -955,10 +1287,64 @@ export default {
const { user } = req;
const uid = user.id;
- let app = await g(pubkey);
+ if (secret) pubkey = getPublicKey(hexToBytes(secret));
+
+ // The pubkey names `app:<pubkey>` and `<pubkey>:payments`, so it must be
+ // a real NWC key: a user id here would hand the caller that user's
+ // payment list through GET /app.
+ if (!/^[0-9a-f]{64}$/.test(pubkey)) fail("Invalid pubkey");
+ // App records live under `app:<pubkey>`. Looking up the bare pubkey made
+ // this ownership check ineffective for normal NWC keys and allowed an
+ // authenticated user to overwrite another connection's configuration.
+ let app = await g(`app:${pubkey}`);
if (app && uid !== app.uid) fail("Unauthorized");
- if (secret) pubkey = getPublicKey(secret);
+
+ // Rotation is mandatory after a credential disclosure: the NWC pubkey is
+ // derived from the bearer secret, so re-registering a retired or
+ // quarantined pubkey would re-authorize the exact secret that was
+ // disclosed (we store client-supplied secrets, so they're part of any DB
+ // leak). Reject it and make the client mint a fresh secret — a new secret
+ // yields a new pubkey and never trips this check.
+ const credentialCutoff = Number(await g("nwc:credential-cutoff")) || 0;
+ const retired =
+ (app &&
+ credentialCutoff > 0 &&
+ (!Number(app.created) || Number(app.created) < credentialCutoff)) ||
+ (await db.sIsMember("nwc:quarantined", pubkey));
+ if (retired)
+ return res.code(403).send({
+ error:
+ "This connection's key was retired for security reasons; connect again with a newly generated secret",
+ });
+
+ const validRenewals = new Set([
+ "daily",
+ "weekly",
+ "monthly",
+ "yearly",
+ "never",
+ ]);
+ if (budget_renewal && !validRenewals.has(budget_renewal))
+ fail("Invalid budget renewal period");
+
+ for (const [label, value] of [
+ ["spending budget", max_amount],
+ ["maximum fee", max_fee],
+ ]) {
+ if (
+ value !== undefined &&
+ value !== null &&
+ value !== "" &&
+ (!Number.isFinite(Number(value)) || Number(value) < 0)
+ )
+ fail(`Invalid ${label}`);
+ }
+
+ // Notifications are opt-in (default off): most users don't consume them and
+ // publishing payment_sent/payment_received for every app would flood the
+ // relay. A client enables them by sending notify:"true" when creating the
+ // connection. (Reverted a brief default-on change, 2026-06-06.)
notify = String(notify) === "true";
app = {
@@ -973,14 +1359,16 @@ export default {
secret,
};
- if (!app?.created) app.created = Date.now();
+ // Refresh the creation epoch on every owner post: this record passed the
+ // retirement check above, so the owner updating it (or minting it fresh)
+ // asserts it's live and keeps it clear of the credential cutoff.
+ app.created = Date.now();
await s(`app:${pubkey}`, app);
await db.sAdd(`${uid}:apps`, pubkey);
res.send({});
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -990,13 +1378,15 @@ export default {
const { user } = req;
const uid = user.id;
const { pubkey } = req.body;
- const app = await g(pubkey);
- if (app && uid !== app.uid) fail("Unauthorized");
+ const app = await g(`app:${pubkey}`);
+ if (app && uid !== app.uid) {
+ warn(app.uid, uid);
+ fail("Unauthorized");
+ }
await db.sRem(`${uid}:apps`, pubkey);
await db.del(`app:${pubkey}`);
res.send({});
} catch (e) {
- console.log(e);
bail(res, e.message);
}
},
@@ -1041,4 +1431,11 @@ export default {
const liquid = await g(`credit:liquid:${id}`);
res.send({ bitcoin, lightning, liquid });
},
+
+ async ro(req, res) {
+ const { user } = req;
+ const payload = { id: `${user.id}-ro` };
+ const token = jwt.sign(payload, config.jwt);
+ res.send(token);
+ },
};