aboutsummaryrefslogtreecommitdiffstats
path: root/routes
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-07-30 08:51:36 -0700
committerAdam Soltys <[email protected]>2026-07-30 08:51:36 -0700
commitadd3ad93ff37f1daaedab5788f00663be299555c (patch)
treede0523f7f6d13e57e58c26d8b03dabbdcb5790cb /routes
parent81c3593f1eec7f94dc9cbc22edc3456fa57b95cb (diff)
downloadcoinos-server-add3ad93ff37f1daaedab5788f00663be299555c.tar.gz
coinos-server-add3ad93ff37f1daaedab5788f00663be299555c.zip
bolt12 zaps: pay offers, NWC pay/receive, per-user offers, NIP-44
- sendLightning fetches an invoice from lno1 offers (payer_note passthrough for NIP-177 zap intents) and pays it through the existing bolt12 path - NWC: new nwc#2 pay/receive methods (BIP-321 URIs, payer proofs stubbed until CLN 26.06 createproof), nip44_v2 alongside nip04 with per-request scheme detection, dual 23196/23197 notifications, 13194 refreshed when its content or encryption tag goes stale - standing per-user bolt12 offer via GET /offer; incoming bolt12 payments record invreq_payer_note as memo - parse/fetchinvoice routes handle offers; zap display parses kind 9736 receipts (amount tag + embedded intent) and drops a call to a sync() helper that never existed Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'routes')
-rw-r--r--routes/nostr.ts69
-rw-r--r--routes/payments.ts40
2 files changed, 74 insertions, 35 deletions
diff --git a/routes/nostr.ts b/routes/nostr.ts
index 0eb95ff6..4a7ce069 100644
--- a/routes/nostr.ts
+++ b/routes/nostr.ts
@@ -21,6 +21,36 @@ import { decode } from "nostr-tools/nip19";
import type { ProfilePointer } from "nostr-tools/nip19";
import { getZapEndpoint, makeZapRequest } from "nostr-tools/nip57";
+// Amount and payer of a zap receipt. NIP-57 kind 9735: amount comes from
+// decoding the paid bolt11, payer from the embedded 9734 zap request in the
+// description tag. NIP-177 kind 9736 (BOLT12 zaps): amount is an explicit
+// msat tag, and the receipt is payer-published with the signed 9737 intent in
+// the description tag.
+const parseZap = async (ev) => {
+ const { kind, tags, pubkey } = ev;
+ const description = tags.find((t) => t[0] === "description")?.[1];
+
+ let payer;
+ try {
+ payer = JSON.parse(description).pubkey;
+ } catch (e) {}
+ payer ||= pubkey;
+
+ let amount = 0;
+ if (kind === 9736) {
+ const msat = Number.parseInt(tags.find((t) => t[0] === "amount")?.[1]);
+ if (msat > 0) amount = Math.round(msat / 1000);
+ } else {
+ try {
+ const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
+ const { amount_msat } = await ln.decode(bolt11);
+ if (amount_msat) amount = Math.round(amount_msat / 1000);
+ } catch (e) {}
+ }
+
+ return { amount, pubkey: payer };
+};
+
export default {
async event(req, res) {
try {
@@ -46,23 +76,14 @@ export default {
)
.map(({ value }) => value.pubkey);
- const zapEvents = await scan({ kinds: [9735], "#e": [id] });
+ const zapEvents = await scan({ kinds: [9735, 9736], "#e": [id] });
const zaps = [];
- for (const { tags } of zapEvents) {
- const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
- const description = tags.find((t) => t[0] === "description")?.[1];
- const { pubkey } = JSON.parse(description);
- let amount = 0;
- try {
- const { amount_msat } = await ln.decode(bolt11);
- if (amount_msat) amount = Math.round(amount_msat / 1000);
- } catch (e) {}
-
- zaps.push({ amount, pubkey });
+ for (const zapEvent of zapEvents) {
+ zaps.push(await parseZap(zapEvent));
}
pubkeys.push(event.pubkey);
- pubkeys.push(...zaps.map((z) => z.pubkey));
+ pubkeys.push(...zaps.map((z) => z.pubkey).filter((p) => p));
pubkeys = [...new Set(pubkeys)];
const profiles = await scan({ kinds: [0], authors: pubkeys });
const found = profiles.map((p) => p.pubkey);
@@ -167,24 +188,16 @@ export default {
let { id } = req.params;
if (id.startsWith("nevent")) id = decode(id).data.id;
if (id.startsWith("note")) id = decode(id).data;
- const filter = { kinds: [9735], "#e": [id] };
- let events = await scan(filter);
- if (!events.length) {
- await sync("wss://relay.primal.net", filter);
- events = await scan(filter);
- }
+ // q() scans the local strfry and falls back to primal (the old code
+ // called a `sync` helper that never existed and threw ReferenceError
+ // whenever the local relay had no receipts)
+ const filter = { kinds: [9735, 9736], "#e": [id] };
+ const events = await q(filter);
if (!events.length) return res.send([]);
const zaps = [];
- for (const { tags } of events) {
- const bolt11 = tags.find((t) => t[0] === "bolt11")?.[1];
- const description = tags.find((t) => t[0] === "description")?.[1];
- const { pubkey } = JSON.parse(description);
- let amount = 0;
- try {
- const { amount_msat } = await ln.decode(bolt11);
- if (amount_msat) amount = Math.round(amount_msat / 1000);
- } catch (e) {}
+ for (const zapEvent of events) {
+ const { amount, pubkey } = await parseZap(zapEvent);
let user;
if (pubkey) {
diff --git a/routes/payments.ts b/routes/payments.ts
index 9c62d2bb..4239a409 100644
--- a/routes/payments.ts
+++ b/routes/payments.ts
@@ -2,7 +2,7 @@ import config from "$config";
import api from "$lib/api";
import { requirePin } from "$lib/auth";
import { archive, db, g, gf, s, sa } from "$lib/db";
-import { generate } from "$lib/invoices";
+import { generate, getUserOffer } from "$lib/invoices";
import { replay } from "$lib/lightning";
import ln from "$lib/ln";
import { err, l, warn } from "$lib/logging";
@@ -219,21 +219,30 @@ export default {
let amount_msat;
let payee;
- if (decoded.type.includes("bolt12")) {
+ if (decoded.type === "bolt12 offer") {
+ ({ offer_amount_msat: amount_msat } = decoded);
+ payee = decoded.offer_issuer_id || decoded.offer_node_id;
+ } else if (decoded.type.includes("bolt12")) {
({ invoice_amount_msat: amount_msat, invoice_node_id: payee } =
decoded);
} else ({ amount_msat, payee } = decoded);
const node = nodes.find((n) => n.nodeid === payee);
- const alias = node ? node.alias : payee.substr(0, 12);
+ const alias = node ? node.alias : (payee || "").substr(0, 12);
- const amount = Math.round(amount_msat / 1000);
+ const amount = Math.round((amount_msat || 0) / 1000);
let ourfee = Math.round(amount * config.fee[PaymentType.lightning]);
const credit = await g(`credit:lightning:${user.id}`);
const covered = Math.min(credit, ourfee) || 0;
ourfee -= covered;
- res.send({ alias, amount, ourfee });
+ res.send({
+ alias,
+ amount,
+ ourfee,
+ type: decoded.type,
+ description: decoded.offer_description || decoded.description,
+ });
} catch (e) {
err("problem parsing", e.message);
bail(res, e.message);
@@ -716,9 +725,26 @@ export default {
res.send(await ln.decode(bolt11));
},
+ // The user's standing bolt12 offer (lno1...) — reusable receive code they
+ // can publish (e.g. in a nostr kind 10058 list for bolt12 zaps)
+ async offer(req, res) {
+ try {
+ res.send(await getUserOffer(req.user));
+ } catch (e) {
+ bail(res, e.message);
+ }
+ },
+
async fetchinvoice(req, res) {
- const { amount, offer } = req.body;
- res.send(await ln.fetchinvoice(offer, amount ? amount * 1000 : null));
+ const { amount, offer, payer_note } = req.body;
+ res.send(
+ await ln.fetchinvoice({
+ offer,
+ amount_msat: amount ? amount * 1000 : undefined,
+ payer_note,
+ timeout: 60,
+ }),
+ );
},
async auth(req, res) {