From 9507e040a03834f7d603c5c13a1f69ae6ef48b3a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Tue, 3 Mar 2026 21:26:57 -0800 Subject: bip353 --- lib/bip353.ts | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/payments.ts | 35 +++++++++++++++- routes/users.ts | 7 ++++ 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 lib/bip353.ts diff --git a/lib/bip353.ts b/lib/bip353.ts new file mode 100644 index 00000000..4e37c46e --- /dev/null +++ b/lib/bip353.ts @@ -0,0 +1,121 @@ +import config from "$config"; +import { db, g, s } from "$lib/db"; +import ln from "$lib/ln"; +import { l, warn } from "$lib/logging"; +import { v4 } from "uuid"; +import { PaymentType } from "$lib/types"; + +const { CF_EMAIL, CF_API_KEY, CF_ZONE_ID } = process.env; +const cfApi = "https://api.cloudflare.com/client/v4"; +const cfHeaders = { + "X-Auth-Email": CF_EMAIL, + "X-Auth-Key": CF_API_KEY, + "Content-Type": "application/json", +}; + +const createDnsRecord = async (username: string, bolt12: string) => { + if (!CF_API_KEY || !CF_ZONE_ID) return; + + const name = `${username}.user._bitcoin-payment.${config.hostname}`; + const content = `bitcoin:?lno=${bolt12}`; + + const listRes = await fetch( + `${cfApi}/zones/${CF_ZONE_ID}/dns_records?type=TXT&name=${name}`, + { headers: cfHeaders }, + ); + const listData = (await listRes.json()) as any; + + if (listData.result?.length) { + const recordId = listData.result[0].id; + await fetch(`${cfApi}/zones/${CF_ZONE_ID}/dns_records/${recordId}`, { + method: "PUT", + headers: cfHeaders, + body: JSON.stringify({ type: "TXT", name, content, ttl: 3600 }), + }); + } else { + await fetch(`${cfApi}/zones/${CF_ZONE_ID}/dns_records`, { + method: "POST", + headers: cfHeaders, + body: JSON.stringify({ type: "TXT", name, content, ttl: 3600 }), + }); + } +}; + +const deleteDnsRecord = async (username: string) => { + if (!CF_API_KEY || !CF_ZONE_ID) return; + + const name = `${username}.user._bitcoin-payment.${config.hostname}`; + const listRes = await fetch( + `${cfApi}/zones/${CF_ZONE_ID}/dns_records?type=TXT&name=${name}`, + { headers: cfHeaders }, + ); + const listData = (await listRes.json()) as any; + + for (const record of listData.result || []) { + await fetch(`${cfApi}/zones/${CF_ZONE_ID}/dns_records/${record.id}`, { + method: "DELETE", + headers: cfHeaders, + }); + } +}; + +export const setupBip353 = async (user) => { + const existing = await g(`bip353:${user.username}`); + if (existing) { + await createDnsRecord(user.username, existing.bolt12); + return; + } + + const r = await ln.offer({ + amount: "any", + label: `bip353 ${user.username}`, + description: `Pay ${user.username}`, + }); + + await s(`bip353:${user.username}`, { + offer_id: r.offer_id, + bolt12: r.bolt12, + }); + + // Template invoice so the lightning listener can route payments + const id = v4(); + const rates = await g("rates"); + const currency = user.currency || "USD"; + + const inv = { + id, + aid: user.id, + amount: 0, + currency, + hash: r.bolt12, + rate: rates?.[currency] || 0, + memo: "", + path: null, + pending: 0, + received: 0, + tip: 0, + type: PaymentType.bolt12, + uid: user.id, + created: Date.now(), + }; + + await s(`invoice:${r.offer_id}`, id); + await s(`invoice:${id}`, inv); + + await createDnsRecord(user.username, r.bolt12); + l("BIP 353 setup complete for", user.username); +}; + +export const teardownBip353 = async (user) => { + const existing = await g(`bip353:${user.username}`); + if (!existing) return; + + if (existing.offer_id) { + await ln.disableoffer(existing.offer_id).catch(() => {}); + await db.del(`invoice:${existing.offer_id}`); + } + + await db.del(`bip353:${user.username}`); + await deleteDnsRecord(user.username); + l("BIP 353 teardown complete for", user.username); +}; diff --git a/lib/payments.ts b/lib/payments.ts index 47c82d9d..b3c63cfa 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -63,6 +63,22 @@ const { URL } = process.env; const dust = 547; +const resolveBip353 = async (name: string, domain: string) => { + const qname = `${name}.user._bitcoin-payment.${domain}`; + const res = await fetch( + `https://1.1.1.1/dns-query?name=${qname}&type=TXT`, + { headers: { accept: "application/dns-json" } }, + ); + const data = await res.json(); + if (!data.AD) return null; // DNSSEC validation failed + if (!data.Answer?.length) return null; + for (const ans of data.Answer) { + const txt = ans.data?.replace(/^"|"$/g, "").replaceAll('" "', ""); + if (txt?.startsWith("bitcoin:")) return txt; + } + return null; +}; + export const getUserRate = async (user) => { const rates = await g("rates"); const { currency } = user; @@ -478,7 +494,24 @@ const pay = async ({ aid = undefined, amount, to, user }) => { if (to.includes("@") && to.includes(".")) { const [name, domain] = to.split("@"); if (URL.includes(domain)) to = name; - lnurl = `https://${domain}/.well-known/lnurlp/${name}`; + else { + try { + const uri = await resolveBip353(name, domain); + if (uri) { + const params = new URLSearchParams(uri.split("?")[1]); + if (params.has("lno")) to = params.get("lno"); + else if (params.has("sp")) to = params.get("sp"); + else if (params.has("lightning")) to = params.get("lightning"); + else { + const addr = uri.replace("bitcoin:", "").split("?")[0]; + if (addr) to = addr; + } + } + } catch (e) { + warn("BIP 353 resolution failed, falling back to LNURL", e.message); + } + if (to.includes("@")) lnurl = `https://${domain}/.well-known/lnurlp/${name}`; + } } else if (to.startsWith("lnurl")) { lnurl = Buffer.from(bech32.fromWords(bech32.decode(to, 20000).words)).toString(); } diff --git a/routes/users.ts b/routes/users.ts index 941a0a3b..6948fb28 100644 --- a/routes/users.ts +++ b/routes/users.ts @@ -26,6 +26,7 @@ import { authenticator } from "otplib"; import { v4 } from "uuid"; import { setCookie } from "hono/cookie"; +import { setupBip353, teardownBip353 } from "$lib/bip353"; import { PaymentType } from "$lib/types"; import { createBalanceAccount, getBalance, getCredit, getPending } from "$lib/tb"; import { importAccountHistory } from "$lib/payments"; @@ -297,6 +298,7 @@ export default { "arkAddress", "autowithdraw", "banner", + "bip353", "currencies", "currency", "destination", @@ -353,6 +355,11 @@ export default { await s(`user:${user.id}`, user); if (user.nip5) await db.sAdd("nip5", `${user.username}:${user.pubkey}`); + if (typeof body.bip353 !== "undefined") { + if (user.bip353) setupBip353(user).catch((e) => warn("BIP 353 setup failed", e.message)); + else teardownBip353(user).catch((e) => warn("BIP 353 teardown failed", e.message)); + } + emit(user.id, "user", user); return c.json({ user }); } catch (e) { -- cgit v1.2.3