import config from "$config"; import { existsSync } from "fs"; import api from "$lib/api"; import changeid from "$lib/changeid"; import { db, g, gf, s, sa } from "$lib/db"; import { broadcastTx, btcNetwork, deriveAddress, deriveAddresses, getAddressTxs, getAddressUtxos, getTxHex, getTxStatus, hdVersions, findLastUsedIndex, } from "$lib/esplora"; import { HDKey } from "@scure/bip32"; import { generate } from "$lib/invoices"; import ln from "$lib/ln"; import { err, l, warn } from "$lib/logging"; import { handleZap } from "$lib/nostr"; import { notify, nwcNotify } from "$lib/notifications"; import { emit } from "$lib/sockets"; import { squarePayment } from "$lib/square"; import { getBalance, getCredit, tbConfirm, tbCredit, tbDebit, tbRefund, tbReverse, tbSetBalance, tbSetPending, } from "$lib/tb"; import { SATS, btc, fail, fmt, formatReceipt, getInvoice, getPayment, getUser, link, sats, sleep, t, } from "$lib/utils"; import { getArkBalance, sendArk } from "$lib/ark"; import { callWebhook } from "$lib/webhooks"; import rpc from "@coinos/rpc"; import { selectUTXO, p2wpkh } from "@scure/btc-signer"; import { bech32 } from "bech32"; import got from "got"; import { v4 } from "uuid"; import { PaymentType } from "$lib/types"; import { P2A_VALUE, getP2AAddress, isP2AOutput, calculateBumpReserve } from "$lib/p2a"; import { buildCpfpSend } from "$lib/cpfp"; const bc = rpc(config.bitcoin); const lq = rpc(config.liquid); const { URL } = process.env; const dust = 547; // External-withdrawal lockfiles. Out-of-band emergency stop: nobal.sh (or a // human) can `touch /home/adam/locks/.locked` on the host, which is // bind-mounted into this container at /locks. Existence of /locks/.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; }; const resolveBip353 = async (name: string, domain: string) => { const qname = `${name}.user._bitcoin-payment.${domain}`; const res = await fetch( `https://1.1.1.1/dns-query?name=${qname}&type=TXT`, { headers: { accept: "application/dns-json" } }, ); const data = await res.json(); if (!data.AD) return null; // DNSSEC validation failed if (!data.Answer?.length) return null; for (const ans of data.Answer) { const txt = ans.data?.replace(/^"|"$/g, "").replaceAll('" "', ""); if (txt?.startsWith("bitcoin:")) return txt; } return null; }; export const getUserRate = async (user) => { const rates = await g("rates"); const { currency } = user; const rate = rates[currency]; return { rates, rate, currency }; }; export const acquireArkLock = async (hash) => { const locked = await db.set(`arklock:${hash}`, "1", { NX: true }); if (!locked) fail("Already processed"); }; export const createArkPayment = async ({ aid, uid, amount, hash, rate, currency, type = PaymentType.ark, iid = undefined, created = Date.now(), mapHashToId = false, extraHashMappings = [] as string[], extraMultiOps = undefined as ((m: any, p: any) => void) | undefined, }) => { const p = { id: v4(), aid, amount, hash, confirmed: true, rate, currency, type, uid, iid, created, }; const m = db.multi(); m.set(`payment:${p.id}`, JSON.stringify(p)) .lPush(`${aid}:payments`, p.id) .set(`${aid}:payments:last`, p.created); if (mapHashToId) { m.set(`payment:${aid}:${hash}`, JSON.stringify(p.id)); } for (const h of extraHashMappings) { m.set(`payment:${aid}:${h}`, JSON.stringify(p.id)); } if (extraMultiOps) extraMultiOps(m, p); await m.exec(); return p; }; export const debit = async ({ aid = undefined, hash, amount, fee = 0, memo = undefined, user, type = PaymentType.internal, rate = undefined, }) => { amount = Number.parseInt(amount); // Defense-in-depth: verify account ownership at the debit level if (aid && user && aid !== user.id) { const index = await db.lPos(`${user.id}:accounts`, aid); if (index === null) fail("unauthorized"); } const whitelisted = await db.sIsMember("whitelist", 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); const skipServerLimit = type === PaymentType.fund || type === PaymentType.internal; // 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) { warn("Blocking", user.username, amount, hash, user.id, type, "withdraw-lock", lockedKind); fail("External withdrawals temporarily disabled"); } } if (frozen || (userLimit != null && amount > userLimit && !whitelisted) || (!skipServerLimit && serverLimit != null && amount > serverLimit)) { warn("Blocking", user.username, amount, hash, user.id, type, frozen, userLimit, serverLimit); fail("Problem sending payment"); } let ref; const { id: uid } = user; if (!aid) aid = uid; const debitAccount = aid && aid !== uid ? await g(`account:${aid}`) : null; const currency = debitAccount?.currency || user.currency; const rates = await g("rates"); if (!rate) rate = rates[currency]; const invoice = await getInvoice(hash); let iid; if (invoice) { if (invoice.received >= amount && invoice.type !== PaymentType.bolt12) fail("Invoice already paid"); ({ id: iid } = invoice); ref = invoice.uid; const equivalentRate = invoice.rate * (rates[currency] / rates[invoice.currency]); if (Math.abs(invoice.rate / rates[invoice.currency] - 1) < 0.01) { rate = equivalentRate; } else { warn("rate slipped", hash, invoice.rate, equivalentRate); } } const tip = Number.parseInt(invoice?.tip) || 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; let ourfee: any = [PaymentType.bitcoin, PaymentType.liquid, PaymentType.lightning].includes(type) ? Math.round((amount + fee + tip) * config.fee[creditType]) : 0; if (aid !== uid) ourfee = 0; const frozenBalance = !blacklisted || whitelisted ? 0 : await getBalance(uid); ourfee = await tbDebit( aid, uid, creditType, amount || 0, tip || 0, fee || 0, ourfee || 0, frozenBalance || 0, t(user).insufficientFunds, ); if (ourfee.err) fail(ourfee.err); const id = v4(); const p = { id, aid, amount: -amount, fee, hash, hex: undefined, ourfee, memo, iid, uid, confirmed: true, rate, currency, type, ref, tip, created: Date.now(), }; await s(`payment:${hash}`, id); await s(`payment:${id}`, p); await db .multi() .lPush("payments", id) .lPush(`${aid || uid}:payments`, id) .set(`${aid || uid}:payments:last`, p.created) .exec(); l(user.username, "sent", type, amount); if (![PaymentType.lightning, PaymentType.bolt12].includes(type)) nwcNotify(p); return p; }; export const credit = async ({ hash, amount, memo = "", ref = "", type = PaymentType.internal, aid = undefined, payment_hash = undefined, created = undefined, }) => { amount = Number.parseInt(amount) || 0; 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); } if (!inv) { await db.sAdd("missing", ref.split(":")[0]); return; } let { path, tip } = inv; tip = Number.parseInt(tip) || 0; if (!memo) ({ memo } = inv); if (memo && memo.length > 5000) fail("memo too long"); if (amount < 0 || tip < 0) fail("Invalid amount"); if (type === PaymentType.internal) amount += tip; const user = await getUser(inv.uid); const { id: uid } = user; const creditAccount = aid && aid !== uid ? await g(`account:${aid}`) : null; const currency = creditAccount?.currency || user.currency; const rates = await g("rates"); let rate = rates[currency]; if (!rate) await sleep(1000); rate = rates[currency]; const equivalentRate = inv.rate * (rates[currency] / rates[inv.currency]); if (Math.abs(inv.rate / rates[inv.currency] - 1) < 0.01) { rate = equivalentRate; } else { // warn("rate slipped", hash, invoice.rate, equivalentRate); } const id = v4(); const p = { aid, id, iid: inv.id, hash, amount: amount - tip, path, uid, rate, currency, memo, payment_hash, ref, tip, type, confirmed: true, created: created || Date.now(), items: undefined, }; if ([PaymentType.bitcoin, PaymentType.liquid].includes(type)) inv.pending += amount; else { inv.received += amount; inv.preimage = ref; inv.settled = Date.now(); } // 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 credit 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 tbCredit. `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; } } let balanceKey = "balance"; if ([PaymentType.bitcoin, PaymentType.liquid].includes(type)) { const [txid, vout] = ref.split(":").slice(-2); 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 even if the // main-db pointer is wiped by a data-loss event. await sa(`payment:${txid}:${vout}`, id); } else { await s(`payment:${hash}`, id); await sa(`payment:${hash}`, id); } let creditType = type; if (creditType === PaymentType.bolt12) creditType = PaymentType.lightning; const isPending = balanceKey === "pending"; await tbCredit(aid || uid, uid, creditType, amount, isPending); await db .multi() .set(`invoice:${inv.id}`, JSON.stringify(inv)) .set(`payment:${p.id}`, JSON.stringify(p)) .lPush(`${aid || uid}:payments`, p.id) .set(`${aid || uid}:payments:last`, p.created) .exec(); // Mirror the payment + invoice records 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; } await completePayment(inv, p, user); return p; }; export const completePayment = async (inv, p, user) => { const { id, username } = user; const aid = p.aid || id; const account = aid !== id ? await g(`account:${aid}`) : null; const autowithdraw = account?.autowithdraw; const threshold = account?.threshold ?? user.threshold; const reserve = account?.reserve ?? user.reserve; const destination = account?.destination ?? user.destination; let withdrawal; if (p.confirmed) { if (inv.forward && !inv.forwarded) { const lockKey = `forward:${inv.id}`; const locked = await db.setNX(lockKey, "1"); if (locked) { await db.expire(lockKey, 300); try { inv.forwarded = true; await s(`invoice:${inv.id}`, inv); // Skip forward if destination is the same account (not just same user) const destInv = await getInvoice(inv.forward).catch(() => null); if (destInv?.uid === id && destInv?.aid === aid) return; await pay({ amount: p.amount, to: inv.forward, user }); } catch (e) { warn(username, "forward failed", inv.forward, e.message); } } } else if (autowithdraw && p.type !== "ark") { try { const to = destination.trim(); const balance = await getBalance(aid); const amount = balance - reserve; if (balance > threshold) { l("initiating autowithdrawal", amount, to, balance, threshold); const w = await pay({ aid, amount, to, user }); withdrawal = { amount: fmt(-w.amount), link: link(w.id), }; } } catch (e) { console.log(e); withdrawal = { failed: true }; warn(username, "autowithdraw failed", e.message); } } } nwcNotify(p); notify(p, user, withdrawal); squarePayment(p, user); l(username, "received", p.type, p.amount); callWebhook(inv, p); }; const confirmWatchedIncoming = async (address, existing) => { existing.confirmed = true; const inv = await getInvoice(address); if (inv) { inv.received += Number.parseInt(inv.pending); inv.pending = 0; await s(`invoice:${inv.id}`, inv); } await s(`payment:${existing.id}`, existing); await tbConfirm(existing.aid || existing.uid, existing.amount); const user = await getUser(existing.uid); if (inv) await completePayment(inv, existing, user); await db.sRem("watching", address); }; export const processWatchedTx = async (tx) => { const txid = tx.txid; for (let vout = 0; vout < tx.vout.length; vout++) { const output = tx.vout[vout]; const address = output.scriptpubkey_address; if (!address) continue; if (!(await db.sIsMember("watching", address))) continue; const invoice = await getInvoice(address); if (!invoice) continue; const existing = await getPayment(`${txid}:${vout}`); if (existing) { if (!existing.confirmed && tx.status?.confirmed) { await confirmWatchedIncoming(address, existing); } continue; } if (output.value < 300) continue; const lockKey = `lock:${txid}:${vout}`; const locked = await db.setNX(lockKey, "1"); if (!locked) continue; await db.expire(lockKey, 60); await credit({ hash: address, amount: output.value, ref: `${txid}:${vout}`, type: PaymentType.bitcoin, aid: invoice.aid, }); if (tx.status?.confirmed) { const created = await getPayment(`${txid}:${vout}`); if (created && !created.confirmed) { await confirmWatchedIncoming(address, created); } } } }; const pay = async ({ aid = undefined, amount, to, user }) => { if (!aid) aid = user.id; amount = Number.parseInt(amount) || 0; let lnurl; let pr; if (to.includes("@") && to.includes(".")) { const [name, domain] = to.split("@"); if (URL.includes(domain)) to = name; else { try { const uri = await resolveBip353(name, domain); if (uri) { const params = new URLSearchParams(uri.split("?")[1]); if (params.has("lno")) to = params.get("lno"); else if (params.has("sp")) to = params.get("sp"); else if (params.has("lightning")) to = params.get("lightning"); else { const addr = uri.replace("bitcoin:", "").split("?")[0]; if (addr) to = addr; } } } catch (e) { warn("BIP 353 resolution failed, falling back to LNURL", e.message); } if (to.includes("@")) lnurl = `https://${domain}/.well-known/lnurlp/${name}`; } } else if (to.startsWith("lnurl")) { lnurl = Buffer.from(bech32.fromWords(bech32.decode(to, 20000).words)).toString(); } const recipient = await getUser(to); if (recipient) return sendInternal({ amount, recipient, sender: user, }); if (to.startsWith("ark") || to.startsWith("tark")) { const tmpHash = v4(); const p = await debit({ hash: tmpHash, amount, user, type: PaymentType.ark, }); try { const txid = await sendArk(to, amount); p.hash = txid; await s(`payment:${p.id}`, p); await s(`payment:${txid}`, p.id); // Instant vault notification try { const vaultInfo = await g(`arkaddr:${to}`); if (vaultInfo) { const { aid: vaultAid, uid: vaultUid } = vaultInfo; const isForward = await g(`custodial-ark-invoice:${to}`); const vaultOwner = await g(`user:${vaultUid}`); const { rate, currency } = await getUserRate(vaultOwner || user); const vp = await createArkPayment({ aid: vaultAid, uid: vaultUid, amount, hash: txid, rate, currency, extraHashMappings: [txid], }); if (!isForward) { l("ark pay: instant vault credit", vaultAid, txid); emit(vaultUid, "payment", vp); } } } catch (e) { warn("ark pay: instant vault notification failed", e.message); } } catch (e) { await reverse(p); throw e; } return p; } // Check for local invoice before applying external fees if (to.startsWith("ln") && !to.startsWith("lnurl") && !to.startsWith("lno")) { const localInv = await getInvoice(to).catch(() => null); if (localInv) { const recipient = await getUser(localInv.uid); if (recipient) return sendInternal({ amount, invoice: localInv, recipient, sender: user }); } } let fee; const ourfee = Math.round(amount * (config.fee.lightning || 0)); if (lnurl) { fee = Math.max(5, Math.round(amount * 0.02)); amount -= fee + ourfee; const { callback } = (await got(lnurl).json()) as any; ({ pr } = (await got(`${callback}?amount=${amount * 1000}`).json()) as any); } else if (to.startsWith("lno")) { const { id: source } = await ln.getinfo(); const { offer_issuer_id } = await ln.decode(to); const { routes } = await ln.getroutes( source, offer_issuer_id, amount * 1000, ["auto.localchans", "auto.sourcefree"], Math.round(amount * 0.02) * 1000, 6, ); fee = routes.length ? Math.max(5, Math.round((routes[0].path[0].amount_msat - routes[0].amount_msat) / 1000)) : 0; amount -= fee + ourfee; const { invoice } = await ln.fetchinvoice(to, amount * 1000); pr = invoice; } else if (to.startsWith("ln")) { fee = Math.max(5, Math.round(amount * 0.02)); amount -= fee + ourfee; pr = to; } if (pr) return sendLightning({ user, pr, amount, fee }); return sendOnchain({ aid, amount, address: to, user, subtract: true }); }; export const decode = async (hex) => { let type; let tx; try { tx = await bc.decodeRawTransaction(hex); type = PaymentType.bitcoin; } catch (e) { try { tx = await lq.decodeRawTransaction(hex); type = PaymentType.liquid; } catch (e) { err("invalid hex", hex); fail("unrecognized tx"); } } return { tx, type }; }; const inflight = {}; const sendNonCustodial = async (params) => { let { aid, hex, rate, user } = params; if (!hex) ({ hex } = await build(params)); const { tx } = await decode(hex); let { txid } = tx; try { if (inflight[txid]) fail("payment in flight"); inflight[txid] = true; const account = await g(`account:${aid}`); const nextIndex = account.nextIndex || 0; // Build set of own addresses for change detection const ownAddresses = new Set(); for (let i = 0; i <= nextIndex; i++) { ownAddresses.add(deriveAddress(account.pubkey, account.fingerprint, i, false).address); ownAddresses.add(deriveAddress(account.pubkey, account.fingerprint, i, true).address); } let totalIn = 0; for (const { txid: inputTxid, vout } of tx.vin) { const inputHex = await getTxHex(inputTxid); const inputTx = await bc.decodeRawTransaction(inputHex); totalIn += sats(inputTx.vout[vout].value); } let total = 0; let change = 0; for (const { scriptPubKey: { address }, value, } of tx.vout) { total += sats(value); const invoice = await getInvoice(address); if (invoice?.aid === aid) fail("Cannot send to internal address"); if (ownAddresses.has(address)) { change += sats(value); } } const fee = totalIn - total; const amount = total - change; const p = await debit({ aid, hash: txid, amount, fee, rate, user, type: PaymentType.bitcoin, }); p.hex = hex; await s(`payment:${p.id}`, p); await broadcastTx(hex); await db.sAdd(`inflight:${aid}`, p.id); delete inflight[txid]; return p; } catch (e) { delete inflight[txid]; throw e; } }; export const sendOnchain = async (params) => { let { aid, hex, rate, user, signed, feeRate } = params; if (!aid) aid = user.id; // Non-custodial bitcoin account — use esplora if (aid !== user.id) { const index = await db.lPos(`${user.id}:accounts`, aid); if (index === null) fail("unauthorized"); return sendNonCustodial(params); } let buildResult; if (!hex) { buildResult = await build(params); hex = buildResult.hex; } // CPFP+send path: already signed, needs package submission if (buildResult?.signed) { const { txid, parentHex, fee, bumpReserve, p2aVout, parentVsize, ourfee, cpfpParentId, cpfpSubsidy } = buildResult; try { if (inflight[txid]) fail("payment in flight"); inflight[txid] = true; // Submit as package (parent + child) since parent may not be in mempool const r = await bc.testMempoolAccept([hex]); if (r[0].allowed) { await bc.sendRawTransaction(hex); } else { const pkg = await bc.submitPackage([parentHex, hex]); if (!pkg["tx-results"]) fail("package submission failed"); } // Decode to get the send amount const decoded = await bc.decodeRawTransaction(hex); let amount = 0; for (const out of decoded.vout) { if (isP2AOutput(out.scriptPubKey.hex)) continue; const info = await bc.getAddressInfo(out.scriptPubKey.address); if (!info.ismine) amount += sats(out.value); } const p = await debit({ aid, hash: txid, amount, fee: fee + bumpReserve, rate, user, type: PaymentType.bitcoin, }); p.fee = fee; p.hex = hex; (p as any).address = params.address; (p as any).bumpReserve = bumpReserve; (p as any).p2aVout = p2aVout; (p as any).parentVsize = parentVsize; (p as any).cpfpParentId = cpfpParentId; p.confirmed = false; await s(`payment:${p.id}`, p); await db.sAdd("outgoing:unconfirmed", p.id); delete inflight[txid]; return p; } catch (e) { delete inflight[txid]; throw e; } } const { tx, type } = await decode(hex); const node = rpc(config[type]); const isBitcoin = type === PaymentType.bitcoin; let { txid } = tx; try { if (inflight[txid]) fail("payment in flight"); inflight[txid] = true; if (!signed) { if (config[type].walletpass) await node.walletPassphrase(config[type].walletpass, 300); ({ hex } = await node.signRawTransactionWithWallet( type === PaymentType.liquid ? await node.blindRawTransaction(hex) : hex, )); } ({ txid } = await node.decodeRawTransaction(hex)); const r = await node.testMempoolAccept([hex]); if (!r[0].allowed) fail("transaction rejected"); let total = 0; let fee = 0; let change = 0; let p2aVout = -1; if (type === PaymentType.liquid) { for (const { asset, scriptPubKey: { address, type }, value, } of tx.vout) { if (asset !== config.liquid.btc) fail("only L-BTC supported"); if (type === "fee") fee = sats(value); else { total += sats(value); if (address) { if ((await node.getAddressInfo(address)).ismine) { change += sats(value); } } } } } else { let totalIn = 0; for await (const { txid, vout } of tx.vin) { const hex = await node.getRawTransaction(txid); const tx = await node.decodeRawTransaction(hex); totalIn += sats(tx.vout[vout].value); } for (let i = 0; i < tx.vout.length; i++) { const { scriptPubKey, value } = tx.vout[i]; if (isP2AOutput(scriptPubKey.hex)) { p2aVout = i; continue; } total += sats(value); const invoice = await getInvoice(scriptPubKey.address); if (invoice?.aid === aid) fail("Cannot send to internal address"); if ((await node.getAddressInfo(scriptPubKey.address)).ismine) { change += sats(value); } } fee = totalIn - total - (p2aVout >= 0 ? P2A_VALUE : 0); } const amount = total - change; let bumpReserve = buildResult?.bumpReserve || 0; let parentVsize = buildResult?.parentVsize || 0; // When hex is pre-built, check if the fee rate is still acceptable if (isBitcoin && !buildResult) { const currentFees: any = await fetch(`${api[type]}/fees/recommended`).then((r) => r.json()); const minAcceptable = currentFees.hourFee; const decoded = await node.decodeRawTransaction(hex); const originalRate = fee / decoded.vsize; if (originalRate < minAcceptable) { // Fee rate is stale — rebuild the tx from scratch buildResult = await build(params); hex = buildResult.hex; ({ hex } = await node.signRawTransactionWithWallet(hex)); ({ txid } = await node.decodeRawTransaction(hex)); const r2 = await node.testMempoolAccept([hex]); if (!r2[0].allowed) fail("rebuilt transaction rejected"); bumpReserve = buildResult.bumpReserve || 0; parentVsize = buildResult.parentVsize || 0; fee = buildResult.fee || 0; } else { // Original rate still acceptable — honor what was shown to the user bumpReserve = Number(params.bumpReserve) || 0; parentVsize = Number(params.parentVsize) || 0; } } const p = await debit({ aid, hash: txid, amount, fee: fee + bumpReserve, rate, user, type, }); p.fee = fee; p.hex = hex; (p as any).address = params.address; if (isBitcoin) { p.confirmed = false; if (bumpReserve > 0) { (p as any).bumpReserve = bumpReserve; (p as any).p2aVout = p2aVout; (p as any).parentVsize = parentVsize; } } await s(`payment:${p.id}`, p); await node.sendRawTransaction(hex); if (isBitcoin) { await db.sAdd("outgoing:unconfirmed", p.id); } delete inflight[txid]; return p; } catch (e) { delete inflight[txid]; throw e; } }; export const sendKeysend = async ({ hash, amount, pubkey, fee = undefined, memo = undefined, user, extratlvs = undefined, }) => { fee = Math.max(Number.parseInt(fee || amount * 0.005), 5); let p = await gf(`payment:${hash}`); if (p) fail("duplicate keysend"); p = await debit({ hash, amount, fee, memo, user, type: PaymentType.lightning, }); let outcome = "unknown"; try { const r = await ln.keysend({ destination: pubkey, amount_msat: amount * 1000, maxfee: fee * 1000, retry_for: 20, 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, })); outcome = "keysend-returned"; // success path — caller handles preimage polling l("sendKeysend outcome", p.id, "=", outcome); return r; } catch (e: any) { err("failed keysend", hash?.slice(0, 16), "error:", e?.message); try { const { pays } = await ln.listpays({ payment_hash: hash }); warn("listpays after keysend-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? [])); const completed = pays.find((x) => x.status === "complete"); const failed = !pays.length || pays.every((x) => x.status === "failed"); if (completed) { outcome = "keysend-completed-despite-throw"; } else if (failed) { await reverse(p); outcome = "reversed-after-keysend-throw"; } else { outcome = "pending-after-keysend-throw"; } // else: pending or completed — leave the debit; check() loop will // reconcile. Reversing on pending would refund while CLN may still // settle the keysend. } catch (verifyErr: any) { warn("listpays 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 ({ user, pr, amount, fee = undefined, memo = undefined }) => { let p; if (typeof amount !== "undefined") { amount = Number.parseInt(amount); if (amount < 0 || amount > SATS || Number.isNaN(amount)) { warn("invalid amount", amount); fail("Invalid amount"); } } let { type, invoice_amount_msat, amount_msat, invoice_node_id, payee } = await ln.decode(pr); if (type.includes("bolt12")) { amount_msat = invoice_amount_msat; payee = invoice_node_id; } const { channels } = await ln.listpeerchannels(); const isDirect = channels.some((c) => c.peer_id === payee); const minfee = isDirect ? 0 : Math.max(Math.round(amount * 0.005), 5); fee = Math.max(Number.parseInt(fee || 0), minfee); if (fee < 0) fail("Fee cannot be negative"); const { pays } = await ln.listpays(pr); if (pays.find((p) => p.status === "complete")) fail("Invoice has already been paid"); if (pays.find((p) => p.status === "pending")) fail("Payment is already underway"); p = await debit({ hash: pr, amount: amount_msat ? Math.round(amount_msat / 1000) : amount, fee, memo, user, type: PaymentType.lightning, }); 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). Used by the leak guard at the function end. let outcome = "unknown"; try { const r = await ln.xpay({ invstring: pr.replace(/\s/g, "").toLowerCase(), // bolt11: pr.replace(/\s/g, "").toLowerCase(), amount_msat: amount_msat ? undefined : amount * 1000, maxfee: fee * 1000, retry_for: 20, }); // Only log the xpay response shape when there's something interesting — // failed_parts > 0 or a missing preimage. The success case is noise. if (r?.failed_parts || !(r?.payment_preimage ?? r?.preimage)) { warn("xpay returned (abnormal)", p.id, JSON.stringify({ status: r?.status, failed_parts: r?.failed_parts, parts: r?.parts, has_preimage: !!(r?.payment_preimage ?? r?.preimage), amount_sent_msat: r?.amount_sent_msat, amount_msat: r?.amount_msat, })); } if (r.failed_parts) { // xpay didn't throw but reported failed parts. The old code path skipped // finalize and left the debit stranded. Verify with listpays and act. warn("xpay returned failed_parts without throwing", p.id, r.failed_parts); 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) { warn("xpay failed_parts but listpays complete — finalizing", p.id); try { p = 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 { outcome = "pending-after-failed_parts"; // leave for check() loop } } catch (verifyErr: any) { warn("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:", e?.message); try { const { pays } = await ln.listpays(pr); warn("listpays after xpay-threw", p.id, JSON.stringify(pays?.map((x) => ({ status: x.status, amount_sent_msat: x.amount_sent_msat })) ?? [])); 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 { p = 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) { warn("listpays verification failed", verifyErr?.message ?? String(verifyErr)); outcome = "verify-failed-after-xpay-throw"; } 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 s() and then thrown on a later line (e.g. the tbRefund fee // refund), but the payment itself is recorded as complete. Only treat as a // real leak if ref is genuinely missing from the db record. Pending-* outcomes // are fine — check() will reconcile. finalize-threw-* with no ref is the // joho33-style stuck state we want zero of; grep "LEAKED DEBIT" in app logs. 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)) { 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; }; export const sendInternal = async ({ amount, invoice = undefined, memo = undefined, recipient, sender, }) => { if (!invoice) invoice = await generate({ invoice: { amount, type: "lightning" }, user: recipient, }); const { hash } = invoice; const p = await debit({ hash, amount, memo, user: sender }); await credit({ hash, amount, memo, ref: sender.id }); if (invoice.memo?.includes("9734")) { const { invoices } = await ln.listinvoices({ invstring: hash }); const inv = invoices[0]; inv.payment_preimage = p.id; inv.paid_at = Math.floor(Date.now() / 1000); handleZap(inv, sender.pubkey).catch(console.log); } return p; }; const getAddressType = async (a) => { try { await bc.getAddressInfo(a); return PaymentType.bitcoin; } catch (e) { try { await lq.getAddressInfo(a); return PaymentType.liquid; } 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"); } } }; const buildNonCustodial = async ({ aid, amount, address, feeRate, subtract, user: _user }) => { const account = await g(`account:${aid}`); if (!account?.pubkey) fail("account missing pubkey"); amount = Number.parseInt(amount); if (amount < 0) fail("invalid amount"); const fees: any = await fetch(`${api[PaymentType.bitcoin]}/fees/recommended`).then((r) => r.json(), ); fees.hourFee = fees.halfHourFee; fees.halfHourFee = fees.fastestFee; fees.fastestFee = Math.max(Math.ceil(fees.fastestFee * 1.5), 4); if (!feeRate) feeRate = fees.halfHourFee; const nextIndex = account.nextIndex || 0; // Derive all used external + internal addresses and fetch UTXOs const externalAddrs = deriveAddresses(account.pubkey, account.fingerprint, nextIndex + 1, false); const internalAddrs = deriveAddresses(account.pubkey, account.fingerprint, nextIndex + 1, true); const allAddrs = [...externalAddrs, ...internalAddrs]; const rawUtxos = await getAddressUtxos(allAddrs); if (!rawUtxos.length) fail("no UTXOs available"); // Build address-to-path lookup const addrToPath = {}; for (let i = 0; i <= nextIndex; i++) { const { address: extAddr } = deriveAddress(account.pubkey, account.fingerprint, i, false); addrToPath[extAddr] = `m/0/${i}`; const { address: intAddr } = deriveAddress(account.pubkey, account.fingerprint, i, true); addrToPath[intAddr] = `m/1/${i}`; } // Convert esplora UTXOs to selectUTXO input format const keyVersions = account.pubkey.startsWith("tpub") ? hdVersions : undefined; const accountKey = HDKey.fromExtendedKey(account.pubkey, keyVersions); const utxoInputs = rawUtxos.map((u) => { const path = addrToPath[u.address]; const parts = path.split("/").slice(-2); const child = accountKey .deriveChild(Number.parseInt(parts[0])) .deriveChild(Number.parseInt(parts[1])); const payment = p2wpkh(child.publicKey, btcNetwork); return { txid: u.txid, index: u.vout, witnessUtxo: { amount: BigInt(u.value), script: payment.script, }, }; }); const balance = await getBalance(aid); let ourfee = 0; // Non-custodial accounts don't pay platform fee const outputs = [{ address, amount: BigInt(amount) }]; // Derive a change address (next internal address) const { address: changeAddress } = deriveAddress( account.pubkey, account.fingerprint, nextIndex, true, ); let selected = selectUTXO(utxoInputs, outputs, "default", { changeAddress, feePerByte: BigInt(Math.ceil(feeRate)), network: btcNetwork, createTx: true, }); if (!selected) { subtract = true; if (amount <= dust) { fail(`insufficient funds ⚡️${balance} of ⚡️${amount + dust}`); } // Try with subtracted fee — send max const maxOutputs = [{ address, amount: BigInt(amount) }]; selected = selectUTXO(utxoInputs, maxOutputs, "all", { changeAddress, feePerByte: BigInt(Math.ceil(feeRate)), network: btcNetwork, createTx: true, }); if (!selected) fail("insufficient funds"); } const fee = Number(selected.fee); // Build input metadata for client signing const inputs = selected.inputs.map((input) => { const inputTxid = typeof input.txid === "string" ? input.txid : Buffer.from(input.txid).toString("hex"); const utxo = rawUtxos.find((u) => u.txid === inputTxid && u.vout === input.index); const path = utxo ? addrToPath[utxo.address] : undefined; return { witnessUtxo: { amount: Number(input.witnessUtxo.amount), script: Buffer.from(input.witnessUtxo.script).toString("hex"), }, path, }; }); const hex = Buffer.from(selected.tx.toPSBT()).toString("hex"); return { feeRate, ourfee, fee, fees, hex, inputs, subtract }; }; export const build = async ({ aid, amount, address, feeRate, subtract, user }) => { const type = await getAddressType(address); if (!aid) aid = user.id; // Non-custodial bitcoin account — use esplora if (type === PaymentType.bitcoin) { const account = await g(`account:${aid}`); if (account?.pubkey) { return buildNonCustodial({ aid, amount, address, feeRate, subtract, user }); } } const node = rpc(config[type]); const isBitcoin = type === PaymentType.bitcoin; const p2aAddress = isBitcoin ? getP2AAddress() : null; amount = Number.parseInt(amount); if (amount < 0) fail("invalid amount"); const fees: any = type === PaymentType.liquid ? { fastestFee: 0.1, halfHourFee: 0.1, hourFee: 0.1 } : await fetch(`${api[type]}/fees/recommended`).then((r) => r.json()); const rawFastestFee = fees.fastestFee; if (isBitcoin) { fees.hourFee = fees.halfHourFee; fees.halfHourFee = fees.fastestFee; fees.fastestFee = Math.max(Math.ceil(fees.fastestFee * 1.5), 4); } if (!feeRate) { feeRate = fees.halfHourFee; } if (isBitcoin) { if (feeRate < 0.1) fail("fee rate too low"); } else { if (feeRate < fees.hourFee) fail("fee rate too low"); } const replaceable = false; let outs: any[] = [{ [address]: btc(amount) }]; if (p2aAddress) outs.push({ [p2aAddress]: btc(P2A_VALUE) }); if (type === PaymentType.liquid) outs = outs.map((o) => ({ ...o, asset: config.liquid.btc })); let raw = await node.createRawTransaction([], outs, 0, replaceable); if (isBitcoin) raw = "03000000" + raw.substring(8); let fee = 0; let tx; let bumpReserve = 0; let parentVsize = 0; // Piggyback on an unconfirmed parent via CPFP if one exists if (isBitcoin) { try { let ourfee = Math.round(amount * config.fee[type]); const creditBal = await getCredit(aid, type); const covered = Math.min(creditBal, ourfee); ourfee -= covered; const cpfp = await buildCpfpSend({ address, amount: amount - ourfee, feeRate, fees }); return { ...cpfp, feeRate, ourfee, fees, inputs: [], subtract, signed: true }; } catch {} } try { tx = await node.fundRawTransaction(raw, { fee_rate: feeRate, replaceable, subtractFeeFromOutputs: [], }); fee = sats(tx.fee); if (isBitcoin) { const decoded = await node.decodeRawTransaction(tx.hex); parentVsize = decoded.vsize; bumpReserve = calculateBumpReserve(feeRate, fees.fastestFee, parentVsize); } } catch (e) { if (e.message.startsWith("Insufficient")) subtract = true; else throw e; } const balance = await getBalance(aid); let ourfee = Math.round(amount * config.fee[type]); const creditBal = await getCredit(aid, type); const covered = Math.min(creditBal, ourfee); ourfee -= covered; // Full balance withdrawal — skip reserve and P2A, nothing to refund to const fullWithdrawal = subtract || amount + fee + ourfee + bumpReserve > balance; if (fullWithdrawal) { subtract = true; bumpReserve = 0; feeRate = Math.max(rawFastestFee, 1); if (amount <= fee + ourfee + dust) { fail(`insufficient funds ⚡️${balance} of ⚡️${amount + fee + ourfee + dust}`); } outs = [{ [address]: btc(amount - ourfee) }]; raw = await node.createRawTransaction([], outs, 0, replaceable); if (isBitcoin) raw = "03000000" + raw.substring(8); tx = await node.fundRawTransaction(raw, { fee_rate: feeRate, replaceable, subtractFeeFromOutputs: [0], }); fee = sats(tx.fee); if (isBitcoin) { const decoded = await node.decodeRawTransaction(tx.hex); parentVsize = decoded.vsize; } } const inputs = []; const { vin } = await node.decodeRawTransaction(tx.hex); for (const { txid, vout } of vin) { const rawTx = await node.getRawTransaction(txid); const tx = await node.decodeRawTransaction(rawTx); const prevOutput = tx.vout[vout]; const { address } = prevOutput.scriptPubKey; const { hdkeypath: path } = await node.getAddressInfo(address); const witnessUtxo = { amount: Math.round(prevOutput.value * SATS), script: prevOutput.scriptPubKey.hex, }; inputs.push({ witnessUtxo, path }); } return { feeRate, ourfee, fee, fees, hex: tx.hex, inputs, subtract, bumpReserve, parentVsize }; }; export const checkOutgoingConfirmations = async () => { try { const paymentIds = await db.sMembers("outgoing:unconfirmed"); for (const pid of paymentIds) { try { const p = await gf(`payment:${pid}`); if (!p) { await db.sRem("outgoing:unconfirmed", pid); continue; } if (p.confirmed) { await db.sRem("outgoing:unconfirmed", pid); continue; } const txInfo = await bc.getRawTransaction(p.hash, true).catch(() => null); if (!txInfo || !txInfo.confirmations || txInfo.confirmations < 1) continue; const refundAmount = (p.bumpReserve || 0) - (p.bumpedFee || 0); if (refundAmount > 0) { await tbRefund(p.uid, refundAmount); } p.confirmed = true; p.bumpReserve = 0; await s(`payment:${p.id}`, p); await db.sRem("outgoing:unconfirmed", pid); emit(p.uid, "payment", p); l("outgoing confirmed", p.id, "refunded", refundAmount); } catch (e) { err("problem checking outgoing confirmation", pid, e.message); } } } catch (e) { err("checkOutgoingConfirmations failed", e.message); } }; export const catchUp = async () => { try { // Check watched addresses for any missed bitcoin transactions const watched = await db.sMembers("watching"); for (const address of watched as any) { try { const txs = await getAddressTxs(address as string); for (const tx of txs as any) { await processWatchedTx(tx); } } catch (e) { err("catchUp address check failed", address, e.message); } } // Check custodial outgoing bitcoin tx confirmations await checkOutgoingConfirmations(); // Non-custodial accounts: check pending outgoing payments const inflightAccounts = await db.keys("inflight:*"); for (const key of inflightAccounts) { const keyType = await db.type(key); if (keyType !== "set") continue; const paymentIds = await db.sMembers(key); for (const pid of paymentIds) { try { const p = await gf(`payment:${pid}`); if (!p || p.confirmed) { await db.sRem(key, pid); continue; } const status = await getTxStatus(p.hash) as any; if (status.confirmed) { p.confirmed = true; await s(`payment:${p.id}`, p); await db.sRem(key, pid); emit(p.uid, "payment", p); } } catch (e) { err("problem checking inflight payment", e.message); } } } } catch (e) { err("problem catching up", e.message); } }; export const syncBitcoinVault = async (account, user) => { const { id, uid, pubkey, fingerprint } = account; let nextIndex = account.nextIndex || 0; const lastUsed = await findLastUsedIndex(pubkey, fingerprint); if (lastUsed > nextIndex) { nextIndex = lastUsed; account.nextIndex = nextIndex; await s(`account:${id}`, account); } const count = nextIndex + 1; const externalAddrs = deriveAddresses(pubkey, fingerprint, count, false); const internalAddrs = deriveAddresses(pubkey, fingerprint, count, true); const allAddrs = [...externalAddrs, ...internalAddrs]; const addressSet = new Set(allAddrs); const changeSet = new Set(internalAddrs); const utxos = await getAddressUtxos(allAddrs); const confirmedUtxos = utxos.filter((u) => u.status.confirmed && u.value >= 300); const pendingUtxos = utxos.filter((u) => !u.status.confirmed && u.value >= 300); const confirmedTotal = confirmedUtxos.reduce((sum, u) => sum + u.value, 0); const pendingTotal = pendingUtxos.reduce((sum, u) => sum + u.value, 0); const { rate, currency } = await getUserRate(user); const newPayments = []; const externalSet = new Set(externalAddrs); // Scan pending UTXOs for unconfirmed incoming (external only) for (const u of pendingUtxos) { if (!externalSet.has(u.address)) continue; const ref = `${u.txid}:${u.vout}`; const existing = await getPayment(ref); if (existing?.aid === id) continue; if (existing) { existing.aid = id; await db .multi() .set(`payment:${existing.id}`, JSON.stringify(existing)) .lPush(`${id}:payments`, existing.id) .exec(); continue; } const p = { id: v4(), aid: id, amount: u.value, fee: 0, hash: u.address, confirmed: false, rate, currency, type: PaymentType.bitcoin, uid, ref, created: Date.now(), }; await db .multi() .set(`payment:${ref}`, p.id) .set(`payment:${p.id}`, JSON.stringify(p)) .lPush(`${id}:payments`, p.id) .set(`${id}:payments:last`, p.created) .exec(); newPayments.push(p); } // Process all transactions from history (incoming + outgoing) const txsById = new Map(); for (const address of allAddrs) { const txs = await getAddressTxs(address); for (const tx of txs as any) { txsById.set(tx.txid, tx); } } for (const tx of txsById.values()) { if (!tx.status?.confirmed) continue; let inputsFromUs = 0; let outputsToUs = 0; let outputsToExternal = 0; let totalIn = 0; let totalOut = 0; for (const vin of tx.vin || []) { const prev = vin.prevout; if (!prev) continue; const value = Number.parseInt(prev.value) || 0; totalIn += value; if (addressSet.has(prev.scriptpubkey_address)) inputsFromUs += value; } for (const vout of tx.vout || []) { const value = Number.parseInt(vout.value) || 0; totalOut += value; if (addressSet.has(vout.scriptpubkey_address)) outputsToUs += value; if (externalSet.has(vout.scriptpubkey_address)) outputsToExternal += value; } const created = tx.status?.block_time ? tx.status.block_time * 1000 : Date.now(); if (inputsFromUs) { // Outgoing transaction const net = outputsToUs - inputsFromUs; if (net >= 0) continue; const amount = totalOut - outputsToUs; if (amount <= 0) continue; const existing = await getPayment(tx.txid); if (existing?.aid === id) continue; if (existing) { existing.aid = id; await db .multi() .set(`payment:${existing.id}`, JSON.stringify(existing)) .lPush(`${id}:payments`, existing.id) .exec(); continue; } const fee = Math.max(0, totalIn - totalOut); const p = { id: v4(), aid: id, amount: -amount, fee, hash: tx.txid, confirmed: true, rate, currency, type: PaymentType.bitcoin, uid, created, }; await db .multi() .set(`payment:${tx.txid}`, p.id) .set(`payment:${p.id}`, JSON.stringify(p)) .lPush(`${id}:payments`, p.id) .set(`${id}:payments:last`, p.created) .exec(); } else if (outputsToExternal) { // Incoming transaction (to external addresses, not from us) for (const vout of tx.vout || []) { const value = Number.parseInt(vout.value) || 0; if (value < 300) continue; if (!externalSet.has(vout.scriptpubkey_address)) continue; const voutIndex = tx.vout.indexOf(vout); const ref = `${tx.txid}:${voutIndex}`; const existing = await getPayment(ref); if (existing?.aid === id) { if (!existing.confirmed) { existing.confirmed = true; await s(`payment:${existing.id}`, existing); } continue; } if (existing) { existing.aid = id; existing.confirmed = true; await db .multi() .set(`payment:${existing.id}`, JSON.stringify(existing)) .lPush(`${id}:payments`, existing.id) .exec(); continue; } const p = { id: v4(), aid: id, amount: value, fee: 0, hash: vout.scriptpubkey_address, confirmed: true, rate, currency, type: PaymentType.bitcoin, uid, ref, created, }; await db .multi() .set(`payment:${ref}`, p.id) .set(`payment:${p.id}`, JSON.stringify(p)) .lPush(`${id}:payments`, p.id) .set(`${id}:payments:last`, p.created) .exec(); newPayments.push(p); } } } return { confirmedTotal, pendingTotal, newPayments }; }; export const importAccountHistory = async (account) => { try { const user = await getUser(account.uid); const { confirmedTotal } = await syncBitcoinVault(account, user); await tbSetBalance(account.id, confirmedTotal); await tbSetPending(account.id, 0); account.importedAt = account.importedAt || Date.now(); await s(`account:${account.id}`, account); } catch (e) { console.log(e); warn("problem importing account history", e.message, account); } }; export const check = async () => { if (process.env.URL.includes("dev")) return; try { const payments = await db.sMembers("pending"); for (const pr of payments) { if (!pr.startsWith("ln")) { await db.sRem("pending", pr); continue; } const p = await getPayment(pr); if (!p || Date.now() - p.created < 10000) continue; const { pays } = await ln.listpays(pr); const failed = !pays.length || pays.every((p) => p.status === "failed"); const completed = pays.find((p) => p.status === "complete"); try { if (completed) await finalize(completed, p); 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); }; const finalize = async (r, p) => { let { preimage } = r; if (!preimage) preimage = r.payment_preimage; 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; // Compute the actual fee xpay paid (sent − invoice amount, both msat). // Robust against zero-amount invoices (decoded amount_msat is undefined, fall // back to invoice_amount_msat then 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 the tbRefund balance write and // leave finalize in a partial state). const { amount_msat, invoice_amount_msat } = await ln.decode(p.hash); const invMsat = Number(amount_msat ?? invoice_amount_msat ?? r.amount_msat); const sentMsat = Number(r.amount_sent_msat); const computedFee = Math.round((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=", amount_msat ?? invoice_amount_msat ?? r.amount_msat); } p.fee = Number.isFinite(computedFee) ? computedFee : maxfee; p.ref = preimage; if (!(await g(`payment:${p.id}`)).ref) { await s(`payment:${p.id}`, p); const refund = maxfee - p.fee; if (Number.isFinite(refund) && Number.isInteger(refund)) { l("refunding fee", maxfee, p.fee, refund, p.ref); await tbRefund(p.uid, refund); } else { warn("finalize: skipping fee refund (non-integer delta)", p.id, "maxfee=", maxfee, "p.fee=", p.fee, "refund=", refund); } } return p; }; export const reverse = async (p) => { await sleep(Math.floor(Math.random() * (1500 - 500 + 1)) + 500); 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; l("reversing", p.id, p.amount, p.fee, total, ourfee, credit); // Check payment still exists before reversing const exists = await db.exists(`payment:${p.id}`); if (!exists) throw new Error("Payment has already been reversed"); // TB: restore balance + credits await tbReverse(p.uid, total, credit); // Redis: clean up payment records await db .multi() .del(`payment:${p.id}`) .sRem("pending", p.hash) .del(`payment:${p.hash}`) .lRem(`${p.uid}:payments`, 0, p.id) .lRem("payments", 0, p.id) .exec(); warn("reversed", p.id); }; const freezeCheck = async () => { try { const funds = await ln.listfunds(); const lnbalance = Math.round(funds.channels.reduce((a, b) => a + b.our_amount_msat, 0) / 1000); const lnthreshold = await g("lightning:threshold"); await s("lightning:limit", Math.max(lnbalance - lnthreshold, 0)); 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)); } catch {} try { const bcbalance = Math.round((await bc.getBalance()) * SATS); const bcthreshold = await g("bitcoin:threshold"); await s("bitcoin:limit", Math.max(bcbalance - bcthreshold, 0)); } catch {} try { const { bitcoin } = await lq.getBalance(); const lqbalance = Math.round(bitcoin * SATS); const lqthreshold = await g("liquid:threshold"); await s("liquid:limit", Math.max(lqbalance - lqthreshold, 0)); } catch {} const arkBalance = getArkBalance(); if (arkBalance) { const arkthreshold = (await g("ark:threshold")) || 0; await s("ark:available", arkBalance.available); await s("ark:limit", Math.max(arkBalance.available - arkthreshold, 0)); } setTimeout(freezeCheck, 10000); }; setTimeout(freezeCheck, 10_000);