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.js | |
| parent | 00e43ddc0bf3d6dd719fc9295d156567fa2fecfb (diff) | |
| download | coinos-server-fe62cadd6fb4a7e49f7d59401fc74ae0411e32a7.tar.gz coinos-server-fe62cadd6fb4a7e49f7d59401fc74ae0411e32a7.zip | |
typescript
Diffstat (limited to 'lib/payments.js')
| -rw-r--r-- | lib/payments.js | 682 |
1 files changed, 0 insertions, 682 deletions
diff --git a/lib/payments.js b/lib/payments.js deleted file mode 100644 index 92e17839..00000000 --- a/lib/payments.js +++ /dev/null @@ -1,682 +0,0 @@ -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); -}; |
