aboutsummaryrefslogtreecommitdiffstats
path: root/lib/payments.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2024-08-26 21:17:43 -0700
committerAdam Soltys <[email protected]>2024-08-26 21:17:43 -0700
commitfe62cadd6fb4a7e49f7d59401fc74ae0411e32a7 (patch)
treeaa8f34e1692bd2d638a2c996eeadb58671d07aea /lib/payments.js
parent00e43ddc0bf3d6dd719fc9295d156567fa2fecfb (diff)
downloadcoinos-server-fe62cadd6fb4a7e49f7d59401fc74ae0411e32a7.tar.gz
coinos-server-fe62cadd6fb4a7e49f7d59401fc74ae0411e32a7.zip
typescript
Diffstat (limited to 'lib/payments.js')
-rw-r--r--lib/payments.js682
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);
-};