diff options
Diffstat (limited to 'lib/payments.ts')
| -rw-r--r-- | lib/payments.ts | 1108 |
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(); |
