aboutsummaryrefslogtreecommitdiffstats
path: root/lib
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-30 10:28:23 -0700
committerAdam Soltys <[email protected]>2026-07-30 10:38:43 -0700
commitfd1617ea66e01557e71debc7e45ca08cfe01bf70 (patch)
tree8c3600a86d60dc7731130397c98e818970d63990 /lib
parentf5a5b1ff856efcd2849275a301daabb80ef76638 (diff)
downloadcoinos-server-fd1617ea66e01557e71debc7e45ca08cfe01bf70.tar.gz
coinos-server-fd1617ea66e01557e71debc7e45ca08cfe01bf70.zip
mint/lightning: refuse caller-supplied invoices, tiered fees, log hygiene
- invoices: reject caller-supplied bolt11/bolt12; the server always mints its own. Closes the invoice-registration path behind the shared-node credit and payment-hijack vectors. - lightning: skip mint-owned invoices (lbl* label) in the paid-invoice listener. - payments: lightning free-tier fee credit, withheld for flagged high-throughput (ln:nofree) accounts which pay an elevated rate; the failed-send reversal restores credit at the same rate. freezeCheck made crash-resilient. Lightning and bolt12 sends stay pending until the preimage settles. - payments (route): validate payment-request / invoice input types. - logging: collapse repeated xpay failure lines into one. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Diffstat (limited to 'lib')
-rw-r--r--lib/invoices.ts25
-rw-r--r--lib/lightning.ts10
-rw-r--r--lib/logging.ts19
-rw-r--r--lib/payments.ts134
4 files changed, 147 insertions, 41 deletions
diff --git a/lib/invoices.ts b/lib/invoices.ts
index d11073c2..9661bbc7 100644
--- a/lib/invoices.ts
+++ b/lib/invoices.ts
@@ -90,14 +90,14 @@ export const generate = async ({ invoice, user }) => {
let paymentHash;
if (type === PaymentType.lightning) {
+ // SECURITY: never accept a caller-supplied bolt11. It let anyone register any
+ // node-payable invoice (incl. a nutshell mint quote) as their own coinos
+ // deposit — which both double-credited (coinos balance + mint ecash) and let
+ // an attacker hijack another user's incoming payment (there was no ownership
+ // guard, so it overwrote invoice:<bolt11>). Always mint the invoice here.
+ if (bolt11) fail("Cannot provide your own Lightning invoice");
let r;
- if (bolt11) {
- const { id: nodeid } = await ln.getinfo();
- r = await ln.decode(bolt11);
- if (r.payee !== nodeid) fail("invalid invoice");
- amount = Math.round(r.amount_msat / 1000);
- r.bolt11 = bolt11;
- } else {
+ {
expiry ||= 60 * 60 * 24 * 30;
const args = {
@@ -174,14 +174,11 @@ export const generate = async ({ invoice, user }) => {
text = r.bolt11;
paymentHash = r.payment_hash;
} else if (type === PaymentType.bolt12) {
+ // SECURITY: same as bolt11 — never accept a caller-supplied bolt12. Always
+ // mint the offer server-side.
+ if (bolt12) fail("Cannot provide your own bolt12 invoice");
let r;
- if (bolt12) {
- const { id: nodeid } = await ln.getinfo();
- r = await ln.decode(bolt12);
- if (r.invoice_node_id !== nodeid) fail("invalid invoice");
- amount = Math.round(r.invoice_amount_msat / 1000);
- r.bolt12 = bolt12;
- } else {
+ {
r = await ln.offer({
amount: amount ? `${amount + tip}sat` : "any",
label: `${id} ${user.username} ${new Date()}`,
diff --git a/lib/lightning.ts b/lib/lightning.ts
index 8935f2ce..cf099b23 100644
--- a/lib/lightning.ts
+++ b/lib/lightning.ts
@@ -42,6 +42,7 @@ export async function listenForLightning() {
const inv = await lnListen.waitanyinvoice(payIndex);
const {
+ label,
local_offer_id,
bolt11,
bolt12,
@@ -77,6 +78,15 @@ export async function listenForLightning() {
try {
if (!preimage) return;
+ // The mint (nutshell) shares this cl node, so waitanyinvoice also fires for
+ // mint-quote invoices. Those are owned by the mint (it issues ecash for
+ // them) and must NOT credit a coinos balance — otherwise a single payment
+ // is credited twice (coinos balance + ecash = double-mint exploit). The
+ // mint labels its invoices "lbl<random>"; coinos labels are
+ // "<uuid> <username> <ts>". Never credit a mint-owned invoice.
+ if (typeof label === "string" && label.startsWith("lbl"))
+ return warn("skipping mint-owned invoice (not a coinos deposit)", label, bolt11);
+
const invoice = await getInvoice(bolt11 ?? local_offer_id ?? bolt12);
if (!invoice) return warn("received lightning with no invoice", bolt11);
diff --git a/lib/logging.ts b/lib/logging.ts
index aab110c2..c68a3199 100644
--- a/lib/logging.ts
+++ b/lib/logging.ts
@@ -4,6 +4,25 @@ export const l = (...msgs) => pino().info(msgs.join(" "));
export const warn = (...msgs) => pino().warn(msgs.join(" "));
export const err = (...msgs) => pino().error(msgs.join(" "));
+// Collapse a verbose error message for logging. CLN's xpay concatenates one
+// failure sentence per routing retry (e.g. "We got temporary_channel_failure
+// for X … updating our map." repeated 50+ times), which floods the logs on an
+// ordinary failed lightning send. Dedupe repeated sentences and cap length.
+export const shortError = (msg: any, max = 400): string => {
+ let s = typeof msg === "string" ? msg : msg?.message ?? String(msg);
+ const parts = s.split(/(?<=\.)\s+/);
+ const seen = new Set<string>();
+ const uniq: string[] = [];
+ for (const p of parts) {
+ const t = p.trim();
+ if (t && !seen.has(t)) { seen.add(t); uniq.push(t); }
+ }
+ s = uniq.join(" ");
+ const dropped = parts.length - uniq.length;
+ if (dropped > 0) s += ` [${dropped} repeated lines collapsed]`;
+ return s.length > max ? `${s.slice(0, max)}…` : s;
+};
+
export const line = () => {
const stack = new Error().stack;
const stackLine = stack.split("\n")[1];
diff --git a/lib/payments.ts b/lib/payments.ts
index 56aa08de..7855abd2 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -18,7 +18,7 @@ const isWithdrawLocked = (type: string): string | null => {
};
import { generate } from "$lib/invoices";
import ln from "$lib/ln";
-import { err, l, warn } from "$lib/logging";
+import { err, l, shortError, warn } from "$lib/logging";
import { handleZap } from "$lib/nostr";
import { notify, nwcNotify } from "$lib/notifications";
import { squarePayment } from "$lib/square";
@@ -196,19 +196,41 @@ export const debit = async ({
}
}
- const tip = Number.parseInt(invoice?.tip) || null;
+ // Capture the tip ONCE, and only for INTERNAL payments — where credit() pays
+ // the invoice owner the same tip (sendInternal passes p.tip through). External
+ // sends (bitcoin/liquid/lightning/bolt12/fund) must NOT inherit a tip: the
+ // destination `hash` can coincidentally match another user's deposit invoice,
+ // which would otherwise pull that stranger's tip into this debit's balance
+ // check and stored record (the LiquidNode negative-balance vector). This one
+ // captured value feeds the ourfee calc, the atomic debit, and the record, so
+ // the record can never disagree with what was actually debited.
+ const tip =
+ type === PaymentType.internal ? Number.parseInt(invoice?.tip) || null : null;
if (tip < 0) fail("Invalid tip");
if (!amount || amount < 0) fail("Amount must be greater than zero");
let creditType = type;
if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning;
+
+ // High-throughput pass-through accounts (flagged `ln:nofree` by
+ // ln-freetier-monitor.ts) pay an elevated platform fee on lightning
+ // withdrawals; everyone else pays the standard rate. Flagged accounts already
+ // accrue no free-tier credit, so this rate is what they actually pay.
+ let feeRate = config.fee[creditType];
+ if (
+ creditType === PaymentType.lightning &&
+ aid === uid &&
+ (await g(`ln:nofree:${uid}`))
+ )
+ feeRate = config.fee.lightningHigh;
+
let ourfee: any = [
PaymentType.bitcoin,
PaymentType.liquid,
PaymentType.lightning,
].includes(type)
- ? Math.round((amount + fee + tip) * config.fee[creditType])
+ ? Math.round((amount + fee + tip) * feeRate)
: 0;
if (aid !== uid) ourfee = 0;
@@ -228,6 +250,17 @@ export const debit = async ({
if (ourfee.err) fail(ourfee.err);
+ // Defense-in-depth: the DEBIT lua rejects overdrafts, so a negative balance
+ // here means the invariant was violated by a mismatched input or a non-lua
+ // balance write. Log loudly with full context so the exact vector is greppable
+ // (SECURITY: negative balance) instead of surfacing days later as a stuck
+ // negative account.
+ const postBal = Number.parseInt((await db.get(`balance:${aid}`)) || "0");
+ if (postBal < 0)
+ err(
+ `SECURITY: negative balance ${postBal} after debit user=${user?.username} aid=${aid} amount=${amount} tip=${tip} fee=${fee} ourfee=${ourfee} type=${type} hash=${hash}`,
+ );
+
const id = v4();
const p = {
id,
@@ -240,7 +273,13 @@ export const debit = async ({
memo,
iid,
uid,
- confirmed: true,
+ // Lightning/bolt12 sends are IN-FLIGHT until the HTLC settles (preimage
+ // revealed). Don't mark them confirmed on the optimistic debit — finalize()
+ // flips this to true once the preimage arrives. Hold invoices (e.g. Ark)
+ // can keep an HTLC pending for hours/days; showing "confirmed" while the
+ // recipient hasn't been paid is misleading. Other send types settle
+ // immediately (internal) or are broadcast right away (bitcoin/liquid/fund).
+ confirmed: ![PaymentType.lightning, PaymentType.bolt12].includes(type),
rate,
currency,
type,
@@ -399,15 +438,26 @@ export const credit = async ({
let creditType = type;
if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning;
- if (
- [PaymentType.bitcoin, PaymentType.liquid, PaymentType.lightning].includes(
- creditType,
- )
- )
+ // Bitcoin/liquid always accrue the same-network fee credit.
+ if ([PaymentType.bitcoin, PaymentType.liquid].includes(creditType)) {
m.incrBy(
`credit:${creditType}:${uid}`,
Math.round(amount * config.fee[creditType]),
);
+ } else if (creditType === PaymentType.lightning) {
+ // Lightning FREE TIER: a lightning (incl. bolt12) receipt accrues a fee
+ // credit — so a normal deposit+withdraw is free — UNLESS this account is
+ // flagged as a high-throughput pass-through (using us to rebalance channels
+ // for free). ln-freetier-monitor.ts sets `ln:nofree:<uid>` from a 30-day
+ // round-trip analysis. It's behavioral + per-account, so it can't be dodged
+ // by splitting across sockpuppets: every puppet that rebalances gets flagged.
+ const noFree = await g(`ln:nofree:${uid}`);
+ if (!noFree)
+ m.incrBy(
+ `credit:lightning:${uid}`,
+ Math.round(amount * config.fee.lightning),
+ );
+ }
m.set(`invoice:${inv.id}`, JSON.stringify(inv))
.set(`payment:${p.id}`, JSON.stringify(p))
@@ -449,7 +499,7 @@ export const completePayment = async (inv, p, user) => {
}
} catch (e) {
withdrawal = { failed: true };
- warn(username, "autowithdraw failed", e.message);
+ warn(username, "autowithdraw failed", shortError(e.message));
}
}
}
@@ -539,18 +589,28 @@ export const sendOnchain = async (params) => {
const node =
aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid });
let { txid } = tx;
+ let locked = false;
try {
if (inflight[txid]) fail("payment in flight");
inflight[txid] = true;
- // Reserve UTXOs to keep concurrent sends from selecting the same inputs.
- // Released in the catch block on failure; spent UTXOs make the lock moot on success.
- try {
- const lockVin = tx.vin.map(({ txid, vout }) => ({ txid, vout }));
- if (lockVin.length) await node.lockUnspent(false, lockVin);
- } catch (e: any) {
- warn("lockUnspent failed", e.message);
+ // Reserve the exact inputs this tx spends. If locking fails, bitcoind is
+ // telling us an input is no longer unspent ("expected unspent output") —
+ // another send already committed it in the gap between build() (coin
+ // selection) and here. Broadcasting anyway would double-spend, and the
+ // higher-fee tx wins, silently losing the loser's funds (the replaced-tx
+ // incident). So abort hard rather than warn-and-continue; the debit hasn't
+ // happened yet and build() will pick fresh inputs on retry.
+ const lockVin = tx.vin.map(({ txid, vout }) => ({ txid, vout }));
+ if (lockVin.length) {
+ try {
+ await node.lockUnspent(false, lockVin);
+ locked = true;
+ } catch (e: any) {
+ warn("lockUnspent failed, aborting send", e.message);
+ fail("Selected inputs are no longer available, please retry");
+ }
}
if (!signed) {
@@ -635,11 +695,14 @@ export const sendOnchain = async (params) => {
return p;
} catch (e) {
delete inflight[txid];
- // Release UTXOs that build() locked, so the user can retry without abandoning coins.
- try {
- const vin = tx?.vin?.map(({ txid, vout }) => ({ txid, vout })) ?? [];
- if (vin.length) await node.lockUnspent(true, vin);
- } catch {}
+ // Release UTXOs only if WE locked them, so we don't accidentally free
+ // another concurrent send's reservation on a shared input.
+ if (locked) {
+ try {
+ const vin = tx?.vin?.map(({ txid, vout }) => ({ txid, vout })) ?? [];
+ if (vin.length) await node.lockUnspent(true, vin);
+ } catch {}
+ }
throw e;
}
};
@@ -685,7 +748,7 @@ export const sendKeysend = async ({
l("sendKeysend outcome", p.id, "=", outcome);
return r;
} catch (e: any) {
- err("failed keysend", hash?.slice(0,16), "error:", e?.message);
+ err("failed keysend", hash?.slice(0,16), "error:", shortError(e?.message));
try {
const { pays } = await ln.listpays({ payment_hash: hash });
warn("listpays after keysend-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? []));
@@ -854,7 +917,7 @@ export const sendLightning = async ({
}
}
} catch (e: any) {
- err("failed to pay", pr.substr(-8), "xpay error:", e?.message);
+ err("failed to pay", pr.substr(-8), "xpay error:", shortError(e?.message));
try {
const { pays } = await ln.listpays(pr);
warn("listpays after xpay-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? []));
@@ -1191,6 +1254,8 @@ const finalize = async (r, p) => {
}
p.fee = Number.isFinite(computedFee) ? computedFee : maxfee;
p.ref = preimage;
+ // The HTLC settled (we have the preimage) — the send is now fully confirmed.
+ p.confirmed = true;
const current = await g(`payment:${p.id}`);
// The record is gone because a concurrent reverse() already refunded this
@@ -1220,7 +1285,13 @@ const reverse = async (p) => {
const total = Math.abs(p.amount) + p.fee + p.ourfee;
const ourfee = p.ourfee || 0;
- const credit = Math.round(total * config.fee[PaymentType.lightning]) - ourfee;
+ // Restore the free-tier credit consumed at debit time. Use the SAME rate the
+ // send was charged at — flagged `ln:nofree` accounts were charged the elevated
+ // rate, so reversing with the standard rate would push their credit negative.
+ const rate = (await g(`ln:nofree:${p.uid}`))
+ ? config.fee.lightningHigh
+ : config.fee.lightning;
+ const credit = Math.round(total * rate) - ourfee;
l("reversing", p.id, p.amount, p.fee, total, ourfee, credit);
@@ -1240,6 +1311,12 @@ const reverse = async (p) => {
};
const freezeCheck = async () => {
+ // Crash-resilient loop: ANY error in the body (an unhandled rejection in the
+ // limit-write section, a stuck withLimitLock, etc.) used to escape here and
+ // kill the loop silently (the global unhandledRejection handler is a no-op),
+ // freezing every ${type}:limit until someone noticed sends failing. Wrap the
+ // whole body so errors are logged and the next tick is ALWAYS scheduled.
+ try {
// Each asset is fetched independently so e.g. lq being unreachable doesn't
// prevent lightning and bitcoin limits from refreshing.
let lnbalance: number | undefined;
@@ -1294,7 +1371,10 @@ const freezeCheck = async () => {
await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0));
});
}
-
- setTimeout(freezeCheck, 10000);
+ } catch (e: any) {
+ warn("freezeCheck: iteration failed, will retry next tick:", e?.message ?? String(e));
+ } finally {
+ setTimeout(freezeCheck, 10000);
+ }
};
freezeCheck();