aboutsummaryrefslogtreecommitdiffstats
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
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]>
-rw-r--r--index.ts1
-rw-r--r--lib/invoices.ts39
-rw-r--r--lib/lightning.ts8
-rw-r--r--lib/nostr.ts24
-rw-r--r--lib/notifications.ts45
-rw-r--r--lib/nwc.ts356
-rw-r--r--lib/payments.ts24
-rw-r--r--routes/nostr.ts69
-rw-r--r--routes/payments.ts40
9 files changed, 500 insertions, 106 deletions
diff --git a/index.ts b/index.ts
index cbe08b3a..5f468454 100644
--- a/index.ts
+++ b/index.ts
@@ -91,6 +91,7 @@ app.post("/send/:lnaddress/:amount", auth, payments.lnaddress);
app.post("/send", auth, payments.internal);
app.post("/replace", auth, payments.replace);
app.get("/decode/:bolt11", payments.decode);
+app.get("/offer", auth, payments.offer);
app.post("/fetchinvoice", payments.fetchinvoice);
app.get("/square/connect", auth, square.connect);
diff --git a/lib/invoices.ts b/lib/invoices.ts
index 613287a3..d11073c2 100644
--- a/lib/invoices.ts
+++ b/lib/invoices.ts
@@ -188,7 +188,15 @@ export const generate = async ({ invoice, user }) => {
description: memo || id,
});
- if (await getInvoice(r.offer_id)) fail("Duplicate offer exists");
+ // CLN returns the same offer_id for identical offer contents, so a
+ // re-create of an offer we already track must not register a second
+ // invoice for it (duplicate :invoices entries — see kaliyi's OCEAN
+ // offer). If it's the same user's offer, hand back the existing record.
+ const existing = await getInvoice(r.offer_id);
+ if (existing) {
+ if (existing.uid === user.id) return existing;
+ fail("Duplicate offer exists");
+ }
await s(`invoice:${r.offer_id}`, id);
}
@@ -266,3 +274,32 @@ export const generate = async ({ invoice, user }) => {
return invoice;
};
+
+// Each user gets one standing amountless bolt12 offer (lno1...) they can hand
+// out as a reusable payment code — e.g. published in a kind 10058 list for
+// NIP-177 bolt12 zaps. Created lazily on first request, then reused; incoming
+// payments credit through the regular bolt12 receive path via local_offer_id.
+export const getUserOffer = async (user) => {
+ const key = `${user.id}:offer`;
+ const existingId = await g(key);
+ if (existingId) {
+ const existing = await g(`invoice:${existingId}`);
+ if (existing?.hash) return existing;
+ }
+
+ let domain;
+ try {
+ domain = new globalThis.URL(process.env.URL).host;
+ } catch (e) {}
+
+ const invoice = await generate({
+ invoice: {
+ type: PaymentType.bolt12,
+ memo: domain ? `${user.username}@${domain}` : user.username,
+ },
+ user,
+ });
+
+ await s(key, invoice.id);
+ return invoice;
+};
diff --git a/lib/lightning.ts b/lib/lightning.ts
index 1c768981..8935f2ce 100644
--- a/lib/lightning.ts
+++ b/lib/lightning.ts
@@ -46,6 +46,7 @@ export async function listenForLightning() {
bolt11,
bolt12,
description,
+ invreq_payer_note,
pay_index,
payment_hash,
amount_received_msat,
@@ -97,7 +98,9 @@ export async function listenForLightning() {
await credit({
hash: bolt11 || bolt12,
amount: received,
- memo: invoice.memo,
+ // A bolt12 payer note (e.g. nostr:nip177:<zap-intent-id>) identifies
+ // this specific payment; the offer's own memo is just its static label
+ memo: invreq_payer_note || invoice.memo,
ref: preimage,
type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning,
payment_hash,
@@ -206,6 +209,7 @@ export async function replay(index) {
bolt11,
bolt12,
description,
+ invreq_payer_note,
amount_received_msat,
payment_preimage: preimage,
paid_at,
@@ -237,7 +241,7 @@ export async function replay(index) {
p = await credit({
hash: bolt11 || bolt12,
amount: received,
- memo: invoice.memo,
+ memo: invreq_payer_note || invoice.memo,
ref: preimage,
type: bolt12 ? PaymentType.bolt12 : PaymentType.lightning,
created: paid_at ? paid_at * 1000 : undefined,
diff --git a/lib/nostr.ts b/lib/nostr.ts
index 064e1952..ed1b2f42 100644
--- a/lib/nostr.ts
+++ b/lib/nostr.ts
@@ -4,7 +4,14 @@ import { l, warn } from "$lib/logging";
import { scan } from "$lib/strfry";
import { fail, fields, getUser, pick } from "$lib/utils";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
-import { finalizeEvent, getPublicKey, nip19, verifyEvent } from "nostr-tools";
+import {
+ finalizeEvent,
+ getPublicKey,
+ nip04,
+ nip19,
+ nip44,
+ verifyEvent,
+} from "nostr-tools";
import type { Event } from "nostr-tools";
import { AbstractSimplePool } from "nostr-tools/abstract-pool";
import { Relay } from "nostr-tools/relay";
@@ -37,6 +44,21 @@ export const anon = (pubkey) => ({
followers: [],
});
+// NIP-47 encryption schemes: clients pick one from the 13194 info event's
+// `encryption` tag and mark their requests with the same tag; replies use
+// whatever the request used. No tag means legacy nip04.
+export const encryptionSchemes = "nip44_v2 nip04";
+
+export const decryptPayload = (scheme, sk, pk, content) =>
+ scheme === "nip44_v2"
+ ? nip44.decrypt(content, nip44.getConversationKey(hexToBytes(sk), pk))
+ : nip04.decrypt(sk, pk, content);
+
+export const encryptPayload = (scheme, sk, pk, plaintext) =>
+ scheme === "nip44_v2"
+ ? nip44.encrypt(plaintext, nip44.getConversationKey(hexToBytes(sk), pk))
+ : nip04.encrypt(sk, pk, plaintext);
+
export async function publish(ev, url = config.nostr) {
if (!verifyEvent(ev)) fail("Invalid event");
const r = await Relay.connect(url);
diff --git a/lib/notifications.ts b/lib/notifications.ts
index eeb6e8c1..09f4fb2b 100644
--- a/lib/notifications.ts
+++ b/lib/notifications.ts
@@ -4,11 +4,11 @@ import ln from "$lib/ln";
import { err, l, warn } from "$lib/logging";
import { mail, templates } from "$lib/mail";
import mqtt from "$lib/mqtt";
-import { publish, serverSecret2 } from "$lib/nostr";
+import { encryptPayload, publish, serverSecret2 } from "$lib/nostr";
import { emit } from "$lib/sockets";
import { f, fiat, fmt, getUser, link, nada, t } from "$lib/utils";
import { hexToBytes } from "@noble/hashes/utils";
-import { finalizeEvent, nip04 } from "nostr-tools";
+import { finalizeEvent } from "nostr-tools";
import webpush from "web-push";
if (config.vapid) {
@@ -84,8 +84,15 @@ export const nwcNotify = async (p) => {
const user = await getUser(p.uid);
const pubkeys = await db.sMembers(`${user.id}:apps`);
if (pubkeys.length) {
- let payment_hash = "";
- if (p.type === "lightning") ({ payment_hash } = await ln.decode(p.hash));
+ let payment_hash = p.payment_hash || "";
+ if (!payment_hash && (p.type === "lightning" || p.type === "bolt12")) {
+ try {
+ const d = await ln.decode(p.hash);
+ // bolt11 decodes expose payment_hash, bolt12 invoices
+ // invoice_payment_hash
+ payment_hash = d.payment_hash || d.invoice_payment_hash || "";
+ } catch (e) {}
+ }
for (const pubkey of pubkeys) {
const { notify } = await g(`app:${pubkey}`);
if (!notify) continue;
@@ -108,18 +115,30 @@ export const nwcNotify = async (p) => {
notification,
});
- const content = await nip04.encrypt(serverSecret2, pubkey, payload);
+ // NIP-47: a wallet supporting both schemes publishes each notification
+ // twice — kind 23196 nip04-encrypted, kind 23197 nip44-encrypted
+ for (const [scheme, kind] of [
+ ["nip04", 23196],
+ ["nip44_v2", 23197],
+ ] as const) {
+ const content = await encryptPayload(
+ scheme,
+ serverSecret2,
+ pubkey,
+ payload,
+ );
- const unsigned = {
- content,
- tags: [["p", pubkey]],
- kind: 23196,
- created_at: Math.floor(Date.now() / 1000),
- };
+ const unsigned = {
+ content,
+ tags: [["p", pubkey]],
+ kind,
+ created_at: Math.floor(Date.now() / 1000),
+ };
- const event = finalizeEvent(unsigned, hexToBytes(serverSecret2));
+ const event = finalizeEvent(unsigned, hexToBytes(serverSecret2));
- publish(event).catch(nada);
+ publish(event).catch(nada);
+ }
}
}
} catch (e) {
diff --git a/lib/nwc.ts b/lib/nwc.ts
index 9d54f62f..d6c27b6d 100644
--- a/lib/nwc.ts
+++ b/lib/nwc.ts
@@ -1,9 +1,12 @@
import config from "$config";
import { archive, db, g, gf } from "$lib/db";
-import { generate } from "$lib/invoices";
+import { generate, getUserOffer } from "$lib/invoices";
import ln from "$lib/ln";
import { err, l, warn } from "$lib/logging";
import {
+ decryptPayload,
+ encryptPayload,
+ encryptionSchemes,
handleZap,
serverPubkey,
serverPubkey2,
@@ -13,9 +16,10 @@ import {
import { sendInternal, sendKeysend, sendLightning } from "$lib/payments";
import { fail, getInvoice, sleep } from "$lib/utils";
import rpc from "@coinos/rpc";
-import { hexToBytes } from "@noble/hashes/utils";
+import { sha256 } from "@noble/hashes/sha256";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
import { Relay } from "nostr";
-import { finalizeEvent, nip04 } from "nostr-tools";
+import { finalizeEvent } from "nostr-tools";
import type { UnsignedEvent } from "nostr-tools";
const serverKeys = {
@@ -30,6 +34,8 @@ const bc = rpc(config.bitcoin);
const methods = [
"pay_keysend",
"pay_invoice",
+ "pay",
+ "receive",
"get_balance",
"get_info",
"make_invoice",
@@ -74,6 +80,7 @@ export default () => {
tags: [
["p", pk],
["notifications", "payment_received payment_sent"],
+ ["encryption", encryptionSchemes],
],
content: methods.join(" "),
},
@@ -197,7 +204,18 @@ export default () => {
r.on("event", async (sub, ev) => {
try {
if (sub === "infocheck") {
- if (ev.kind === 13194) infoSeen.add(ev.pubkey);
+ // Only count the stored info event as present if it's CURRENT —
+ // otherwise a stale replaceable event (old method list, missing
+ // encryption tag) would never get refreshed, since we only publish
+ // the ones not "seen".
+ if (ev.kind === 13194) {
+ const current =
+ ev.content === methods.join(" ") &&
+ ev.tags?.some(
+ (t) => t[0] === "encryption" && t[1] === encryptionSchemes,
+ );
+ if (current) infoSeen.add(ev.pubkey);
+ }
return;
}
if (sub !== "nwc") return;
@@ -208,20 +226,31 @@ export default () => {
let { content, pubkey } = ev;
const pk = ev.tags.find((t) => t[0] === "p")[1];
const sk = serverKeys[pk];
+ // Clients mark nip44 requests with an `encryption` tag; absence means
+ // legacy nip04 (NIP-47). Replies must use the same scheme.
+ const scheme =
+ ev.tags.find((t) => t[0] === "encryption")?.[1] === "nip44_v2"
+ ? "nip44_v2"
+ : "nip04";
const { params, method } = JSON.parse(
- await nip04.decrypt(sk, pubkey, content),
+ await decryptPayload(scheme, sk, pubkey, content),
);
// Helper: send a 23195 reply (used for both results and errors). Pulled
// up so the rate-limit path can reply instead of dropping silently.
const reply = async (payloadObj: any) => {
- const enc = await nip04.encrypt(sk, pubkey, JSON.stringify(payloadObj));
+ const enc = await encryptPayload(
+ scheme,
+ sk,
+ pubkey,
+ JSON.stringify(payloadObj),
+ );
const signed = await finalizeEvent(
{
created_at: Math.floor(Date.now() / 1000),
kind: 23195,
pubkey: serverPubkey,
- tags: [["p", pubkey], ["e", ev.id]],
+ tags: [["p", pubkey], ["e", ev.id], ["encryption", scheme]],
content: enc,
} as UnsignedEvent,
hexToBytes(sk),
@@ -295,6 +324,95 @@ export default () => {
connect();
};
+// Pull the single lightning instruction out of a BIP-321 payment string for
+// the nwc `pay` method (nwc#2): a bitcoin: URI (preferring lno= over
+// lightning=), a lightning: URI, or a bare bolt11/bolt12 string.
+const parseInstruction = (payment) => {
+ if (typeof payment !== "string") return null;
+ let s = payment.replace(/\s/g, "");
+ const lower = s.toLowerCase();
+ if (lower.startsWith("lightning:")) {
+ s = s.slice("lightning:".length);
+ } else if (lower.startsWith("bitcoin:")) {
+ const i = s.indexOf("?");
+ if (i === -1) return null;
+ let lno;
+ let bolt11;
+ // BIP-321 keys are case-insensitive and may repeat; first one wins
+ for (const [k, v] of new URLSearchParams(s.slice(i + 1))) {
+ const key = k.toLowerCase();
+ if (key === "lno") lno ||= v;
+ else if (key === "lightning") bolt11 ||= v;
+ }
+ s = lno || bolt11;
+ if (!s) return null;
+ }
+ s = s.toLowerCase();
+ return s.startsWith("ln") ? s : null;
+};
+
+// CLN v26.06 adds an experimental `createproof` RPC producing the lnp payer
+// proof that NIP-177 zap receipts (kind 9736) embed: createproof(invstring,
+// [note], [include]) -> { proofs: [{ bolt12: "lnp..." }] }. Feature-detect
+// through the generic call() — on older nodes (current: v25.12) this errors
+// and we omit the proof, which the nwc `pay` spec allows. The proof format is
+// still draft; once 26.06 is deployed, check whether NIP-177's required
+// fields (invreq_payer_note, invoice_amount) need an explicit `include` list.
+const createPayerProof = async (invstring: string) => {
+ try {
+ const r = await ln.call("createproof", { invstring });
+ return r?.proofs?.[0]?.bolt12;
+ } catch (e) {
+ return undefined;
+ }
+};
+
+// Connection validity + spending budget for an NWC app, shared by pay_invoice
+// and pay. Returns a NIP-47 error payload, or null when the spend is allowed.
+const checkBudget = async (app, amount) => {
+ const { max_amount, budget_renewal, pubkey, created } = app;
+
+ if (!created) {
+ return error({
+ code: "UNAUTHORIZED",
+ message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`,
+ });
+ }
+
+ const periods = {
+ daily: 60 * 60 * 24 * 1000,
+ weekly: 60 * 60 * 24 * 7 * 1000,
+ monthly: 60 * 60 * 24 * 30 * 1000,
+ yearly: 60 * 60 * 24 * 365 * 1000,
+ never: 60 * 60 * 24 * 365 * 10 * 1000,
+ };
+
+ const pids = await db.lRange(`${pubkey}:payments`, 0, -1);
+ let payments = await Promise.all(pids.map((pid) => gf(`payment:${pid}`)));
+ payments = payments.filter(
+ (p) => p?.created > Date.now() - periods[budget_renewal],
+ );
+
+ const spent = payments.reduce(
+ (a, b) =>
+ a +
+ (Math.abs(Number.parseInt(b.amount || 0)) +
+ Number.parseInt(b.tip || 0) +
+ Number.parseInt(b.fee || 0) +
+ Number.parseInt(b.ourfee || 0)),
+ 0,
+ );
+
+ if (max_amount > 0 && spent + amount > max_amount) {
+ return error({
+ code: "QUOTA_EXCEEDED",
+ message: `Budget exceeded: ${spent + amount} of ${max_amount}`,
+ });
+ }
+
+ return null;
+};
+
const handle = (method, params, ev, app, user) =>
({
async pay_invoice() {
@@ -314,53 +432,10 @@ const handle = (method, params, ev, app, user) =>
});
}
const amount = Math.round(amountMsat / 1000);
- const { max_amount, max_fee, budget_renewal, pubkey, created } = app;
+ const { max_fee, pubkey } = app;
- const periods = {
- daily: 60 * 60 * 24 * 1000,
- weekly: 60 * 60 * 24 * 7 * 1000,
- monthly: 60 * 60 * 24 * 30 * 1000,
- yearly: 60 * 60 * 24 * 365 * 1000,
- never: 60 * 60 * 24 * 365 * 10 * 1000,
- };
-
- const pids = await db.lRange(`${pubkey}:payments`, 0, -1);
- let payments = await Promise.all(pids.map((pid) => gf(`payment:${pid}`)));
- payments = payments.filter(
- (p) => p?.created > Date.now() - periods[budget_renewal],
- );
-
- const spent = payments.reduce(
- (a, b) =>
- a +
- (Math.abs(Number.parseInt(b.amount || 0)) +
- Number.parseInt(b.tip || 0) +
- Number.parseInt(b.fee || 0) +
- Number.parseInt(b.ourfee || 0)),
- 0,
- );
-
- if (!created) {
- return error({
- code: "UNAUTHORIZED",
- message: `This NWC connection is no longer valid please create a new one at https://coinos.io/settings/nostr`,
- });
- }
-
- if (max_amount > 0 && spent + amount > max_amount) {
- // warn(
- // "budget exceeded",
- // pubkey,
- // user?.username,
- // spent,
- // amount,
- // max_amount,
- // );
- return error({
- code: "QUOTA_EXCEEDED",
- message: `Budget exceeded: ${spent + amount} of ${max_amount}`,
- });
- }
+ const budgetError = await checkBudget(app, amount);
+ if (budgetError) return budgetError;
if (payee === id) {
const invoice = await getInvoice(pr);
@@ -433,6 +508,181 @@ const handle = (method, params, ev, app, user) =>
});
},
+ // nwc#2 generalized payment: BIP-321 URI carrying a bolt11 invoice or a
+ // bolt12 offer/invoice. This is what Amethyst v1.13+ uses for bolt12 zaps —
+ // payer_note carries the nostr:nip177:<intent-id> binding and the client
+ // builds the kind 9736 receipt from the returned preimage/payer_proof.
+ async pay() {
+ const { payment, amount: reqAmountMsat, payer_note, metadata } = params;
+ const { pubkey } = app;
+
+ const pr = parseInstruction(payment);
+ if (!pr) {
+ return error({
+ code: "UNSUPPORTED_PAYMENT_INSTRUCTION",
+ message: "No lightning instruction found (bolt11 or bolt12 required)",
+ });
+ }
+
+ let decoded;
+ try {
+ decoded = await ln.decode(pr);
+ if (decoded.valid === false) throw new Error("invalid");
+ } catch (e) {
+ return error({
+ code: "BAD_REQUEST",
+ message: "Failed to decode payment instruction",
+ });
+ }
+
+ const instruction_type = decoded.type.includes("bolt12")
+ ? "bolt12"
+ : "bolt11";
+ const instructionAmountMsat =
+ decoded.type === "bolt12 offer"
+ ? decoded.offer_amount_msat
+ : instruction_type === "bolt12"
+ ? decoded.invoice_amount_msat
+ : decoded.amount_msat;
+
+ const amountMsat = instructionAmountMsat || reqAmountMsat;
+ if (!amountMsat) {
+ return error({
+ code: "BAD_REQUEST",
+ message:
+ "Amountless payment instruction requires an amount (msat) in the request",
+ });
+ }
+ const amount = Math.round(amountMsat / 1000);
+
+ const budgetError = await checkBudget(app, amount);
+ if (budgetError) return budgetError;
+
+ const created_at = Math.floor(Date.now() / 1000);
+
+ // Recipient is a coinos user (their invoice or standing offer is ours):
+ // settle internally, same as pay_invoice does
+ const invoice = await getInvoice(pr);
+ if (invoice) {
+ const recipient = await g(`user:${invoice.uid}`);
+
+ if (recipient?.username !== "mint") {
+ const { id: pid } = await sendInternal({
+ amount,
+ invoice,
+ recipient,
+ sender: user,
+ });
+
+ if (pubkey !== user.pubkey) await db.lPush(`${pubkey}:payments`, pid);
+
+ if (invoice.memo?.includes("9734")) {
+ const { invoices } = await ln.listinvoices({ invstring: pr });
+ const inv = invoices[0];
+ if (inv) {
+ inv.payment_preimage = pid;
+ inv.paid_at = created_at;
+ try {
+ await handleZap(inv, user.pubkey);
+ } catch (e) {
+ console.log("zap receipt failed", e);
+ }
+ }
+ }
+
+ // Internal settlement never touches CLN, so there's no real
+ // preimage or payer proof to hand back
+ return result({
+ transaction_id: pid,
+ state: "settled",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: 0,
+ created_at,
+ settled_at: Math.floor(Date.now() / 1000),
+ });
+ }
+ }
+
+ try {
+ const p = await sendLightning({
+ amount,
+ fee: app.max_fee || Math.round(amount * 0.01),
+ user,
+ pr,
+ memo: metadata ? JSON.stringify(metadata) : undefined,
+ payerNote: payer_note,
+ });
+
+ await db.lPush(`${pubkey}:payments`, p.id);
+
+ const preimage = p.ref;
+ if (!preimage) {
+ return result({
+ transaction_id: p.id,
+ state: "pending",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: 0,
+ created_at,
+ });
+ }
+
+ return result({
+ transaction_id: p.id,
+ state: "settled",
+ instruction_type,
+ amount: amountMsat,
+ fees_paid: (p.fee || 0) * 1000,
+ payment_hash: bytesToHex(sha256(hexToBytes(preimage))),
+ preimage,
+ // p.hash is the invoice actually paid (for an offer, the bolt12
+ // invoice fetched from it)
+ payer_proof:
+ instruction_type === "bolt12"
+ ? await createPayerProof(p.hash)
+ : undefined,
+ created_at,
+ settled_at: Math.floor(Date.now() / 1000),
+ });
+ } catch (e) {
+ return error({ code: "PAYMENT_FAILED", message: e.message });
+ }
+ },
+
+ // nwc#2 counterpart to pay: hand back a BIP-321 URI combining a fresh
+ // bolt11 invoice with the user's standing bolt12 offer
+ async receive() {
+ const { amount, description } = params;
+
+ try {
+ const invoice = await generate({
+ invoice: {
+ amount: amount ? Math.round(amount / 1000) : 0,
+ type: "lightning",
+ memo: description,
+ },
+ user,
+ });
+
+ const qs = new URLSearchParams();
+ qs.set("lightning", invoice.hash);
+ try {
+ const offer = await getUserOffer(user);
+ if (offer?.hash) qs.set("lno", offer.hash);
+ } catch (e) {
+ warn("failed to include offer in receive", e.message);
+ }
+
+ return result({
+ bip321: `bitcoin:?${qs.toString()}`,
+ transaction_id: invoice.id,
+ });
+ } catch (e) {
+ return error({ code: "BAD_REQUEST", message: e.message });
+ }
+ },
+
async pay_keysend() {
const { amount: amount_msat, pubkey, tlv_records } = params;
const amount = Math.round(amount_msat / 1000);
diff --git a/lib/payments.ts b/lib/payments.ts
index df2c8857..56aa08de 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -717,6 +717,7 @@ export const sendLightning = async ({
amount,
fee = undefined,
memo = undefined,
+ payerNote = undefined,
}) => {
let p;
@@ -736,8 +737,29 @@ export const sendLightning = async ({
}
}
+ let decoded = await ln.decode(pr);
+
+ // A bolt12 offer (lno1...) isn't payable directly — fetch a real invoice
+ // from it over onion messages, then pay that. payerNote rides along in
+ // invreq_payer_note (NIP-177 bolt12 zaps put nostr:nip177:<intent-id> there).
+ if (decoded.type === "bolt12 offer") {
+ if (decoded.offer_currency)
+ fail("Currency-denominated offers are not supported");
+ if (!decoded.offer_amount_msat && !amount) fail("Amount required");
+
+ const { invoice } = await ln.fetchinvoice({
+ offer: pr,
+ amount_msat: decoded.offer_amount_msat ? undefined : amount * 1000,
+ payer_note: payerNote,
+ timeout: 60,
+ });
+
+ pr = invoice.replace(/\s/g, "").toLowerCase();
+ decoded = await ln.decode(pr);
+ }
+
let { type, invoice_amount_msat, amount_msat, invoice_node_id, payee } =
- await ln.decode(pr);
+ decoded;
if (type.includes("bolt12")) {
amount_msat = invoice_amount_msat;
payee = invoice_node_id;
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) {