diff options
| author | Adam Soltys <[email protected]> | 2024-08-26 21:17:43 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2024-08-26 21:17:43 -0700 |
| commit | fe62cadd6fb4a7e49f7d59401fc74ae0411e32a7 (patch) | |
| tree | aa8f34e1692bd2d638a2c996eeadb58671d07aea /lib/payments.ts | |
| parent | 00e43ddc0bf3d6dd719fc9295d156567fa2fecfb (diff) | |
| download | coinos-server-fe62cadd6fb4a7e49f7d59401fc74ae0411e32a7.tar.gz coinos-server-fe62cadd6fb4a7e49f7d59401fc74ae0411e32a7.zip | |
typescript
Diffstat (limited to 'lib/payments.ts')
| -rw-r--r-- | lib/payments.ts | 682 |
1 files changed, 682 insertions, 0 deletions
diff --git a/lib/payments.ts b/lib/payments.ts new file mode 100644 index 00000000..92e17839 --- /dev/null +++ b/lib/payments.ts @@ -0,0 +1,682 @@ +import locales from "$lib/locales/index"; +import config from "$config"; +import { emit } from "$lib/sockets"; +import { v4 } from "uuid"; +import { notify } from "$lib/notifications"; +import { db, g, s, t } from "$lib/db"; +import { l, err, warn } from "$lib/logging"; +import { + f, + btc, + fail, + fiat, + getInvoice, + getUser, + sleep, + wait, + SATS, + sats, + formatReceipt, +} from "$lib/utils"; +import { callWebhook } from "$lib/webhooks"; +import got from "got"; +import { mqtt1, mqtt2 } from "$lib/mqtt"; +import { mail, templates } from "$lib/mail"; +import api from "$lib/api"; + +import lq from "$lib/liquid"; +import bc from "$lib/bitcoin"; +import ln from "$lib/ln"; + +let { URL } = process.env; + +export let types = { + internal: "internal", + bitcoin: "bitcoin", + lightning: "lightning", + fund: "fund", + liquid: "liquid", + ecash: "ecash", +}; + +export let debit = async ({ + hash, + amount, + fee = 0, + memo, + user, + type = types.internal, + rate, +}) => { + amount = parseInt(amount); + if (type !== types.internal && (await g("freeze"))) + fail("Problem sending payment"); + // if (!user.unlimited && amount > 1000000) + // fail(`⚡️${amount} exceeds max withdrawal of ⚡️1,000,000`); + let ref; + let { id: uid, currency, username } = user; + + let rates = await g('rates'); + if (!rate) rate = rates[currency]; + + let invoice = await getInvoice(hash); + let iid; + + if (invoice) { + if (invoice.received >= amount) fail("Invoice already paid"); + ({ id: iid } = invoice); + + ref = invoice.uid; + + let 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); + } + } + + let tip = parseInt(invoice?.tip) || null; + if (tip < 0) fail("Invalid tip"); + + if (!amount || amount < 0) fail("Amount must be greater than zero"); + + let ourfee = [types.bitcoin, types.liquid, types.lightning].includes(type) + ? Math.round((amount + fee + tip) * config.fee) + : 0; + + ourfee = await db.debit( + `balance:${uid}`, + `credit:${type}:${uid}`, + amount || 0, + tip || 0, + fee || 0, + ourfee || 0, + ); + + if (ourfee.err) fail(ourfee.err); + + let id = v4(); + let p = { + id, + amount: -amount, + fee, + hash, + ourfee, + memo, + iid, + uid, + confirmed: ![types.bitcoin, types.liquid].includes(type), + rate, + currency, + type, + ref, + tip, + created: Date.now(), + }; + + await s(`payment:${hash}`, id); + await s(`payment:${id}`, p); + await db.lPush(`${uid}:payments`, id); + + l(user.username, "sent", type, amount); + //emit(user.id, "payment", p); + + return p; +}; + +export let credit = async (hash, amount, memo, ref, type = types.internal) => { + amount = parseInt(amount) || 0; + + let inv = await getInvoice(hash); + if (!inv) { + await db.sAdd("missing", ref.split(":")[0]); + return; + } + + let { tip } = inv; + tip = 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 === types.internal) amount += tip; + + let user = await g(`user:${inv.uid}`); + let { id: uid, currency, username } = user; + + let rates = await g('rates'); + let rate = rates[currency]; + + if (!rate) await sleep(1000); + rate = rates[currency]; + + let 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); + } + + let id = v4(); + let p = { + id, + iid: inv.id, + hash, + amount: parseInt(amount - tip), + uid, + rate, + currency, + memo, + ref, + tip, + type, + confirmed: true, + created: Date.now(), + }; + + if ([types.bitcoin, types.liquid].includes(type)) inv.pending += amount; + else { + inv.received += amount; + inv.preimage = ref; + } + + let balanceKey = "balance"; + if ([types.bitcoin, types.liquid].includes(type)) { + let [txid, vout] = ref.split(":").slice(-2); + p.confirmed = false; + balanceKey = "pending"; + await s(`payment:${txid}:${vout}`, id); + } else { + await s(`payment:${hash}`, id); + } + + let m = await db.multi(); + + if ([types.bitcoin, types.liquid, types.lightning].includes(type)) + m.incrBy(`credit:${type}:${uid}`, Math.round(amount * config.fee)); + + m.set(`invoice:${inv.id}`, JSON.stringify(inv)) + .set(`payment:${p.id}`, JSON.stringify(p)) + .lPush(`${uid}:payments`, p.id) + .incrBy(`${balanceKey}:${uid}`, amount) + .exec(); + + emit(uid, "payment", p); + notify(user, `Received ${amount} sats`); + callWebhook(inv, p); + l(username, "received", type, amount); + + let items; + if (inv.items && inv.items.length) { + formatReceipt(inv.items, inv.currency); + p.items = inv.items; + } + + if (config.mqtt1) { + mqtt1.publish( + username, + `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}:${p.memo}:${items}`, + ); + } + + if (config.mqtt2) { + mqtt2.publish( + username, + `pay:${p.amount}:${p.tip}:${p.rate}:${p.created}:${p.id}:${p.memo}:${items}`, + ); + } + + try { + await completePayment(p, user); + } catch (e) { + console.log(e); + } + + return p; +}; + +let fmt = (sats) => + "⚡️" + + new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(sats); + +export let completePayment = async (p, user) => { + let { id, autowithdraw, threshold, reserve, destination, username } = user; + let link = (id) => `${URL}/payment/${id}`; + if (p.confirmed) { + let withdrawal; + if (autowithdraw) { + try { + let to = destination.trim(); + let balance = await g(`balance:${id}`); + let amount = balance - reserve; + if (balance > threshold) { + l("initiating autowithdrawal", amount, to, balance, threshold); + let w = await pay({ amount, to, user }); + withdrawal = { + amount: fmt(-w.amount), + link: link(w.id), + }; + } + } catch (e) { + withdrawal = { failed: true }; + warn(username, "autowithdraw failed", e.message); + } + } + + let t = locales[user.language || "en"]; + if (user.verified && user.notify) { + mail(user, t.paymentReceived, templates.paymentReceived, { + ...t, + username, + payment: { + amount: fmt(p.amount), + link: link(p.id), + tip: p.tip ? fmt(p.tip) : undefined, + fiat: f(fiat(p.amount, p.rate), p.currency), + fiatTip: p.tip ? f(fiat(p.tip, p.rate), p.currency) : undefined, + memo: p.memo, + items: + p.items && + p.items.map((i) => { + return { + quantity: i.quantity, + name: i.name, + total: i.quantity * i.price, + totalFiat: f(i.quantity * i.price, p.currency), + }; + }), + }, + withdrawal, + }); + } + } +}; + +let pay = async ({ amount, to, user }) => { + amount = parseInt(amount) || 0; + let lnurl, pr; + if (to.includes("@") && to.includes(".")) { + let [name, domain] = to.split("@"); + lnurl = `https://${domain}/.well-known/lnurlp/${name}`; + } else if (to.startsWith("lnurl")) { + lnurl = Buffer.from( + bech32.fromWords(bech32.decode(text, 20000).words), + ).toString(); + } + + let maxfee = getMaxFee(amount); + if (lnurl) { + amount -= maxfee; + let { callback } = await got(lnurl).json(); + ({ pr } = await got(`${callback}?amount=${amount * 1000}`).json()); + } else if (to.startsWith("ln")) { + amount -= maxfee; + pr = to; + } + + return pr + ? await sendLightning({ user, pr, amount, maxfee }) + : await sendOnchain({ amount, address: to, user, subtract: true }); +}; + +export let decode = async (hex) => { + let type, tx; + try { + tx = await bc.decodeRawTransaction(hex); + type = types.bitcoin; + } catch (e) { + try { + tx = await lq.decodeRawTransaction(hex); + type = types.liquid; + } catch (e) { + err("invalid hex", hex); + fail("unrecognized tx"); + } + } + + return { tx, type }; +}; + +export let sendOnchain = async (params) => { + let { hex, rate, user } = params; + if (!hex) ({ hex } = await build(params)); + + let { tx, type } = await decode(hex); + let node = getNode(type); + let { txid } = tx; + + try { + if (inflight[txid]) fail("payment in flight"); + inflight[txid] = true; + + if (config[type].walletpass) + await node.walletPassphrase(config[type].walletpass, 300); + + let { hex: signed } = await node.signRawTransactionWithWallet( + type === types.liquid ? await node.blindRawTransaction(hex) : hex, + ); + + ({ txid } = await node.decodeRawTransaction(signed)); + + let r = await node.testMempoolAccept([signed]); + if (!r[0].allowed) fail("transaction rejected"); + + let total = 0; + let fee = 0; + let change = 0; + + if (type === types.liquid) { + for (let { + 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 (let { txid, vout } of tx.vin) { + let hex = await node.getRawTransaction(txid); + let tx = await node.decodeRawTransaction(hex); + totalIn += sats(tx.vout[vout].value); + } + + for (let { + scriptPubKey: { address }, + value, + } of tx.vout) { + total += sats(value); + if (await g(`invoice:${address}`)) + fail("Cannot send to internal address"); + + if ((await node.getAddressInfo(address)).ismine) change += sats(value); + } + + fee = totalIn - total; + } + + let amount = total - change; + + let p = await debit({ + hash: txid, + amount, + fee, + rate, + user, + type, + }); + + p.hex = signed; + await s(`payment:${p.id}`, p); + + await node.sendRawTransaction(signed); + + delete inflight[txid]; + return p; + } catch (e) { + delete inflight[txid]; + throw e; + } +}; + +let getMaxFee = (n) => + Math.round( + n < 100 + ? n * 5 + : n < 1000 + ? n + : n < 10000 + ? n * 0.5 + : n < 100000 + ? n * 0.1 + : n < 1000000 + ? n * 0.05 + : n * 0.01, + ); + +let checking = {}; +let inflight = {}; +export let sendLightning = async ({ user, pr, amount, maxfee, memo }) => { + try { + if (inflight[user.id]) fail("payment in flight"); + inflight[user.id] = true; + + let p; + let hash = pr; + + if (typeof amount !== "undefined") { + amount = parseInt(amount); + if (amount < 0 || amount > SATS || isNaN(amount)) fail("Invalid amount"); + } + + let total = amount; + let { amount_msat, payment_hash } = await ln.decode(pr); + if (amount_msat) total = Math.round(amount_msat / 1000); + + maxfee = parseInt(maxfee) || getMaxFee(total); + + if (maxfee < 0) fail("Max fee cannot be negative"); + + let invoice = await getInvoice(hash); + + if (invoice) { + if (invoice.uid === user.id) fail("Cannot send to self"); + hash = pr; + } else { + let r; + + let { 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: total, + fee: maxfee, + memo, + user, + type: types.lightning, + }); + + let check = async () => { + if (checking[pr]) return; + checking[pr] = true; + + try { + let { pays } = await ln.listpays(pr); + + let recordExists = !!(await g(`payment:${pr}`)); + let paymentFailed = + !pays.length || pays.every((p) => p.status === "failed"); + + if (recordExists && paymentFailed) { + await db.del(`payment:${pr}`); + await db.del(`payment:${p.id}`); + + let ourfee = p.ourfee || 0; + let credit = Math.round(total * config.fee) - ourfee; + await db.incrBy(`balance:${p.uid}`, total + maxfee + ourfee); + + await db.incrBy(`credit:${types.lightning}:${p.uid}`, credit); + await db.lRem(`${p.uid}:payments`, 1, p.id); + + clearInterval(interval); + } + } catch (e) { + err("Failed to reverse payment", r); + } + + checking[pr] = false; + }; + + let interval = setInterval(check, 5000); + + try { + l("paying lightning invoice", pr.substr(-8), total, amount, maxfee); + + r = await ln.pay({ + bolt11: pr.replace(/\s/g, "").toLowerCase(), + amount_msat: amount_msat ? undefined : amount * 1000, + maxfee: maxfee * 1000, + retry_for: 5, + }); + + if (!(r.status === "complete" && r.payment_preimage)) + fail("Payment did not complete"); + + l("payment completed", p.id, r.payment_preimage); + + p.amount = -total; + p.fee = Math.round((r.amount_sent_msat - r.amount_msat) / 1000); + p.ref = r.payment_preimage; + + await s(`payment:${p.id}`, p); + + l("refunding fee", maxfee, p.fee, maxfee - p.fee, p.ref); + await db.incrBy(`balance:${p.uid}`, maxfee - p.fee); + } catch (e) { + await check(); + throw e; + } + + clearInterval(interval); + } + + delete inflight[user.id]; + return p; + } catch (e) { + delete inflight[user.id]; + throw e; + } +}; + +export let getNode = (type) => { + if (type === types.bitcoin) return bc; + else if (type === types.liquid) return lq; + else fail("unrecognized transaction type"); +}; + +let getAddressType = async (a) => { + try { + await bc.getAddressInfo(a); + return types.bitcoin; + } catch (e) { + try { + await lq.getAddressInfo(a); + return types.liquid; + } catch (e) { + fail("unrecognized address"); + } + } +}; + +export let build = async ({ amount, address, feeRate, user, subtract }) => { + let type = await getAddressType(address); + + let node = getNode(type); + amount = parseInt(amount); + if (amount < 0) fail("invalid amount"); + + let fees = await fetch(`${api[type]}/fees/recommended`).then((r) => r.json()); + + if (!feeRate) { + feeRate = fees.halfHourFee; + } + + if (feeRate < fees.economyFee) fail("fee rate too low"); + + let replaceable = false; + + let outs = [{ [address]: btc(amount) }]; + + if (type === types.liquid) + outs = outs.map((o) => ({ ...o, asset: config.liquid.btc })); + + let raw = await node.createRawTransaction([], outs, 0, replaceable); + + let tx = await node.fundRawTransaction(raw, { + fee_rate: feeRate, + replaceable, + subtractFeeFromOutputs: [], + }); + + let fee = sats(tx.fee); + let dust = 547; + + let balance = await g(`balance:${user.id}`); + let ourfee = Math.round(amount * config.fee); + let credit = await g(`credit:${type}:${user.id}`); + let covered = Math.min(credit, ourfee); + ourfee -= covered; + + if (subtract || amount + fee + ourfee > balance) { + 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); + + tx = await node.fundRawTransaction(raw, { + fee_rate: feeRate, + replaceable, + subtractFeeFromOutputs: [0], + }); + + fee = sats(tx.fee); + } + + return { feeRate, ourfee, fee, fees, hex: tx.hex }; +}; + +export let catchUp = async () => { + try { + let txns = []; + for (let [type, n] of Object.entries({ bitcoin: bc, liquid: lq })) { + txns.push( + ...(await n.listTransactions("*", 200)).filter((tx) => { + tx.type = type; + return tx.category === "receive" && tx.confirmations > 0; + }), + ); + } + + for (let { txid, type } of txns) { + try { + if (await db.zScore("seen", txid)) continue; + await got.post(`http://localhost:${process.env.PORT || 3119}/confirm`, { + json: { txid, wallet: config.bitcoin.wallet, type }, + }); + + await db.zAdd("seen", { score: Date.now(), value: txid }); + if ((await db.zCard("seen")) > 10000) + await db.zRemRangeByRank("seen", 0, 0); + } catch (e) { + err("problem confirming", e.message); + } + } + } catch (e) { + err("problem syncing", e.message); + } + + setTimeout(catchUp, 2000); +}; |
