summaryrefslogtreecommitdiffstats
path: root/src/bip353.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 20:28:56 -0700
committerAdam Soltys <[email protected]>2026-08-01 20:28:56 -0700
commit5986cbf5337593eb11fd984d284bd41da7719a87 (patch)
tree4ea35eb279b7213f5ebe9ec77118710612f35b6d /src/bip353.js
parent348a57004f94dd0f58c4e70769e65ca7ea3f7619 (diff)
downloadcoinosv3-5986cbf5337593eb11fd984d284bd41da7719a87.tar.gz
coinosv3-5986cbf5337593eb11fd984d284bd41da7719a87.zip
Payment names: ₿[email protected] via BIP-353 DNS payment instructions
Receiving: a Settings card claims a username, signed with the wallet's nostr key; the new names registrar (names/server.js, deployed at names.halwallet.app) enforces first-come ownership by that key and mirrors the claim into a DNSSEC-signed TXT record at <name>.user._bitcoin-payment.halwallet.app whose BIP-21 URI carries the wallet's reusable ark address. The record re-registers itself if the ark address ever changes, and a collapsible hint shows the single CNAME a user adds to serve the same name from their own domain (BIP-353 supports delegation when both zones are DNSSEC-signed). Sending: a pasted name@domain resolves over DNS first — two independent DoH resolvers must agree and both report the DNSSEC AD flag (full client-side chain validation is the stated follow-up) — an ark= instruction pays natively, an on-chain path fills the send form, and a name with no DNS record falls back to the Lightning-address zap flow. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'src/bip353.js')
-rw-r--r--src/bip353.js73
1 files changed, 73 insertions, 0 deletions
diff --git a/src/bip353.js b/src/bip353.js
new file mode 100644
index 0000000..2bacb65
--- /dev/null
+++ b/src/bip353.js
@@ -0,0 +1,73 @@
+// BIP-353 DNS payment instructions — resolving ₿name@domain to a BIP-21 URI.
+//
+// The record lives at <name>.user._bitcoin-payment.<domain> as a DNSSEC-signed
+// TXT containing one `bitcoin:` URI. Browsers can't do raw DNS, so we resolve
+// over DNS-over-HTTPS. TRUST LEVEL, stated honestly: full client-side DNSSEC
+// validation (what the BIP ultimately wants) is not implemented yet — instead
+// we require agreement between TWO independent validating resolvers
+// (Cloudflare and Google), each reporting the answer DNSSEC-authenticated
+// (AD flag). Collusion or simultaneous compromise of both is the residual
+// risk; a native chain validator is the follow-up.
+
+const RESOLVERS = [
+ 'https://cloudflare-dns.com/dns-query',
+ 'https://dns.google/resolve',
+];
+
+// "a" "b" quoted character-strings → concatenated payload
+function txtPayload(data) {
+ const parts = [...String(data).matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\(.)/g, '$1'));
+ return parts.length ? parts.join('') : String(data);
+}
+
+async function queryOne(base, label) {
+ const r = await fetch(`${base}?name=${label}&type=TXT`, { headers: { accept: 'application/dns-json' } });
+ if (!r.ok) throw new Error(`resolver ${r.status}`);
+ const j = await r.json();
+ const uris = (j.Answer || [])
+ .filter((a) => a.type === 16)
+ .map((a) => txtPayload(a.data))
+ .filter((t) => /^bitcoin:/i.test(t));
+ // BIP-353: multiple bitcoin: TXT records at one label are invalid
+ if (uris.length > 1) throw new Error('multiple payment records');
+ return { uri: uris[0] || null, ad: !!j.AD };
+}
+
+// name like "adam", domain like "halwallet.app" → the bitcoin: URI, or null
+// when no record exists. Throws when resolution can't be trusted.
+export async function resolveBip353(name, domain) {
+ const label = `${name}.user._bitcoin-payment.${domain}`;
+ const results = await Promise.allSettled(RESOLVERS.map((r) => queryOne(r, label)));
+ const ok = results.filter((r) => r.status === 'fulfilled').map((r) => r.value);
+ if (ok.length < 2) {
+ // one resolver down: accept a single authenticated answer, refuse a
+ // single unauthenticated one
+ if (ok.length === 1 && ok[0].uri && ok[0].ad) return ok[0].uri;
+ if (ok.length === 1 && !ok[0].uri) return null;
+ throw new Error('could not resolve the name');
+ }
+ if (ok[0].uri !== ok[1].uri) throw new Error('resolvers disagree — refusing');
+ if (!ok[0].uri) return null;
+ if (!ok[0].ad || !ok[1].ad) throw new Error('name is not DNSSEC-signed');
+ return ok[0].uri;
+}
+
+// A pasted payment name: optional ₿, user@domain with a real-looking TLD.
+export function parsePaymentName(text) {
+ const m = String(text || '').trim().match(/^₿?\s*([a-z0-9._-]{1,64})@([a-z0-9-]+(?:\.[a-z0-9-]+)+)$/i);
+ return m ? { name: m[1].toLowerCase(), domain: m[2].toLowerCase() } : null;
+}
+
+// bitcoin:<onchain?>?ark=...&lno=...&sp=... → { onchain, params }
+export function parseBip21(uri) {
+ const m = String(uri || '').match(/^bitcoin:([^?]*)(?:\?(.*))?$/i);
+ if (!m) return null;
+ const params = {};
+ for (const kv of (m[2] || '').split('&')) {
+ if (!kv) continue;
+ const eq = kv.indexOf('=');
+ if (eq < 0) continue;
+ params[decodeURIComponent(kv.slice(0, eq)).toLowerCase()] = decodeURIComponent(kv.slice(eq + 1));
+ }
+ return { onchain: m[1] ? decodeURIComponent(m[1]) : '', params };
+}