import { db, g, gf, s } from "$lib/db"; import { generate } from "$lib/invoices"; import ln from "$lib/ln"; import { err, l, warn } from "$lib/logging"; import { serverPubkey2 } from "$lib/nostr"; import { getFundBalance, tbFundCredit, tbFundDebit } from "$lib/tb"; import { SATS, bail, fail, getInvoice, getUser } from "$lib/utils"; import { bech32 } from "bech32"; import got from "got"; import { v4 } from "uuid"; import { PaymentType } from "$lib/types"; const { URL } = process.env; const host = URL.split("/").at(-1); const fiveMinutes = 1000 * 60 * 5; export default { async encode(c) { const address = c.req.query("address"); const [name, domain] = address.split("@"); const url = `https://${domain}/.well-known/lnurlp/${name.toLowerCase().replace(/\s/g, "")}`; try { const r = await got(url).json() as any; if (r.tag !== "payRequest") fail("not an ln address"); } catch (e) { const m = `failed to lookup lightning address ${address}`; warn(m); return bail(c, m); } const enc = bech32.encode("lnurl", bech32.toWords(Buffer.from(url)), 20000); return c.json(enc); }, async decode(c) { const text = c.req.query("text"); try { const url = Buffer.from(bech32.fromWords(bech32.decode(text, 20000).words)).toString(); const r = await got(url).json(); return c.json(r); } catch (e) { return bail(c, e.message); } }, async lnurlp(c) { const username = c.req.param("username"); const minSendable = c.req.query("minSendable") || 1000; const maxSendable = c.req.query("maxSendable") || 100000000000; try { const user = await getUser( username.replace("lightning:", "").replace(/\s/g, "").replace("=", "").toLowerCase(), ); if (!user) fail(`User ${username} not found`); const { id: uid } = user; const metadata = JSON.stringify([ ["text/plain", `Paying ${username}@${host}`], ["text/identifier", `${username}@${host}`], ]); const id = v4(); await s(`lnurl:${id}`, uid); return c.json({ allowsNostr: true, minSendable, maxSendable, metadata, nostrPubkey: serverPubkey2, commentAllowed: 512, callback: `${URL}/api/lnurl/${id}`, tag: "payRequest", }); } catch (e) { if (!e.message.includes("found")) warn("problem generating lnurlp request", username, e.message); return bail(c, e.message); } }, async lnurl(c) { const id = c.req.param("id"); const amount = c.req.query("amount"); const comment = c.req.query("comment"); const nostr = c.req.query("nostr"); try { const iid = await g(`lnurl:${id}:invoice`); const uid = await g(`lnurl:${id}`); const user = await getUser(uid); if (!user) fail("user not found"); let { username } = user; username = username.replace(/\s/g, "").toLowerCase(); let metadata = JSON.stringify([ ["text/plain", `Paying ${username}@${host}`], ["text/identifier", `${username}@${host}`], ]); if (nostr) { try { const event = JSON.parse(decodeURIComponent(nostr)); await s(`zap:${id}`, event); metadata = nostr; } catch (e) { err("problem handling zap", e.message); } } const invoice = iid ? await gf(`invoice:${iid}`) : await generate({ invoice: { amount: Math.round(amount / 1000), memo: metadata, type: PaymentType.lightning, }, user, }); if (comment) { invoice.memo = comment; await s(`invoice:${invoice.id}`, invoice); } return c.json({ pr: invoice.text, routes: [], verify: `${URL}/api/lnurl/verify/${invoice.id}`, }); } catch (e) { return bail(c, e.message); } }, async verify(c) { const id = c.req.param("id"); const inv = await getInvoice(id); if (!inv) return c.json({ status: "ERROR", reason: "Not found" }); const { hash, received, amount, preimage } = inv; const settled = received >= amount; return c.json({ pr: hash, status: "OK", settled, preimage: preimage || null }); }, async proxy(c) { const url = c.req.query("url"); if (!url) return bail(c, "url required"); try { const r = await got(url).json(); return c.json(r); } catch (e) { warn("lnurl proxy failed", url, e.message); return bail(c, e.message); } }, async lnurlw(c) { const fundId = c.req.param("fundId"); try { const balance = await getFundBalance(fundId); if (balance === null || balance <= 0) return c.json({ status: "ERROR", reason: "Fund not found or empty" }); const k1 = v4(); await s(`lnurlw:${k1}`, fundId); await db.expire(`lnurlw:${k1}`, 300); return c.json({ tag: "withdrawRequest", callback: `${URL}/api/lnurlw/${fundId}/callback`, k1, defaultDescription: `Withdraw from fund ${fundId}`, minWithdrawable: 1000, maxWithdrawable: balance * 1000, }); } catch (e) { warn("lnurlw request failed", fundId, e.message); return bail(c, e.message); } }, async lnurlwCallback(c) { const fundId = c.req.param("fundId"); const k1 = c.req.query("k1"); const pr = c.req.query("pr"); try { if (!k1 || !pr) fail("Missing k1 or pr"); const storedFundId = await g(`lnurlw:${k1}`); if (storedFundId !== fundId) return c.json({ status: "ERROR", reason: "Invalid or expired k1" }); await db.del(`lnurlw:${k1}`); const decoded = await ln.decode(pr); const amount = Math.round(decoded.amount_msat / 1000); if (amount <= 0) fail("Invalid invoice amount"); const balance = await getFundBalance(fundId); if (amount > balance) return c.json({ status: "ERROR", reason: `Insufficient funds: ${balance} < ${amount}` }); const result: any = await tbFundDebit(fundId, amount, "Insufficient funds"); if (result.err) return c.json({ status: "ERROR", reason: result.err }); l("lnurlw paying invoice from fund", fundId, amount); try { await ln.xpay({ invstring: pr.replace(/\s/g, "").toLowerCase(), maxfee: Math.max(5, Math.round(amount * 0.02)) * 1000, retry_for: 20, }); await db.lPush(`fund:${fundId}:payments`, `lnurlw:${pr.slice(-8)}:${amount}`); l("lnurlw paid from fund", fundId, amount); return c.json({ status: "OK" }); } catch (e) { warn("lnurlw payment failed, reversing fund debit", fundId, e.message); await tbFundCredit(fundId, amount); return c.json({ status: "ERROR", reason: "Payment failed" }); } } catch (e) { warn("lnurlw callback failed", fundId, e.message); return c.json({ status: "ERROR", reason: e.message }); } }, async pay(c) { const amount = c.req.param("amount"); const username = c.req.param("username"); try { const user = await getUser(username); if (!user) fail(`User ${username} not found`); const invoices = await db.lRange(`${user.id}:invoices`, 0, 10); let invoice; for (const iid of invoices) { const i = await getInvoice(iid); const paid = i.amount > 0 && i.received >= i.amount; const old = Date.now() - i.created > fiveMinutes; if (paid) break; if (i.own && !old) { invoice = i; break; } } if (invoice) { if (amount?.startsWith("+")) { const tip = invoice.amount * amount.split("+")[1]; invoice = await generate({ invoice: { ...invoice, tip, }, user, }); } } else { invoice = await generate({ invoice: { amount, prompt: user.prompt, type: PaymentType.lightning, }, user, }); } const { id: uid } = user; const metadata = JSON.stringify([ ["text/plain", `Paying ${username}@${host}`], ["text/identifier", `${username}@${host}`], ]); const id = v4(); const total = (parseInt(invoice.amount || 0) + parseInt(invoice.tip || 0)) * 1000; await s(`lnurl:${id}`, uid); if (total > 0) await s(`lnurl:${id}:invoice`, invoice.id); return c.json({ allowsNostr: true, minSendable: invoice.amount ? total : 1000, maxSendable: invoice.amount ? total : 10 * 1000 * SATS, metadata, nostrPubkey: serverPubkey2, commentAllowed: 512, callback: `${URL}/api/lnurl/${id}`, tag: "payRequest", }); } catch (e) { if (!e.message.includes("found")) warn("problem generating lnurlp request", username, e.message); return bail(c, e.message); } }, };