diff options
| author | Adam Soltys <[email protected]> | 2026-03-08 22:23:17 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-03-08 22:23:17 -0700 |
| commit | ac05ab09314eebd1d939d9b032ddcb16446e294c (patch) | |
| tree | fbeb1f5497ec2049bfbef31310aefce2d2297690 | |
| parent | 4437763b8dd21d2d13420146895b5fe29f72cc4b (diff) | |
| download | coinos-server-ac05ab09314eebd1d939d9b032ddcb16446e294c.tar.gz coinos-server-ac05ab09314eebd1d939d9b032ddcb16446e294c.zip | |
add LNURL-withdraw endpoints for fund-based NFC payments
Co-Authored-By: Claude Opus 4.6 <[email protected]>
| -rw-r--r-- | index.ts | 2 | ||||
| -rw-r--r-- | routes/lnurl.ts | 78 |
2 files changed, 79 insertions, 1 deletions
@@ -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"); |
