aboutsummaryrefslogtreecommitdiffstats
path: root/lib/payments.ts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/payments.ts')
-rw-r--r--lib/payments.ts1108
1 files changed, 923 insertions, 185 deletions
diff --git a/lib/payments.ts b/lib/payments.ts
index cbff6dd3..20025cd2 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -1,11 +1,30 @@
import config from "$config";
+import { existsSync } from "fs";
import api from "$lib/api";
-import { db, g, ga, s } from "$lib/db";
+import { requireAccount } from "$lib/auth";
+import { archive, db, g, ga, gf, s, sa } from "$lib/db";
+import { assertWithinSpendLimit } from "$lib/spend-budget";
+
+// External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a
+// human) can `touch /home/adam/locks/<type>.locked` on the host, which is
+// bind-mounted into this container at /locks. Existence of /locks/<type>.locked
+// (or /locks/ALL.locked) blocks all external sends of that type. Lives outside
+// db so it can't be disabled by a db-write vector (May 18 /gateway lesson).
+//
+// Internal payments are never blocked by these — receives and internal
+// transfers between coinos users keep working with the site fully online.
+const isWithdrawLocked = (type: string): string | null => {
+ if (existsSync("/locks/ALL.locked")) return "ALL";
+ if (existsSync(`/locks/${type}.locked`)) return type;
+ return null;
+};
import { generate } from "$lib/invoices";
import ln from "$lib/ln";
-import { err, l, warn } from "$lib/logging";
+import { quoteRoutingFee, xpayLayers } from "$lib/lnquote";
+import { err, l, quiet, shortError, suppressed, warn } from "$lib/logging";
import { handleZap } from "$lib/nostr";
import { notify, nwcNotify } from "$lib/notifications";
+import { squarePayment } from "$lib/square";
import {
SATS,
btc,
@@ -21,15 +40,65 @@ import {
t,
} from "$lib/utils";
import { callWebhook } from "$lib/webhooks";
+import { maybeForwardToV3, retryV3Forwards } from "$lib/v3forward";
+import changeid from "$lib/changeid";
import rpc from "@coinos/rpc";
import { bech32 } from "bech32";
import got from "got";
import { v4 } from "uuid";
+
import { PaymentType } from "$lib/types";
const bc = rpc(config.bitcoin);
const lq = rpc(config.liquid);
+
+// Fire-and-forget: push a freshly-broadcast tx straight to public explorers so
+// they see it immediately instead of waiting for it to propagate to their node
+// over the p2p network (measured ~8s otherwise). Never throws; never blocks.
+const broadcastToExplorers = (hex: string) => {
+ const net = config.bitcoin?.network;
+ if (net === "regtest" || net === "testnet") return; // public explorers are mainnet
+ for (const base of ["https://mempool.space/api", "https://blockstream.info/api"]) {
+ fetch(`${base}/tx`, {
+ method: "POST",
+ body: hex,
+ headers: { "Content-Type": "text/plain" },
+ }).catch(() => {});
+ }
+};
+
+// Throttled warn — emit at most once per WARN_THROTTLE_MS per (key, message) pair
+// so a downed external service (lq, bc, etc.) doesn't flood the log every loop tick.
+const WARN_THROTTLE_MS = 5 * 60 * 1000;
+const warnLastEmitted: Record<string, number> = {};
+const warnThrottled = (key: string, message: string) => {
+ const k = `${key}|${message}`;
+ const now = Date.now();
+ if ((warnLastEmitted[k] || 0) + WARN_THROTTLE_MS > now) return;
+ warnLastEmitted[k] = now;
+ warn(`${key}:`, message);
+};
+
+// Per-asset-type async mutex. The May 18 2026 drain exploited the fact that
+// the limit check + payment broadcast were non-atomic: a burst of concurrent
+// withdrawals would each pass the same stale ${type}:limit value before any
+// of them decremented it. This lock serializes the check + reservation per
+// asset type. freezeCheck also takes the lock when overwriting limits so its
+// periodic reconciliation can't race with an in-flight check.
+const limitLocks: Record<string, Promise<void>> = {};
+async function withLimitLock<T>(type: string, fn: () => Promise<T>): Promise<T> {
+ const prev = limitLocks[type] || Promise.resolve();
+ let release: () => void = () => {};
+ const next = new Promise<void>((res) => { release = res; });
+ limitLocks[type] = prev.then(() => next);
+ await prev;
+ try {
+ return await fn();
+ } finally {
+ release();
+ }
+}
const { URL } = process.env;
const dust = 547;
@@ -43,42 +112,87 @@ export const debit = async ({
user,
type = PaymentType.internal,
rate = undefined,
+ // a v3 bridge forward (lib/v3forward.ts) costs routing only
+ exempt = false,
+ maxTotal = undefined,
}) => {
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
const whitelisted = await db.sIsMember(
"whitelist",
user?.username?.toLowerCase().trim(),
);
- const blacklisted = await db.sIsMember(
- "blacklist",
- user?.username?.toLowerCase().trim(),
- );
+ // Blacklist (freeze) matches on EITHER the current username OR the uid. The
+ // uid never changes, so renaming the account can't shake the freeze — add the
+ // uid to the `blacklist` set to freeze a compromised account durably.
+ const blacklisted =
+ (await db.sIsMember("blacklist", user?.username?.toLowerCase().trim())) ||
+ (!!user?.id && (await db.sIsMember("blacklist", user.id)));
+
+ if (hash && await db.sIsMember("blocked_addresses", hash)) {
+ err(`SECURITY: blocked send to ${hash} by ${user.username}`);
+ await changeid(user.username);
+ fail("address blocked");
+ }
- const serverLimit = await g(`${type}:limit`);
const userLimit = await g("limit");
const frozen =
(await g("hardfreeze")) ||
((await g("freeze")) && type !== PaymentType.internal);
- if (frozen || (amount > userLimit && !whitelisted) || amount > serverLimit) {
- warn(
- "Blocking",
- user.username,
- amount,
- hash,
- user.id,
- type,
- frozen,
- userLimit,
- serverLimit,
- );
+ // Out-of-band withdrawal lock (file-based, can't be defeated by db writes).
+ // Internal sends are exempt — they don't touch external wallets.
+ if (type !== PaymentType.internal) {
+ const lockedKind = isWithdrawLocked(type);
+ if (lockedKind) {
+ const n = quiet(`block:${user.id}:lock`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind, suppressed(n));
+ fail("External withdrawals temporarily disabled");
+ }
+ }
+
+ if (frozen || (amount > userLimit && !whitelisted)) {
+ // Per-account throttle: a frozen throwaway retrying sends every few
+ // seconds is expected noise, one line per 5 min keeps the tripwire.
+ const n = quiet(`block:${user.id}:frozen`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit, suppressed(n));
fail("Problem sending payment");
}
+ // Atomic check + reserve against the per-asset-type server limit. Decrement
+ // immediately so concurrent calls can't reuse the same budget; freezeCheck
+ // reconciles to actual on-chain balance every 10s.
+ //
+ // Internal sends are exempt: they're pure ledger moves between coinos users
+ // and never touch an external wallet, so there's no hot-wallet liquidity to
+ // reserve against. freezeCheck only ever populates limits for external types
+ // (lightning/fund/ecash/bolt12/bitcoin/liquid), so `internal:limit` stays 0
+ // forever — applying the check here blocks every internal payment.
+ if (type !== PaymentType.internal) {
+ await withLimitLock(type, async () => {
+ const serverLimit = Number.parseInt((await g(`${type}:limit`)) ?? "0", 10) || 0;
+ if (amount > serverLimit) {
+ const n = quiet(`block:${user.id}:server`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("Blocking", user.username, amount, hash, user.id, type, "serverLimit", serverLimit, suppressed(n));
+ // Say what's happening (users read the generic error as "account
+ // frozen") without revealing the limit or the hot-wallet balance.
+ fail(
+ type === PaymentType.bitcoin || type === PaymentType.liquid
+ ? "On-chain withdrawals are temporarily limited. Please try a smaller amount, send over Lightning, or try again later."
+ : "Payments are temporarily limited. Please try a smaller amount or try again later.",
+ );
+ }
+ await db.decrBy(`${type}:limit`, amount);
+ });
+ }
+
let ref;
const { id: uid, currency } = user;
+ if (!aid) aid = uid;
const rates = await g("rates");
if (!rate) rate = rates[currency];
@@ -103,27 +217,56 @@ export const debit = async ({
}
}
- const tip = parseInt(invoice?.tip) || null;
+ // Capture the tip ONCE, and only for INTERNAL payments — where credit() pays
+ // the invoice owner the same tip (sendInternal passes p.tip through). External
+ // sends (bitcoin/liquid/lightning/bolt12/fund) must NOT inherit a tip: the
+ // destination `hash` can coincidentally match another user's deposit invoice,
+ // which would otherwise pull that stranger's tip into this debit's balance
+ // check and stored record (the LiquidNode negative-balance vector). This one
+ // captured value feeds the ourfee calc, the atomic debit, and the record, so
+ // the record can never disagree with what was actually debited.
+ const tip =
+ type === PaymentType.internal ? Number.parseInt(invoice?.tip) || null : null;
if (tip < 0) fail("Invalid tip");
if (!amount || amount < 0) fail("Amount must be greater than zero");
let creditType = type;
if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning;
+
+ // High-throughput pass-through accounts (flagged `ln:nofree` by
+ // ln-freetier-monitor.ts) pay an elevated platform fee on lightning
+ // withdrawals; everyone else pays the standard rate. Flagged accounts already
+ // accrue no free-tier credit, so this rate is what they actually pay.
+ let feeRate = config.fee[creditType];
+ if (
+ creditType === PaymentType.lightning &&
+ aid === uid &&
+ (await g(`ln:nofree:${uid}`))
+ )
+ feeRate = config.fee.lightningHigh;
+
let ourfee: any = [
PaymentType.bitcoin,
PaymentType.liquid,
PaymentType.lightning,
].includes(type)
- ? Math.round((amount + fee + tip) * config.fee[creditType])
+ ? Math.round((amount + fee + tip) * feeRate)
: 0;
- if (aid && aid !== uid) ourfee = 0;
+ if (aid !== uid || exempt) ourfee = 0;
+
+ // NWC and other delegated spenders can provide the exact allowance remaining
+ // for this call. Enforce it only after debit has reloaded the invoice and
+ // captured its tip, so a recipient cannot pass a small-invoice budget check,
+ // mutate the tip, and drain the account through the internal-payment path.
+ assertWithinSpendLimit({ amount, tip, fee, ourfee, maxTotal });
+
const frozenBalance =
- !blacklisted || whitelisted ? 0 : await ga(`balance:${uid}`);
+ !blacklisted || whitelisted ? 0 : await g(`balance:${uid}`);
ourfee = await db.debit(
- `balance:${aid || uid}`,
+ `balance:${aid}`,
`credit:${creditType}:${aid !== uid ? 0 : uid}`,
t(user).insufficientFunds,
amount || 0,
@@ -135,6 +278,17 @@ export const debit = async ({
if (ourfee.err) fail(ourfee.err);
+ // Defense-in-depth: the DEBIT lua rejects overdrafts, so a negative balance
+ // here means the invariant was violated by a mismatched input or a non-lua
+ // balance write. Log loudly with full context so the exact vector is greppable
+ // (SECURITY: negative balance) instead of surfacing days later as a stuck
+ // negative account.
+ const postBal = Number.parseInt((await db.get(`balance:${aid}`)) || "0");
+ if (postBal < 0)
+ err(
+ `SECURITY: negative balance ${postBal} after debit user=${user?.username} aid=${aid} amount=${amount} tip=${tip} fee=${fee} ourfee=${ourfee} type=${type} hash=${hash}`,
+ );
+
const id = v4();
const p = {
id,
@@ -147,7 +301,13 @@ export const debit = async ({
memo,
iid,
uid,
- confirmed: true,
+ // Lightning/bolt12 sends are IN-FLIGHT until the HTLC settles (preimage
+ // revealed). Don't mark them confirmed on the optimistic debit — finalize()
+ // flips this to true once the preimage arrives. Hold invoices (e.g. Ark)
+ // can keep an HTLC pending for hours/days; showing "confirmed" while the
+ // recipient hasn't been paid is misleading. Other send types settle
+ // immediately (internal) or are broadcast right away (bitcoin/liquid/fund).
+ confirmed: ![PaymentType.lightning, PaymentType.bolt12].includes(type),
rate,
currency,
type,
@@ -166,9 +326,7 @@ export const debit = async ({
.exec();
l(user.username, "sent", type, amount);
- //emit(user.id, "payment", p);
- if (![PaymentType.lightning, PaymentType.bolt12].includes(type))
- nwcNotify(p, user);
+ if (![PaymentType.lightning, PaymentType.bolt12].includes(type)) nwcNotify(p);
return p;
};
@@ -181,16 +339,23 @@ export const credit = async ({
type = PaymentType.internal,
aid = undefined,
payment_hash = undefined,
+ created = undefined,
+ tip = undefined,
}) => {
- amount = parseInt(amount) || 0;
+ amount = Number.parseInt(amount) || 0;
- let inv;
- if (type === PaymentType.bolt12) {
+ // Kill switch (see routes/payments.ts confirm): no Liquid credits at all
+ // while `liquid:deposits:disabled` is set.
+ if (type === PaymentType.liquid && (await g("liquid:deposits:disabled"))) {
+ warn("liquid credit blocked (disabled)", hash, amount, ref);
+ return;
+ }
+
+ let inv = await getInvoice(hash);
+ if (!inv && type === PaymentType.bolt12) {
const { invoices } = await ln.listinvoices({ invstring: hash });
const { local_offer_id } = invoices[0];
inv = await getInvoice(local_offer_id);
- } else {
- inv = await getInvoice(hash);
}
if (!inv) {
@@ -198,15 +363,64 @@ export const credit = async ({
return;
}
- let { path, tip } = inv;
- tip = parseInt(tip) || 0;
+ // usePreimage invoices keep their merchant-supplied preimage at
+ // preimage:${paymentHash} (see generate()). Prefer it as the settlement
+ // ref: external settlements already carry the same value, but internal
+ // ones pass ref=sender.id, which would otherwise be surfaced as the
+ // payment preimage and lose the secret the buyer paid for. Only once the
+ // invoice is fully paid, though — internal sends can settle partial
+ // amounts, and an underpayment must not reveal the secret.
+ if (
+ inv.paymentHash &&
+ ![PaymentType.bitcoin, PaymentType.liquid].includes(type) &&
+ inv.received + amount >= inv.amount
+ ) {
+ const stored = await db.get(`preimage:${inv.paymentHash}`);
+ if (stored) ref = stored;
+ }
+
+ let { path } = inv;
+ // Use the tip the sender was actually debited (passed in for internal
+ // transfers) rather than re-reading the invoice. The invoice's tip can be
+ // mutated between the sender debit and this credit (PUT /invoice), which
+ // otherwise lets a payer credit a tip they were never charged — minting
+ // balance. Fall back to the invoice tip only for external receipts
+ // (lightning/ecash) where there is no paired coinos debit to diverge from.
+ tip = tip !== undefined ? Number.parseInt(tip) || 0 : Number.parseInt(inv.tip) || 0;
if (!memo) ({ memo } = inv);
- if (memo && memo.length > 5000) fail("memo too long");
+ // Truncate rather than throw: credit() runs AFTER the payment has settled (an
+ // incoming lightning/bolt12 deposit already advanced pay_index; an internal
+ // send already debited the sender). Throwing here left the sats in the house
+ // wallet with no retry path — the payer saw a completed payment and the
+ // recipient was never credited. An oversized memo (attacker-controllable via
+ // the lnurl comment or a bolt12 payer_note) must never destroy a settled
+ // payment; a clipped note is harmless.
+ if (memo && memo.length > 5000) memo = memo.slice(0, 5000);
if (amount < 0 || tip < 0) fail("Invalid amount");
+ // For a NON-internal receipt the tip is only a cosmetic split of the real
+ // settled `amount` — there is no paired coinos debit carrying it — so it can
+ // never legitimately exceed `amount`. If the invoice tip was mutated via
+ // PUT /invoice to a value larger than the amount actually received, the
+ // fallback above would pull in that inflated tip and the stored record's
+ // `amount - tip` would go negative: a corrupt record that could be reversed
+ // into balance. The balance credit itself is `amount` and is unaffected, but
+ // clamp the tip so the record can never be negative. Internal transfers keep
+ // the full debited tip (their record stores the base amount, non-negative).
+ if (type !== PaymentType.internal && tip > amount) tip = amount;
if (type === PaymentType.internal) amount += tip;
const user = await getUser(inv.uid);
+ if (!user) {
+ // The account is gone (deleted) but its invoice still receives — e.g. a
+ // bolt12 offer a mining pool keeps paying. Record the settlement in
+ // `missing` and skip, like an unknown invoice. Throwing here wedged the
+ // lightning listener on one settlement and blocked every later receipt
+ // for two hours (2026-09-05, pay_index 1068006).
+ warn("received payment for missing user", inv.uid, hash, amount);
+ await db.sAdd("missing", ref.split(":")[0]);
+ return;
+ }
const { id: uid, currency } = user;
const rates = await g("rates");
@@ -240,7 +454,7 @@ export const credit = async ({
tip,
type,
confirmed: true,
- created: Date.now(),
+ created: created || Date.now(),
items: undefined,
};
@@ -258,31 +472,66 @@ export const credit = async ({
p.confirmed = false;
balanceKey = "pending";
await s(`payment:${txid}:${vout}`, id);
+ // Mirror the txid:vout pointer into arc so future bulk /confirm sweeps find
+ // the prior credit via gf() fallback and don't double-credit. See
+ // feedback_apr29_double_credit_incident.md for the incident this prevents.
+ await sa(`payment:${txid}:${vout}`, id);
} else {
await s(`payment:${hash}`, id);
+ await sa(`payment:${hash}`, id);
+ }
+
+ // Idempotency guard for external settlements. The getPayment() pre-check in
+ // the lightning listener/replay is not atomic: N concurrent re-credits of the
+ // same payment all read it as uncredited before any writes the pointer, so
+ // each runs the incrBy below — the /replay double-credit race. SET NX is
+ // atomic, so only the first caller claims the settlement and credits; the rest
+ // bail here, before the incrBy. `ref` is the lightning preimage, unique per
+ // settlement; internal/fund/ecash pass ref=uid and are exempt.
+ if ([PaymentType.lightning, PaymentType.bolt12].includes(type) && ref) {
+ const claimed = await db.set(`credited:${ref}`, id, { NX: true });
+ if (!claimed) {
+ warn("duplicate credit blocked", hash, ref);
+ return;
+ }
}
const m = await db.multi();
let creditType = type;
if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning;
- if (
- [PaymentType.bitcoin, PaymentType.liquid, PaymentType.lightning].includes(
- creditType,
- )
- )
+ // Bitcoin/liquid always accrue the same-network fee credit.
+ if ([PaymentType.bitcoin, PaymentType.liquid].includes(creditType)) {
m.incrBy(
`credit:${creditType}:${uid}`,
Math.round(amount * config.fee[creditType]),
);
+ } else if (creditType === PaymentType.lightning) {
+ // Lightning FREE TIER: a lightning (incl. bolt12) receipt accrues a fee
+ // credit — so a normal deposit+withdraw is free — UNLESS this account is
+ // flagged as a high-throughput pass-through (using us to rebalance channels
+ // for free). ln-freetier-monitor.ts sets `ln:nofree:<uid>` from a 30-day
+ // round-trip analysis. It's behavioral + per-account, so it can't be dodged
+ // by splitting across sockpuppets: every puppet that rebalances gets flagged.
+ const noFree = await g(`ln:nofree:${uid}`);
+ if (!noFree)
+ m.incrBy(
+ `credit:lightning:${uid}`,
+ Math.round(amount * config.fee.lightning),
+ );
+ }
- m.set(`invoice:${inv.id}`, JSON.stringify(inv))
+ await m.set(`invoice:${inv.id}`, JSON.stringify(inv))
.set(`payment:${p.id}`, JSON.stringify(p))
.lPush(`${aid || uid}:payments`, p.id)
.incrBy(`${balanceKey}:${aid || uid}`, amount)
.set(`${aid || uid}:payments:last`, p.created)
.exec();
+ // Mirror the payment record + invoice into arc for the same protection.
+ await sa(`payment:${p.id}`, p);
+ await sa(`invoice:${inv.id}`, inv);
+
if (inv.items?.length) {
formatReceipt(inv.items, inv.currency);
p.items = inv.items;
@@ -293,13 +542,19 @@ export const credit = async ({
return p;
};
+const forwardSend = (o) => sendLightning({ ...o, exempt: true });
+// failed forwards retry with backoff; one process works the queue at a time
+setInterval(() => retryV3Forwards(forwardSend).catch(() => {}), 60_000).unref();
+
export const completePayment = async (inv, p, user) => {
const { id, autowithdraw, threshold, reserve, destination, username } = user;
let withdrawal;
- if (p.confirmed) {
+ // A name that moved to coinos v3: the receive is paid on to the v3 wallet
+ // (lib/v3forward.ts), and legacy autowithdraw stands down for it.
+ const forwarded = await maybeForwardToV3(p, user, forwardSend);
+ if (p.confirmed && !forwarded) {
if (autowithdraw) {
try {
- fail("autowithdrawals disabled");
const to = destination.trim();
const balance = await g(`balance:${id}`);
const amount = balance - reserve;
@@ -312,22 +567,32 @@ export const completePayment = async (inv, p, user) => {
};
}
} catch (e) {
- console.log(e);
withdrawal = { failed: true };
- warn(username, "autowithdraw failed", e.message);
+ warn(username, "autowithdraw failed", shortError(e.message));
}
}
}
nwcNotify(p);
notify(p, user, withdrawal);
- l(username, "received", p.type, p.amount);
+
+ squarePayment(p, user);
+
+ // completePayment runs at two lifecycle points for on-chain (bitcoin/liquid)
+ // deposits — once when the tx is first seen (pending) and again from /confirm
+ // when it confirms — plus once for instant lightning receives. Logging a bare
+ // "received" at each made a single on-chain deposit look like two receives in
+ // the logs (it never double-credited — the /confirm `p.confirmed` guard and
+ // the `seen` set prevent that; it was only noisy/misleading). Distinguish the
+ // states so the log reads as one deposit moving pending -> confirmed.
+ const stage = p.confirmed ? "received" : "receiving (pending)";
+ l(username, stage, p.type, p.amount);
callWebhook(inv, p);
};
const pay = async ({ aid = undefined, amount, to, user }) => {
if (!aid) aid = user.id;
- amount = parseInt(amount) || 0;
+ amount = Number.parseInt(amount) || 0;
let lnurl;
let pr;
if (to.includes("@") && to.includes(".")) {
@@ -341,14 +606,23 @@ const pay = async ({ aid = undefined, amount, to, user }) => {
}
const recipient = await getUser(to);
- if (recipient)
+ if (recipient) {
+ // An autowithdraw destination that resolves back to the sender is a
+ // self-send: the internal credit re-fires completePayment, which re-runs
+ // autowithdraw, and since the balance nets to the same value it never
+ // falls below threshold — an unbounded loop. Reject it here (the route's
+ // create() has the equivalent `Cannot send to self` guard; pay() lacked
+ // it, which is what let a self-pointed autowithdraw spin forever).
+ if (recipient.id === aid)
+ fail("Cannot autowithdraw to your own account");
return sendInternal({
amount,
recipient,
sender: user,
});
+ }
- const fee = Math.max(5, Math.round(amount * 0.005));
+ const fee = Math.max(5, Math.round(amount * 0.02));
if (lnurl) {
amount -= fee;
const { callback } = (await got(lnurl).json()) as any;
@@ -385,18 +659,58 @@ export const decode = async (hex) => {
const inflight = {};
export const sendOnchain = async (params) => {
let { aid, hex, rate, user, signed } = params;
- if (!aid) aid = user.id;
+ aid = await requireAccount({ aid, user });
+ const supplied = !!hex;
if (!hex) ({ hex } = await build(params));
const { tx, type } = await decode(hex);
+
const node =
aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid });
let { txid } = tx;
+ let locked = false;
+
+ // Only sign a transaction WE composed for this account. build() leaves a
+ // short-lived receipt keyed by txid; caller-supplied hex must match one.
+ // Otherwise the hot wallet signs whatever raw tx is posted to /bitcoin/send
+ // and the only thing between an arbitrary tx and the chain is the debit math
+ // below (plus the weSpent credit-side guard in /confirm) — this is the outer
+ // layer, so an unrecognized tx never reaches signRawTransactionWithWallet
+ // and never locks our UTXOs on the way to failing.
+ //
+ // Receipts survive client-side signing: build() returns an unsigned segwit
+ // tx and witness data doesn't change the txid, so the self-custody path
+ // (which posts back hex it finalized itself) matches the same receipt. They
+ // are not single-use — a replay of the identical tx is already stopped by
+ // inflight/lockUnspent/testMempoolAccept, and burning the receipt would
+ // break a legitimate retry after a transient failure.
+ if (supplied) {
+ const builder = await db.get(`build:${txid}`);
+ if (builder !== aid) fail("unrecognized tx");
+ }
try {
if (inflight[txid]) fail("payment in flight");
inflight[txid] = true;
+ // Reserve the exact inputs this tx spends. If locking fails, bitcoind is
+ // telling us an input is no longer unspent ("expected unspent output") —
+ // another send already committed it in the gap between build() (coin
+ // selection) and here. Broadcasting anyway would double-spend, and the
+ // higher-fee tx wins, silently losing the loser's funds (the replaced-tx
+ // incident). So abort hard rather than warn-and-continue; the debit hasn't
+ // happened yet and build() will pick fresh inputs on retry.
+ const lockVin = tx.vin.map(({ txid, vout }) => ({ txid, vout }));
+ if (lockVin.length) {
+ try {
+ await node.lockUnspent(false, lockVin);
+ locked = true;
+ } catch (e: any) {
+ warn("lockUnspent failed, aborting send", e.message);
+ fail("Selected inputs are no longer available, please retry");
+ }
+ }
+
if (!signed) {
if (config[type].walletpass)
await node.walletPassphrase(config[type].walletpass, 300);
@@ -411,6 +725,25 @@ export const sendOnchain = async (params) => {
const r = await node.testMempoolAccept([hex]);
if (!r[0].allowed) fail("transaction rejected");
+ // Known drain destinations. debit() checks `blocked_addresses` against
+ // its hash, which for an on-chain send is the txid, so the address itself
+ // was never matched here. Refuse before the debit: a drain operator can
+ // fund fresh throwaway accounts and sweep each one on-chain to the same
+ // address, so freezing accounts is a treadmill while the destination is
+ // constant.
+ for (const {
+ scriptPubKey: { address },
+ } of tx.vout) {
+ if (address && (await db.sIsMember("blocked_addresses", address))) {
+ const n = quiet(`blockedaddr:${user.id}`, 5 * 60 * 1000);
+ if (n !== null)
+ err(
+ `SECURITY: blocked on-chain send to ${address} by ${user.username} ${suppressed(n)}`,
+ );
+ fail("address blocked");
+ }
+ }
+
let total = 0;
let fee = 0;
let change = 0;
@@ -473,11 +806,20 @@ export const sendOnchain = async (params) => {
await s(`payment:${p.id}`, p);
await node.sendRawTransaction(hex);
+ broadcastToExplorers(hex);
delete inflight[txid];
return p;
} catch (e) {
delete inflight[txid];
+ // Release UTXOs only if WE locked them, so we don't accidentally free
+ // another concurrent send's reservation on a shared input.
+ if (locked) {
+ try {
+ const vin = tx?.vin?.map(({ txid, vout }) => ({ txid, vout })) ?? [];
+ if (vin.length) await node.lockUnspent(true, vin);
+ } catch {}
+ }
throw e;
}
};
@@ -490,10 +832,11 @@ export const sendKeysend = async ({
memo = undefined,
user,
extratlvs = undefined,
+ maxTotal = undefined,
}) => {
- fee = Math.max(parseInt(fee || amount * 0.005), 5);
+ fee = Math.max(Number.parseInt(fee || amount * 0.005), 5);
- let p = await g(`payment:${hash}`);
+ let p = await gf(`payment:${hash}`);
if (p) fail("duplicate keysend");
p = await debit({
@@ -503,19 +846,111 @@ export const sendKeysend = async ({
memo,
user,
type: PaymentType.lightning,
+ maxTotal,
});
- const r = await ln.keysend({
- destination: pubkey,
- amount_msat: amount * 1000,
- maxfee: fee * 1000,
- retry_for: 10,
- extratlvs,
- });
+ let outcome = "unknown";
- if (r.status !== "complete") reverse(p);
+ // COINOS-1: keysend randomizes its payment hash (the CLN command takes no
+ // preimage arg) and a throw yields no result object, so the caller-supplied
+ // `hash` is NOT the payment's real hash — verifying with it is what made the
+ // old code reverse on every throw. CLN keysend errors carry no payment_hash
+ // either (verified on v26.06.2: the error is {code, message, attempts}).
+ //
+ // Instead, tag the payment with our own unique `label` and note where the
+ // sendpays index stands beforehand. `wait sendpays created 0` returns the
+ // current index immediately, so on a throw we can list only sendpays created
+ // since — no full scan — and find ours by label regardless of error shape.
+ // `wait` isn't in the rpc client's generated method list, so go through the
+ // generic call(). nextvalue 0 never blocks — it returns the current index.
+ let startIndex: number | undefined;
+ try {
+ const w = await ln.call("wait", {
+ subsystem: "sendpays",
+ indexname: "created",
+ nextvalue: 0,
+ });
+ startIndex = w?.created;
+ } catch (e: any) {
+ warnThrottled("keysend: wait sendpays unavailable", e?.message ?? String(e));
+ }
- return r;
+ try {
+ const r = await ln.keysend({
+ destination: pubkey,
+ amount_msat: amount * 1000,
+ maxfee: fee * 1000,
+ retry_for: 10,
+ label: hash,
+ extratlvs,
+ });
+ warn("keysend returned", p.id, JSON.stringify({
+ status: r?.status,
+ has_preimage: !!(r?.payment_preimage ?? r?.preimage),
+ amount_sent_msat: r?.amount_sent_msat,
+ }));
+ // keysend blocks on retry_for until it settles or fails, so a successful
+ // return carries the preimage. Finalize HERE — flip the record
+ // pending->confirmed and refund the unused fee reserve — so every caller is
+ // covered. The NWC pay_keysend caller only polls listpays for the preimage
+ // to hand back to the client and never confirmed the record, which left
+ // every successful zap stuck "pending" forever (and check() can't rescue a
+ // keysend: its label isn't a bolt11, so the reconciler evicts it). Guard on
+ // complete+preimage; anything else stays pending for the caller/retry path.
+ const settledPreimage = r?.payment_preimage ?? r?.preimage;
+ if (r?.status === "complete" && settledPreimage) {
+ try {
+ await finalizeKeysend(r, p, settledPreimage);
+ outcome = "finalized-keysend";
+ } catch (e: any) {
+ warn("finalizeKeysend threw", p.id, e?.message);
+ outcome = "keysend-returned (finalize threw)";
+ }
+ } else {
+ outcome = "keysend-returned"; // not settled yet — caller polls for preimage
+ }
+ l("sendKeysend outcome", p.id, "=", outcome);
+ return r;
+ } catch (e: any) {
+ err("failed keysend", hash?.slice(0,16), "error:", shortError(e?.message));
+ try {
+ if (startIndex === undefined) {
+ // Couldn't establish the index baseline, so we can't bound the search.
+ // Never refund a payment that may have settled — leave the debit.
+ warn("keysend threw with no sendpays baseline — NOT reversing (manual review)", p.id, pubkey);
+ outcome = "unverified-after-keysend-throw (not reversed)";
+ } else {
+ const { payments = [] } = await ln.listsendpays({
+ index: "created",
+ start: startIndex,
+ });
+ const ours = payments.filter((x: any) => x.label === hash);
+ warn("sendpays after keysend-threw", p.id, JSON.stringify(ours.map((x: any) => ({ status: x.status }))));
+
+ if (ours.some((x: any) => x.status === "complete")) {
+ // Settled despite the throw — reversing here is the drain.
+ outcome = "keysend-completed-despite-throw";
+ } else if (!ours.length) {
+ // CLN records a sendpay BEFORE it sends the HTLC, so no record at all
+ // means nothing left the node (the common no-route/no-path failure).
+ // Safe — and necessary — to refund.
+ await reverse(p);
+ outcome = "reversed-after-keysend-throw (no htlc sent)";
+ } else if (ours.every((x: any) => x.status === "failed")) {
+ await reverse(p);
+ outcome = "reversed-after-keysend-throw (confirmed failed)";
+ } else {
+ // Still in flight: CLN may yet settle it, so leave the debit.
+ outcome = "pending-after-keysend-throw";
+ }
+ }
+ } catch (verifyErr: any) {
+ warnThrottled("sendpays verification failed (keysend)", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-keysend-throw";
+ }
+ warn("sendKeysend outcome", p.id, "=", outcome);
+ throw e;
+ }
};
export const sendLightning = async ({
@@ -524,30 +959,93 @@ export const sendLightning = async ({
amount,
fee = undefined,
memo = undefined,
+ payerNote = undefined,
+ maxTotal = undefined,
+ exempt = false,
}) => {
let p;
+ // BOLT11 is case-insensitive, but downstream code is not: the stored payment
+ // hash, the `pending` reconciler entry, and check()'s startsWith("ln") test
+ // all assume lowercase. An uppercase-pasted invoice gets stored uppercase,
+ // then check() treats its pending entry as non-lightning and evicts it
+ // without reversing — a failed send that never gets auto-refunded. Normalize
+ // once, up front, so every downstream use sees the same lowercase form.
+ pr = pr.replace(/\s/g, "").toLowerCase();
+
if (typeof amount !== "undefined") {
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
if (amount < 0 || amount > SATS || Number.isNaN(amount)) {
warn("invalid amount", amount);
fail("Invalid amount");
}
}
+ let decoded = await ln.decode(pr);
+
+ // A bolt12 offer (lno1...) isn't payable directly — fetch a real invoice
+ // from it over onion messages, then pay that. payerNote rides along in
+ // invreq_payer_note (NIP-177 bolt12 zaps put nostr:nip177:<intent-id> there).
+ if (decoded.type === "bolt12 offer") {
+ if (decoded.offer_currency)
+ fail("Currency-denominated offers are not supported");
+ if (!decoded.offer_amount_msat && !amount) fail("Amount required");
+
+ const { invoice } = await ln.fetchinvoice({
+ offer: pr,
+ amount_msat: decoded.offer_amount_msat ? undefined : amount * 1000,
+ payer_note: payerNote,
+ timeout: 60,
+ });
+
+ pr = invoice.replace(/\s/g, "").toLowerCase();
+ decoded = await ln.decode(pr);
+ }
+
let { type, invoice_amount_msat, amount_msat, invoice_node_id, payee } =
- await ln.decode(pr);
+ decoded;
if (type.includes("bolt12")) {
amount_msat = invoice_amount_msat;
payee = invoice_node_id;
}
- let minfee = 2;
- const { channels } = await ln.listpeerchannels();
- if (channels.some((c) => c.peer_id === payee)) minfee = 0;
+ const amt = amount_msat ? Math.round(amount_msat / 1000) : amount;
+
+ // Bots retrying sends against an empty account would otherwise cost an
+ // askrene query each. debit() below is still the authoritative check.
+ if (amt > (Number.parseInt(await g(`balance:${user.id}`)) || 0))
+ fail(t(user).insufficientFunds);
- fee = Math.max(parseInt(fee || 0), minfee);
- if (fee < 0) fail("Fee cannot be negative");
+ // Ask askrene up front what routing this will cost — the same query xpay
+ // runs — so the maxfee we reserve is the real fee, not a guess. A caller
+ // that names its own cap keeps it (finalize refunds whatever xpay doesn't
+ // spend), but a cap below the quoted fee is rejected here, before any
+ // debit, instead of failing inside xpay after one. If the quote itself
+ // fails (askrene unavailable, no route) fall back to the old heuristic and
+ // let xpay have a go.
+ let quoted: number | undefined;
+ try {
+ ({ fee: quoted } = await quoteRoutingFee({ pr, amount: amt }));
+ } catch (e: any) {
+ const n = quiet(`quotefail:${user.id}`, 5 * 60 * 1000);
+ if (n !== null)
+ warn("route quote failed", user.username, pr.substr(-8), shortError(e?.message), suppressed(n));
+ }
+
+ const explicit = Number.parseInt(fee);
+ if (Number.isFinite(explicit)) {
+ if (explicit < 0) fail("Fee cannot be negative");
+ if (quoted !== undefined && explicit < quoted)
+ fail(`Routing fee of ⚡️${quoted} required, max fee is ⚡️${explicit}`);
+ fee = explicit;
+ } else if (quoted !== undefined) {
+ fee = quoted;
+ } else {
+ let minfee = Math.max(5, Math.round(amt * 0.005));
+ const { channels } = await ln.listpeerchannels();
+ if (channels.some((c) => c.peer_id === payee)) minfee = 0;
+ fee = minfee;
+ }
const { pays } = await ln.listpays(pr);
if (pays.find((p) => p.status === "complete"))
@@ -556,6 +1054,17 @@ export const sendLightning = async ({
if (pays.find((p) => p.status === "pending"))
fail("Payment is already underway");
+ // A prior attempt at this same invoice may still be awaiting reconciliation
+ // by check() (its CLN parts resolved but the debit hasn't been reversed or
+ // finalized yet). The pending set is keyed by bolt11 and shared across
+ // attempts, so letting a retry proceed here means the retry's own
+ // reverse()/finalize() clears the shared entry and ORPHANS the first
+ // attempt's debit — check() never sees it again and the user is never
+ // refunded. Make the retry wait until reconciliation clears the entry
+ // (seconds to ~a minute).
+ if (await db.sIsMember("pending", pr))
+ fail("Previous attempt is still settling, please retry in a minute");
+
p = await debit({
hash: pr,
amount: amount_msat ? Math.round(amount_msat / 1000) : amount,
@@ -563,11 +1072,17 @@ export const sendLightning = async ({
memo,
user,
type: PaymentType.lightning,
+ maxTotal,
+ exempt,
});
await db.sAdd("pending", pr);
- l("paying lightning invoice", pr.substr(-8), amount, fee);
+ // Instrumentation: track which exit path the payment took so we can
+ // diagnose any future stuck "optimistic" cases (confirmed=true with no
+ // ref, no pending-set membership). Logged just before the function
+ // returns; if "outcome=unsettled" appears in the logs we have a leak.
+ let outcome = "unknown";
try {
const r = await ln.xpay({
@@ -575,20 +1090,133 @@ export const sendLightning = async ({
// bolt11: pr.replace(/\s/g, "").toLowerCase(),
amount_msat: amount_msat ? undefined : amount * 1000,
maxfee: fee * 1000,
- retry_for: 10,
+ // 30s wasn't enough to get past a drained zero-fee hop. Route-finding
+ // prefers free channels, so xpay would spend the whole budget retrying
+ // one 0-base/0-ppm channel that is permanently empty in our direction
+ // and time out ("64 attempts, temporary_channel_failure for the same
+ // scid") without ever trying the paths that work — Strike/Zap payments
+ // failed this way for every user. xpay is in ln.ts's NO_TIMEOUT set and
+ // nothing in front of it (fastify, Caddy, the UI fetch) imposes a
+ // deadline, so the longer budget runs to completion.
+ //
+ // COUPLED TO check(): that reversal sweep must keep skipping payments
+ // for at least twice this window, or it reverses a payment xpay is
+ // still driving and we refund one that settles (the LEAKED DEBIT
+ // losses). Change both together.
+ retry_for: 60,
+ layers: await xpayLayers(),
});
+ // Only log the xpay response when there's a real concern — no preimage
+ // at all (xpay failed silently and we'll need to reverse) is interesting;
+ // failed_parts WITH a preimage is normal multi-path retry behavior and
+ // not worth warning about. The failed_parts branch below handles the
+ // recovery without log spam in the success case.
+ if (!(r?.payment_preimage ?? r?.preimage)) {
+ warn("xpay returned no preimage", p.id, JSON.stringify({
+ status: r?.status,
+ failed_parts: r?.failed_parts,
+ parts: r?.parts,
+ amount_sent_msat: r?.amount_sent_msat,
+ amount_msat: r?.amount_msat,
+ }));
+ }
+
+ if (r.failed_parts) {
+ // xpay didn't throw but reported failed parts. Verify with listpays
+ // before deciding — for multi-path payments this is the common path
+ // and usually finalizes cleanly; the noisy warns only fire when
+ // listpays disagrees with xpay or finalize itself throws.
+ try {
+ const { pays } = await ln.listpays(pr);
+ const completed = pays.find((x) => x.status === "complete");
+ const failed = !pays.length || pays.every((x) => x.status === "failed");
+ if (completed) {
+ try { await finalize(completed, p); outcome = "finalized-via-listpays-after-failed_parts"; } catch (e: any) { warn("finalize threw in failed_parts branch", p.id, e?.message); outcome = "finalize-threw-failed_parts"; }
+ } else if (failed) {
+ warn("xpay failed_parts and listpays failed — reversing", p.id);
+ await reverse(p); outcome = "reversed-via-failed_parts";
+ } else {
+ warn("xpay failed_parts, listpays still pending — leaving for check()", p.id);
+ outcome = "pending-after-failed_parts";
+ }
+ } catch (verifyErr: any) {
+ warnThrottled("listpays verification failed (failed_parts branch)", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-failed_parts";
+ }
+ } else {
+ try {
+ p = await finalize(r, p);
+ outcome = "finalized";
+ } catch (e: any) {
+ warn("finalize threw despite no failed_parts", p.id, e?.message);
+ outcome = "finalize-threw";
+ }
+ }
+ } catch (e: any) {
+ err("failed to pay", pr.substr(-8), "xpay error:", shortError(e?.message));
try {
- if (!r.failed_parts) p = await finalize(r, p);
- } catch (e) {
- warn("failed to process payment", p.id);
+ const { pays } = await ln.listpays(pr);
+ warn("listpays after xpay-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? []));
+ const completed = pays.find((p) => p.status === "complete");
+ const failed = !pays.length || pays.every((p) => p.status === "failed");
+ if (completed) {
+ warn("payment completed despite error, finalizing", p.id);
+ try { await finalize(completed, p); outcome = "finalized-after-xpay-throw"; } catch (_) { outcome = "finalize-threw-after-xpay-throw"; }
+ } else if (failed) {
+ await reverse(p); outcome = "reversed-after-xpay-throw";
+ } else {
+ outcome = "pending-after-xpay-throw";
+ }
+ // else: pending — leave the debit in place; check() loop will reconcile
+ // once CLN confirms outcome. Reversing while still in flight would
+ // refund the user even though the payment may yet complete.
+ } catch (verifyErr: any) {
+ // listpays itself errored (CLN unreachable). We deliberately don't
+ // reverse here — the debit stays and pending stays in the reconciler
+ // set, so check() will retry once CLN is back. Surface the failure so
+ // a real outage doesn't go unnoticed.
+ warnThrottled("listpays verification failed", verifyErr?.message ?? String(verifyErr));
+ outcome = "verify-failed-after-xpay-throw";
}
- } catch (e) {
- err("failed to pay", pr.substr(-8));
- reverse(p);
+ warn("sendLightning outcome", p.id, "=", outcome);
throw e;
}
+ // Leak guard: re-read the db record before deciding. If the payment has
+ // ref set in db, it's settled regardless of which code path threw —
+ // finalize() may have set ref via db.set and then thrown on a later line
+ // (e.g. balance.incrBy for the fee refund), but the payment itself is
+ // recorded as complete. Only treat as a real leak if ref is not in db.
+ const finalizedOutcomes = new Set([
+ "finalized",
+ "finalized-via-listpays-after-failed_parts",
+ "finalized-after-xpay-throw",
+ ]);
+ const safeNonFinalized = new Set([
+ "reversed-via-failed_parts",
+ "reversed-after-xpay-throw",
+ "pending-after-failed_parts",
+ "pending-after-xpay-throw",
+ "verify-failed-after-failed_parts",
+ "verify-failed-after-xpay-throw",
+ ]);
+ const persisted = await g(`payment:${p.id}`);
+ const refInDb = !!persisted?.ref;
+ if (refInDb || finalizedOutcomes.has(outcome)) {
+ // Only log when the outcome wasn't the boring success case — every
+ // payment goes through "finalized" so logging it is just noise. The
+ // recovery-via-fallback variants (finalized-via-listpays-*, etc.) are
+ // worth knowing about, so emit those.
+ if (outcome !== "finalized") {
+ l("sendLightning outcome", p.id, "=", outcome, refInDb && !finalizedOutcomes.has(outcome) ? "(ref set in db despite throw)" : "");
+ }
+ } else if (safeNonFinalized.has(outcome)) {
+ warn("sendLightning outcome", p.id, "=", outcome);
+ } else {
+ err("LEAKED DEBIT in sendLightning", p.id, "outcome=", outcome, "user=", user?.username, "amount=", amount, "fee=", fee);
+ }
+
return p;
};
@@ -598,6 +1226,7 @@ export const sendInternal = async ({
memo = undefined,
recipient,
sender,
+ maxTotal = undefined,
}) => {
if (!invoice)
invoice = await generate({
@@ -606,8 +1235,8 @@ export const sendInternal = async ({
});
const { hash } = invoice;
- const p = await debit({ hash, amount, memo, user: sender });
- await credit({ hash, amount, memo, ref: sender.id });
+ const p = await debit({ hash, amount, memo, user: sender, maxTotal });
+ await credit({ hash, amount, memo, ref: sender.id, tip: p.tip });
if (invoice.memo?.includes("9734")) {
const { invoices } = await ln.listinvoices({ invstring: hash });
@@ -628,7 +1257,16 @@ const getAddressType = async (a) => {
try {
await lq.getAddressInfo(a);
return PaymentType.liquid;
- } catch (e) {
+ } catch (e: any) {
+ // Distinguish "lq is down" from "address isn't liquid" so users get a useful
+ // message instead of "unrecognized address" when cs/lq is the actual problem.
+ if (
+ e?.message?.includes("Unable to connect") ||
+ e?.code === "ECONNREFUSED" ||
+ e?.code === "ETIMEDOUT"
+ ) {
+ fail("liquid temporarily unavailable");
+ }
fail("unrecognized address");
}
}
@@ -643,11 +1281,11 @@ export const build = async ({
user,
}) => {
const type = await getAddressType(address);
- if (!aid) aid = user.id;
+ aid = await requireAccount({ aid, user });
const node =
aid === user.id ? rpc(config[type]) : rpc({ ...config[type], wallet: aid });
- amount = parseInt(amount);
+ amount = Number.parseInt(amount);
if (amount < 0) fail("invalid amount");
const fees: any =
@@ -655,13 +1293,10 @@ export const build = async ({
? { fastestFee: 0.1, halfHourFee: 0.1, hourFee: 0.1 }
: await fetch(`${api[type]}/fees/recommended`).then((r) => r.json());
- fees.hourFee = fees.halfHourFee;
- fees.halfHourFee = fees.fastestFee;
-
if (type === PaymentType.bitcoin) {
- fees.fastestFee = Math.round(fees.fastestFee * 1.1);
- if (fees.fastestFee === fees.halfHourFee) fees.fastestFee++;
- if (fees.hourFee === fees.halfHourFee) fees.hourFee--;
+ fees.hourFee = fees.halfHourFee;
+ fees.halfHourFee = fees.fastestFee;
+ fees.fastestFee = Math.ceil(fees.fastestFee * 1.5);
}
if (!feeRate) {
@@ -723,7 +1358,12 @@ export const build = async ({
}
const inputs = [];
- const { vin } = await node.decodeRawTransaction(tx.hex);
+ const { vin, txid: built } = await node.decodeRawTransaction(tx.hex);
+
+ // Receipt for sendOnchain(): this account composed this exact tx. Bound to
+ // aid so one account can't replay another's build, and short-lived so a hex
+ // can't be banked for later.
+ await db.set(`build:${built}`, aid, { EX: 3600 });
for (const { txid, vout } of vin) {
const rawTx = await node.getRawTransaction(txid);
@@ -738,24 +1378,35 @@ export const build = async ({
inputs.push({ witnessUtxo, path });
}
+
return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract };
};
export const catchUp = async () => {
try {
const txns = [];
+ // Each chain is isolated: one being unreachable (e.g. lq when cs is down)
+ // must not stop the other chain's deposit-recovery loop.
for (const [type, n] of Object.entries({ bitcoin: bc, liquid: lq })) {
- txns.push(
- ...(await n.listTransactions("*", 10)).filter((tx) => {
- tx.type = type;
- return tx.category === "receive" && tx.confirmations > 0;
- }),
- );
+ try {
+ txns.push(
+ ...(await n.listTransactions("*", 100)).filter((tx) => {
+ tx.type = type;
+ return tx.category === "receive" && tx.confirmations > 0;
+ }),
+ );
+ } catch (e: any) {
+ warnThrottled(`catchUp ${type}`, e.message);
+ }
}
for (const { txid, type } of txns) {
try {
if (await db.zScore("seen", txid)) continue;
+ // walletnotify is the primary path; catchUp is a safety net. The
+ // double-call I added 2026-05-05 was for when walletnotify was broken
+ // on cs's lq — now that's fixed, a single call suffices and avoids
+ // contributing to /confirm race conditions.
await got.post(`http://localhost:${process.env.PORT || 3119}/confirm`, {
json: { txid, wallet: config[type].wallet, type },
});
@@ -771,66 +1422,7 @@ export const catchUp = async () => {
err("problem syncing", e.message);
}
- setTimeout(catchUp, 10000);
-};
-
-export const reconcile = async (account, initial = false) => {
- try {
- const { descriptors, id, uid, type } = account;
- const user = await getUser(uid);
- const node = rpc({ ...config[type], wallet: id });
-
- let total;
-
- if (initial) {
- const progress = await node.scanTxOutSet("status");
- if (progress) return setTimeout(() => reconcile(account, initial), 1000);
-
- const { total_amount } = await node.scanTxOutSet("start", descriptors);
- total = Math.round(total_amount * SATS);
- } else {
- total = Math.round((await node.getBalance({ minconf: 1 })) * SATS);
- }
-
- const { balanceAdjustment: memo } = t(user);
-
- const balance = await g(`balance:${id}`);
-
- const amount = Math.abs(total - balance);
- const hash = v4();
-
- if (total > balance) {
- const inv = {
- memo,
- type: PaymentType.reconcile,
- hash,
- amount,
- uid,
- aid: id,
- };
- await s(`invoice:${hash}`, inv);
- await credit({
- hash,
- amount,
- type: PaymentType.reconcile,
- aid: id,
- });
- } else if (total < balance) {
- await debit({
- aid: id,
- amount,
- hash: v4(),
- memo,
- user,
- type: PaymentType.reconcile,
- });
- }
- } catch (e) {
- console.log(e);
- warn("problem reconciling", e.message, account);
- if (e.message.includes("progress"))
- return setTimeout(() => reconcile(account, initial), 1000);
- }
+ setTimeout(catchUp, 30000);
};
export const check = async () => {
@@ -839,8 +1431,34 @@ export const check = async () => {
const payments = await db.sMembers("pending");
for (const pr of payments) {
+ // Case-insensitive: an uppercase-pasted invoice ("LNBC…") is still a
+ // lightning payment. Matching case-sensitively here used to evict such
+ // entries without reversing, stranding failed sends with no auto-refund.
+ if (!pr.toLowerCase().startsWith("ln")) {
+ await db.sRem("pending", pr);
+ continue;
+ }
const p = await getPayment(pr);
- if (!p || Date.now() - p.created < 10000) continue;
+ // Skip payments sendLightning may still be actively driving. xpay retries
+ // for 60s (retry_for: 60), during which listpays can momentarily show all
+ // attempts "failed" before the winning part lands. The old 10s threshold
+ // let check() reverse/refund such a payment mid-flight; it then completed,
+ // and sendLightning's finalize threw on the deleted record — refunding a
+ // payment that actually settled (the LEAKED DEBIT losses). Wait well past
+ // the retry window so sendLightning has finished finalize()/reverse() and
+ // removed it from `pending` before check() ever touches it. This is 2x
+ // retry_for, the same margin the 60s threshold gave the old 30s window —
+ // raise it with retry_for if that ever changes again.
+ // No record anywhere (main db or archive) means there's nothing left to
+ // reconcile — the debit was already reversed or the entry is a stale
+ // stray. Drop it: sendLightning now refuses to re-pay an invoice while
+ // its bolt11 sits in this set, so a dead entry would otherwise block
+ // that invoice forever.
+ if (!p) {
+ await db.sRem("pending", pr);
+ continue;
+ }
+ if (Date.now() - p.created < 120000) continue;
const { pays } = await ln.listpays(pr);
const failed = !pays.length || pays.every((p) => p.status === "failed");
@@ -851,13 +1469,16 @@ export const check = async () => {
else if (failed) await reverse(p);
} catch (e) {
err("failed to finalize", p.id, e.message);
+ if (e.message?.includes("already been reversed")) {
+ await db.sRem("pending", p.hash);
+ }
}
}
} catch (e) {
err("payment check failed", e.message);
}
- setTimeout(check, 2000);
+ setTimeout(check, 5000);
};
const finalize = async (r, p) => {
@@ -866,21 +1487,89 @@ const finalize = async (r, p) => {
if (!preimage) fail("missing preimage");
await db.sRem("pending", p.hash);
- l("payment completed", p.id, r.payment_preimage);
nwcNotify(p);
const maxfee = p.fee;
- const { amount_msat } = await ln.decode(p.hash);
- p.fee = Math.round((r.amount_sent_msat - amount_msat) / 1000);
+ // Compute the actual fee xpay paid (sent − invoice amount, both msat).
+ // Robust against zero-amount invoices (decoded amount_msat is undefined,
+ // fall back to r.amount_msat) and against CLN occasionally returning
+ // unexpected response shapes — if we can't compute a finite integer
+ // fee, keep p.fee at the originally-reserved maxfee so the subsequent
+ // refund delta is 0 instead of NaN (which would crash db.incrBy with
+ // "value is not an integer or out of range" and leave finalize in a
+ // partial state).
+ const decoded = await ln.decode(p.hash);
+ const invMsat = Number(decoded.amount_msat ?? r.amount_msat);
+ const sentMsat = Number(r.amount_sent_msat);
+ // Round UP: the routing fee was quoted and reserved in whole sats (a
+ // 301 msat route is a 1 sat maxfee — xpay's cap is exact and a few msat
+ // short fails the payment), so charge the sat that was quoted rather than
+ // rounding 301 msat to 0 and refunding it. Otherwise a "send everything"
+ // computed from the quote strands a single sat in the account.
+ const computedFee = Math.ceil((sentMsat - invMsat) / 1000);
+ if (!Number.isFinite(computedFee)) {
+ warn("finalize: non-finite fee compute, keeping reserved maxfee", p.id,
+ "sent_msat=", r.amount_sent_msat, "inv_msat=", decoded.amount_msat ?? r.amount_msat);
+ }
+ p.fee = Number.isFinite(computedFee) ? computedFee : maxfee;
p.ref = preimage;
+ // The HTLC settled (we have the preimage) — the send is now fully confirmed.
+ p.confirmed = true;
+
+ const current = await g(`payment:${p.id}`);
+ // The record is gone because a concurrent reverse() already refunded this
+ // payment — yet here we are finalizing it, so it actually COMPLETED on the
+ // network and the refund was wrong (coinos is now short the amount). Do NOT
+ // silently re-create the record; raise a clear, greppable error so the
+ // LEAKED DEBIT guard logs it for manual recovery instead of a cryptic
+ // null-deref. (The check()-window fix should make this path very rare.)
+ if (!current) fail(`finalize: payment ${p.id} reversed-then-completed (double-pay leak)`);
- if (!(await g(`payment:${p.id}`)).ref) {
+ if (!current.ref) {
await s(`payment:${p.id}`, p);
+ const refund = maxfee - p.fee;
+ if (Number.isFinite(refund) && Number.isInteger(refund)) {
+ await db.incrBy(`balance:${p.uid}`, refund);
+ } else {
+ warn("finalize: skipping fee refund (non-integer delta)", p.id,
+ "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund);
+ }
+ }
+
+ return p;
+};
+
+// Keysend variant of finalize(). Keysend has no bolt11, so finalize()'s
+// `ln.decode(p.hash)` can't run — the real fee is instead the msat actually sent
+// minus the record's own amount. Otherwise identical: flip pending->confirmed,
+// set the preimage, refund the unused fee reserve, and refuse to re-create a
+// record a concurrent reverse() already removed (a settled-but-refunded payment
+// is a double-pay leak, surfaced for manual recovery rather than papered over).
+const finalizeKeysend = async (r, p, preimage) => {
+ await db.sRem("pending", p.hash); // keysend rarely enters the set; defensive
+
+ const maxfee = p.fee;
+ const sentMsat = Number(r.amount_sent_msat);
+ const actualFee = Math.ceil((sentMsat - Math.abs(p.amount) * 1000) / 1000);
+ p.fee = Number.isFinite(actualFee) && actualFee >= 0 ? actualFee : maxfee;
+ p.ref = preimage;
+ p.confirmed = true;
+
+ const current = await g(`payment:${p.id}`);
+ if (!current) fail(`finalizeKeysend: payment ${p.id} reversed-then-completed (double-pay leak)`);
- l("refunding fee", maxfee, p.fee, maxfee - p.fee, p.ref);
- await db.incrBy(`balance:${p.uid}`, maxfee - p.fee);
+ if (!current.ref) {
+ await s(`payment:${p.id}`, p);
+ const refund = maxfee - p.fee;
+ if (Number.isFinite(refund) && Number.isInteger(refund)) {
+ await db.incrBy(`balance:${p.uid}`, refund);
+ } else {
+ warn("finalizeKeysend: skipping fee refund (non-integer delta)", p.id,
+ "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund);
+ }
}
+ nwcNotify(p);
return p;
};
@@ -889,7 +1578,13 @@ const reverse = async (p) => {
const total = Math.abs(p.amount) + p.fee + p.ourfee;
const ourfee = p.ourfee || 0;
- const credit = Math.round(total * config.fee[PaymentType.lightning]) - ourfee;
+ // Restore the free-tier credit consumed at debit time. Use the SAME rate the
+ // send was charged at — flagged `ln:nofree` accounts were charged the elevated
+ // rate, so reversing with the standard rate would push their credit negative.
+ const rate = (await g(`ln:nofree:${p.uid}`))
+ ? config.fee.lightningHigh
+ : config.fee.lightning;
+ const credit = Math.round(total * rate) - ourfee;
l("reversing", p.id, p.amount, p.fee, total, ourfee, credit);
@@ -909,27 +1604,70 @@ const reverse = async (p) => {
};
const freezeCheck = async () => {
- const funds = await ln.listfunds();
- const lnbalance = Math.round(
- funds.channels.reduce((a, b) => a + b.our_amount_msat, 0) / 1000,
- );
-
- const bcbalance = Math.round((await bc.getBalance()) * SATS);
- const { bitcoin } = await lq.getBalance();
- const lqbalance = Math.round(bitcoin * SATS);
-
- const lnthreshold = await g("lightning:threshold");
- const bcthreshold = await g("bitcoin:threshold");
- const lqthreshold = await g("liquid:threshold");
+ // Crash-resilient loop: ANY error in the body (an unhandled rejection in the
+ // limit-write section, a stuck withLimitLock, etc.) used to escape here and
+ // kill the loop silently (the global unhandledRejection handler is a no-op),
+ // freezing every ${type}:limit until someone noticed sends failing. Wrap the
+ // whole body so errors are logged and the next tick is ALWAYS scheduled.
+ try {
+ // Each asset is fetched independently so e.g. lq being unreachable doesn't
+ // prevent lightning and bitcoin limits from refreshing.
+ let lnbalance: number | undefined;
+ try {
+ const funds = await ln.listfunds();
+ // Only spendable channels — ONCHAIN/CLOSING funds are locked in pending
+ // sweeps and aren't usable for lightning sends, so counting them inflates
+ // the limit. CHANNELD_AWAITING_SPLICE is included so an in-flight splice
+ // doesn't briefly drop the limit to zero.
+ lnbalance = Math.round(
+ funds.channels
+ .filter((c) =>
+ c.state === "CHANNELD_NORMAL" || c.state === "CHANNELD_AWAITING_SPLICE",
+ )
+ .reduce((a, b) => a + b.our_amount_msat, 0) / 1000,
+ );
+ } catch (e: any) {
+ warnThrottled("freezeCheck lightning", e.message);
+ }
- await s("lightning:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0));
- await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0));
+ let bcbalance: number | undefined;
+ try {
+ bcbalance = Math.round((await bc.getBalance()) * SATS);
+ } catch (e: any) {
+ warnThrottled("freezeCheck bitcoin", e.message);
+ }
- await s("fund:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("ecash:limit", Math.max(lnbalance - lnthreshold, 0));
- await s("bolt12:limit", Math.max(lnbalance - lnthreshold, 0));
+ let lqbalance: number | undefined;
+ try {
+ const { bitcoin } = await lq.getBalance();
+ lqbalance = Math.round(bitcoin * SATS);
+ } catch (e: any) {
+ warnThrottled("freezeCheck liquid", e.message);
+ }
- setTimeout(freezeCheck, 10000);
+ if (lnbalance !== undefined) {
+ const lnthreshold = await g("lightning:threshold");
+ const lim = Math.max(lnbalance - lnthreshold, 0);
+ for (const t of ["lightning", "fund", "ecash", "bolt12"]) {
+ await withLimitLock(t, async () => { await s(`${t}:limit`, lim); });
+ }
+ }
+ if (bcbalance !== undefined) {
+ const bcthreshold = await g("bitcoin:threshold");
+ await withLimitLock("bitcoin", async () => {
+ await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0));
+ });
+ }
+ if (lqbalance !== undefined) {
+ const lqthreshold = await g("liquid:threshold");
+ await withLimitLock("liquid", async () => {
+ await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0));
+ });
+ }
+ } catch (e: any) {
+ warn("freezeCheck: iteration failed, will retry next tick:", e?.message ?? String(e));
+ } finally {
+ setTimeout(freezeCheck, 10000);
+ }
};
freezeCheck();