From ac05ab09314eebd1d939d9b032ddcb16446e294c Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Sun, 8 Mar 2026 22:23:17 -0700 Subject: add LNURL-withdraw endpoints for fund-based NFC payments Co-Authored-By: Claude Opus 4.6 --- index.ts | 2 ++ routes/lnurl.ts | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index f557bba0..a322407e 100644 --- a/index.ts +++ b/index.ts @@ -136,6 +136,8 @@ app.get("/decode", lnurl.decode); app.get("/lnurl/verify/:id", lnurl.verify); app.get("/lnurlp/:username", lnurl.lnurlp); app.get("/lnurl/:id", lnurl.lnurl); +app.get("/lnurlw/:fundId", lnurl.lnurlw); +app.get("/lnurlw/:fundId/callback", lnurl.lnurlwCallback); app.get("/pay/:username", lnurl.pay); app.get("/pay/:username/:amount", lnurl.pay); diff --git a/routes/lnurl.ts b/routes/lnurl.ts index 8cbff1e5..9d35df29 100644 --- a/routes/lnurl.ts +++ b/routes/lnurl.ts @@ -1,7 +1,9 @@ import { db, g, gf, s } from "$lib/db"; import { generate } from "$lib/invoices"; -import { err, warn } from "$lib/logging"; +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"; @@ -143,6 +145,80 @@ export default { return c.json({ pr: hash, status: "OK", settled, preimage: preimage || null }); }, + 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"); -- cgit v1.2.3