aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-08 22:23:17 -0700
committerAdam Soltys <[email protected]>2026-03-08 22:23:17 -0700
commitac05ab09314eebd1d939d9b032ddcb16446e294c (patch)
treefbeb1f5497ec2049bfbef31310aefce2d2297690
parent4437763b8dd21d2d13420146895b5fe29f72cc4b (diff)
downloadcoinos-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.ts2
-rw-r--r--routes/lnurl.ts78
2 files changed, 79 insertions, 1 deletions
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");