aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-03-03 21:26:57 -0800
committerAdam Soltys <[email protected]>2026-03-03 21:26:57 -0800
commit9507e040a03834f7d603c5c13a1f69ae6ef48b3a (patch)
treec82658ef1110105f1ac3f056a462b7e251db657d
parent82f7ae5f84da70e8c5692ff4fffa7263784a7800 (diff)
downloadcoinos-server-9507e040a03834f7d603c5c13a1f69ae6ef48b3a.tar.gz
coinos-server-9507e040a03834f7d603c5c13a1f69ae6ef48b3a.zip
bip353
-rw-r--r--lib/bip353.ts121
-rw-r--r--lib/payments.ts35
-rw-r--r--routes/users.ts7
3 files changed, 162 insertions, 1 deletions
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) {