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 } from "$lib/db"; import { l, err, warn } from "$lib/logging"; import { f, btc, fail, fiat, getInvoice, sleep, 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 { bech32 } from "bech32"; import rpc from "$lib/rpc"; import lq_ from "$lib/liquid"; import bc_ from "$lib/bitcoin"; import ln from "$lib/ln"; let bc = bc_ as any; let lq = lq_ as any; 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 = undefined, user, type = types.internal, rate = undefined, }) => { 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 } = 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: any = [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, hex: undefined, 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: amount - tip, uid, rate, currency, memo, ref, tip, type, confirmed: true, created: Date.now(), items: undefined, }; 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(to, 20000).words), ).toString(); } let maxfee = getMaxFee(amount); if (lnurl) { amount -= maxfee; let { callback } = (await got(lnurl).json()) as any; ({ pr } = (await got(`${callback}?amount=${amount * 1000}`).json()) as any); } 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 = undefined, }) => { 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 } = 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, wallet = undefined) => { if (type === types.bitcoin) { return bc; } else if (type === types.liquid) { return lq; } else if (wallet) { return rpc({ ...config.bitcoin, wallet }); } 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: any = 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); };