aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
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/features
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/features')
-rw-r--r--src/features/ark.js5
-rw-r--r--src/features/index.js5
-rw-r--r--src/features/names.js179
-rw-r--r--src/features/zaps.js9
4 files changed, 197 insertions, 1 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index f84857d..c8a6b23 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1567,6 +1567,11 @@ export function arkFeature(ctx) {
return { invoice: a.invoice, paymentHash: a.paymentHash, amountSat };
},
arkMovements() { const s = arkStateNow(); return s ? (s.movements || []) : []; },
+ // The wallet's reusable ark receive address (BIP-353 names publish it).
+ async arkStaticAddress() {
+ const mgr = await connectArk();
+ return mgr.address();
+ },
// Whether background answering can mirror right now.
arkBgReady() { return !!(ark && ark.info && ark.state); },
// nwc.js calls this (with its connections) whenever the mirror should
diff --git a/src/features/index.js b/src/features/index.js
index 583334f..e34de3d 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -6,6 +6,7 @@
import { giftsFeature } from './gifts.js';
import { arkFeature } from './ark.js';
+import { namesFeature } from './names.js';
import { zapsFeature } from './zaps.js';
import { syncFeature } from './sync.js';
import { nwcFeature } from './nwc.js';
@@ -15,6 +16,8 @@ import { nwcFeature } from './nwc.js';
// after ark so an npub prefers an instant Ark zap and only falls through to a
// Lightning zap when Ark can't serve it. nwc sits after ark because it
// drives ark's headless pay/balance seam.
+// names sits between ark and zaps: a pasted user@domain resolves over DNS
+// (BIP-353) before the zaps feature treats it as a plain Lightning address.
export function buildFeatures(ctx) {
- return [giftsFeature(ctx), arkFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)];
+ return [giftsFeature(ctx), arkFeature(ctx), namesFeature(ctx), zapsFeature(ctx), nwcFeature(ctx), syncFeature(ctx)];
}
diff --git a/src/features/names.js b/src/features/names.js
new file mode 100644
index 0000000..d059516
--- /dev/null
+++ b/src/features/names.js
@@ -0,0 +1,179 @@
+// Payment names — BIP-353 DNS payment instructions.
+//
+// Receiving: claim ₿[email protected]. The claim is signed with the wallet's
+// nostr key and the registrar publishes a DNSSEC-signed TXT record whose
+// BIP-21 URI carries this wallet's (reusable) ark address. The name follows
+// the wallet: if the ark address changes (new ASP), the record re-registers
+// itself on the next open. Users with their own domain point a single CNAME
+// at our record and their name verifies just the same.
+//
+// Sending: paste name@domain (or ₿name@domain) and it resolves over DNS
+// first — an ark= instruction pays natively over Ark; a name with no BIP-353
+// record falls back to the Lightning-address flow (zaps feature).
+
+import { resolveBip353, parsePaymentName, parseBip21 } from '../bip353.js';
+import { isArkAddress } from './ark.js';
+import { getNetwork } from '../api.js';
+import { t } from '../i18n.js';
+
+const REGISTRAR = 'https://names.halwallet.app';
+const DOMAIN = 'halwallet.app';
+const AUTH_KIND = 21353;
+
+export function namesFeature(ctx) {
+ const { h, ui, render, wallet, hook, toast, copyBtn } = ctx;
+
+ const load = () => wallet.loadFeatureState('names', {});
+ const save = (st) => wallet.saveFeatureState('names', st);
+
+ const available = () => getNetwork() === 'mainnet' && !wallet.watchOnly
+ && !!wallet.nostrSign && !!hook('arkReady');
+
+ async function currentUri() {
+ const addr = await hook('arkStaticAddress');
+ return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null;
+ }
+
+ async function claim(name) {
+ const uri = await currentUri();
+ if (!uri) throw new Error(t('namesNeedArk'));
+ const auth = wallet.nostrSign({
+ kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
+ content: JSON.stringify({ action: 'register', name, uri }),
+ });
+ if (!auth) throw new Error('wallet cannot sign');
+ const r = await fetch(`${REGISTRAR}/register`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ auth }),
+ });
+ const j = await r.json();
+ if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
+ save({ name, uri, updated: Date.now() });
+ return j;
+ }
+
+ async function release() {
+ const st = load();
+ if (!st.name) return;
+ const auth = wallet.nostrSign({
+ kind: AUTH_KIND, created_at: Math.floor(Date.now() / 1000), tags: [],
+ content: JSON.stringify({ action: 'delete', name: st.name }),
+ });
+ await fetch(`${REGISTRAR}/register`, {
+ method: 'DELETE', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ auth }),
+ }).catch(() => {});
+ save({});
+ }
+
+ // The record must track the wallet: re-register when the ark address moved.
+ async function refresh() {
+ try {
+ const st = load();
+ if (!st.name || !available()) return;
+ const uri = await currentUri();
+ if (uri && uri !== st.uri) await claim(st.name);
+ } catch (e) { console.warn('names: refresh failed', e.message); }
+ }
+
+ // ---- sending to a name -------------------------------------------------
+
+ function beginResolve(text) {
+ const parsed = parsePaymentName(text);
+ if (!parsed) return false;
+ ui.nameResolve = { text, status: 'resolving' };
+ render();
+ (async () => {
+ let uri = null;
+ try { uri = await resolveBip353(parsed.name, parsed.domain); } catch (e) {
+ if (ui.nameResolve?.text !== text) return;
+ ui.nameResolve = null;
+ ui.sendError = `${parsed.name}@${parsed.domain}: ${e.message}`;
+ render();
+ return;
+ }
+ if (ui.nameResolve?.text !== text) return;
+ ui.nameResolve = null;
+ if (uri) {
+ const dec = parseBip21(uri);
+ const ark = dec?.params?.ark;
+ if (ark && isArkAddress(ark) && hook('arkReady')) {
+ ui.send.recipients[0].address = ark;
+ render();
+ return;
+ }
+ if (dec?.onchain) {
+ ui.send.recipients[0].address = dec.onchain;
+ render();
+ return;
+ }
+ ui.sendError = t('namesNoUsableInstruction');
+ render();
+ return;
+ }
+ // no BIP-353 record: hand off to the Lightning-address flow
+ if (!hook('lnAddressFallback', text)) {
+ ui.sendError = t('namesNotFound', { name: `${parsed.name}@${parsed.domain}` });
+ }
+ render();
+ })();
+ return true;
+ }
+
+ // ---- UI ---------------------------------------------------------------
+
+ function namesCard() {
+ if (!available()) return null;
+ const st = load();
+ if (st.name) {
+ const addr = `₿${st.name}@${DOMAIN}`;
+ return h('div', { class: 'card col' },
+ h('h3', {}, t('namesTitle')),
+ h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr),
+ h('div', { class: 'row gap6' },
+ copyBtn(`${st.name}@${DOMAIN}`, t('namesCopy')),
+ h('button', { class: 'btn-ghost btn-sm', onClick: async () => {
+ await release(); toast(t('namesReleased')); render();
+ } }, t('namesRelease'))),
+ h('details', { class: 'small faint' },
+ h('summary', {}, t('namesOwnDomain')),
+ h('p', { style: 'margin:4px 0' }, t('namesOwnDomainHow')),
+ h('div', { class: 'addr-box break', style: 'font-size:11px' },
+ `${st.name}.user._bitcoin-payment.yourdomain.com. CNAME ${st.name}.user._bitcoin-payment.${DOMAIN}.`)));
+ }
+ return h('div', { class: 'card col' },
+ h('h3', {}, t('namesTitle')),
+ h('p', { class: 'small muted', style: 'margin:0' }, t('namesDesc')),
+ ui.nameClaimError ? h('div', { class: 'notice err' }, ui.nameClaimError) : null,
+ h('div', { class: 'row gap6' },
+ h('input', {
+ type: 'text', placeholder: t('namesPlaceholder'), style: 'flex:1',
+ autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
+ value: ui.nameClaim || '',
+ onInput: (e) => { ui.nameClaim = e.target.value.toLowerCase().trim(); },
+ }),
+ h('button', { class: 'btn-primary', disabled: ui.busy, onClick: async () => {
+ const name = (ui.nameClaim || '').toLowerCase().trim();
+ if (!name) return;
+ ui.busy = true; ui.nameClaimError = null; render();
+ try {
+ await claim(name);
+ ui.nameClaim = '';
+ toast(t('namesClaimed', { name: `${name}@${DOMAIN}` }));
+ } catch (e) { ui.nameClaimError = e.message; }
+ ui.busy = false; render();
+ } }, t('namesClaim'))));
+ }
+
+ return {
+ id: 'names',
+ init() { refresh(); },
+ // BEFORE zaps in the registry: a pasted user@domain tries DNS first.
+ matchSendText(text, typed) {
+ if (typed) return false; // don't yank the form away mid-keystroke
+ if (!ui.send || ui.send.recipients.length !== 1) return false;
+ return beginResolve(text);
+ },
+ settingsCards() { return [namesCard()]; },
+ };
+}
diff --git a/src/features/zaps.js b/src/features/zaps.js
index 2667921..71a94af 100644
--- a/src/features/zaps.js
+++ b/src/features/zaps.js
@@ -156,6 +156,15 @@ export function zapsFeature(ctx) {
onClick: () => begin(target, display),
}, '⚡ ' + t('lnZapOffer', { addr: display }));
},
+ // The names feature resolves user@domain over DNS first (BIP-353) and
+ // lands here when the name has no DNS record — the classic LNURL path.
+ lnAddressFallback(text) {
+ if (!canPay() || !ui.send || ui.send.recipients.length !== 1) return false;
+ const target = parseZapTarget(text);
+ if (!target || target.kind === 'npub') return false;
+ begin(target, target.address || String(text).trim());
+ return true;
+ },
// True when an npub can be zapped over Lightning from this build/wallet —
// ark's "no Ark address" screen checks this before offering the fallback.
canLnZap() { return canPay() && !!wallet.nostrProfile; },