diff options
| -rw-r--r-- | index.ts | 1 | ||||
| -rw-r--r-- | lib/invoices.ts | 39 | ||||
| -rw-r--r-- | lib/lightning.ts | 8 | ||||
| -rw-r--r-- | lib/nostr.ts | 24 | ||||
| -rw-r--r-- | lib/notifications.ts | 45 | ||||
| -rw-r--r-- | lib/nwc.ts | 356 | ||||
| -rw-r--r-- | lib/payments.ts | 24 | ||||
| -rw-r--r-- | routes/nostr.ts | 69 | ||||
| -rw-r--r-- | routes/payments.ts | 40 |
9 files changed, 500 insertions, 106 deletions
@@ -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) { @@ -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) { |
