// Payment names — BIP-353 DNS payment instructions.
//
// Receiving: claim ₿name@halwallet.app. 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 { nip98Header } from '../nostr-login.js';
import { qrSvg } from '../qr.js';
import { isArkAddress } from './ark.js';
import { getNetwork } from '../api.js';
import { t } from '../i18n.js';
const REGISTRAR = 'https://names.coinos.io';
const DOMAIN = 'coinos.io';
const AUTH_KIND = 21353;
export function namesFeature(ctx) {
const { h, ui, render, wallet, hook, toast, copy, copyBtn, brandHeader, goBack } = ctx;
// The wallet's own key signs registrar requests: it's the one key that is
// always available — offline, in the background, with no signer attached.
const walletSigner = () => ({
pubkey: wallet.nostrPubkey && wallet.nostrPubkey(),
signEvent: async (e) => wallet.nostrSign(e),
});
// Nothing here may hang: a stuck signer prompt or a dead endpoint must
// surface as an error, not as a spinner that never stops.
const withTimeout = (p, ms, what) => Promise.race([
p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms)),
]);
async function post(path, method, payload, signer) {
const url = `${REGISTRAR}${path}`;
const hasBody = method !== 'GET';
const body = hasBody ? JSON.stringify(payload) : '';
const auth = await withTimeout(nip98Header(signer || walletSigner(), url, method, body), 12000, 'signing');
const r = await withTimeout(fetch(url, {
method, headers: { 'content-type': 'application/json', authorization: auth }, ...(hasBody ? { body } : {}),
}), 12000, 'registrar');
const j = await r.json().catch(() => ({}));
if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
return j;
}
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 withTimeout(hook('arkStaticAddress'), 12000, 'ark');
return addr ? `bitcoin:?ark=${encodeURIComponent(addr)}` : null;
}
async function claim(name, { signer, manager } = {}) {
const uri = await currentUri();
if (!uri) throw new Error(t('namesNeedArk'));
const j = await post('/register', 'POST', {
name, uri, domain: DOMAIN,
// lets the address take Lightning payments from LNURL-only wallets:
// the registrar asks this key for an invoice before minting its own
...(hook('nwcOfferPubkey') ? { offerPk: hook('nwcOfferPubkey') } : {}),
// when the login identity claims its own npub name, it nominates the
// wallet key to keep the record updated afterwards
...(manager ? { manager } : {}),
}, signer);
const prev = load().name;
save({ name, domain: DOMAIN, uri, offerPk: hook('nwcOfferPubkey') || null, updated: Date.now() });
if (prev && prev !== name) {
post('/register', 'DELETE', { name: prev, domain: DOMAIN }).catch(() => {});
}
return j;
}
async function release() {
const st = load();
if (!st.name) return;
await post('/register', 'DELETE', { name: st.name, domain: st.domain || DOMAIN }).catch(() => {});
save({});
}
// An imported seed (or a new browser) doesn't know its username — the
// registrar does. Ask by the wallet's nostr pubkey and adopt the answer
// before deciding whether anything needs claiming.
async function lookupMine() {
try {
const pk = wallet.nostrPubkey && wallet.nostrPubkey();
if (!pk) return;
const r = await withTimeout(
fetch(`${REGISTRAR}/pubkey/${pk}`).then((x) => x.json()), 12000, 'registrar');
if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN, uri: r.uri });
} catch (e) { console.warn('names: lookup failed —', e.message); }
}
// Everyone gets an address without being asked: the default username is a
// prefix of the wallet's npub — unique, boring, and free. Custom names are
// an optional change (and may cost something one day, to keep squatters
// out), so nothing here ever blocks the wallet.
let checked = false; // setup finished (the pane waits on this)
let retryTimer = null;
let deadline = null;
let lastError = null;
async function refresh(attempt = 0) {
clearTimeout(retryTimer);
// Whatever goes wrong, stop claiming to be "setting up" after a minute
// and show the user the manual form instead.
if (!deadline) {
deadline = setTimeout(() => { checked = true; clearTimeout(retryTimer); render(); }, 12000);
}
try {
if (!available()) {
// Ark connects lazily AFTER the wallet opens, so the first pass
// usually lands too early. Keep looking for a while rather than
// giving up and leaving the user staring at a claim box.
if (attempt < 40) { retryTimer = setTimeout(() => refresh(attempt + 1), 3000); return; }
checked = true; render(); return;
}
let st = load();
if (!st.name) { await lookupMine(); st = load(); }
if (!st.name) {
// The address people see should be the identity they know the user
// by: their real npub when a nostr account is linked, else the
// wallet's own. Claiming the real one has to be SIGNED by that
// identity (the registrar won't let anyone else take an npub-shaped
// name), so it needs a live signer — after a reload an extension can
// usually be re-attached silently; anything else falls back to the
// wallet's own identity rather than retrying a claim that can only
// ever fail.
// Claim with the WALLET's own key: it needs no signer, prompts
// nobody, and always works. Tying the address to the user's real
// npub happens at login instead (adoptIdentity below), where a
// signer is already live — an address must never wait on a popup.
const own = wallet.nostrNpub && wallet.nostrNpub();
if (own) { await claim(own.slice(0, 12), {}); st = load(); }
}
checked = true;
clearTimeout(deadline); deadline = null;
render();
if (!st.name) return;
const uri = await currentUri();
// Re-publish when the address changed OR when the registrar doesn't yet
// know this wallet's offer key — that key is what lets an incoming
// Lightning payment be delivered straight to us instead of waiting on
// the operator's float.
const offerPk = hook('nwcOfferPubkey') || null;
if (uri && (uri !== st.uri || (offerPk && st.offerPk !== offerPk))) await claim(st.name);
} catch (e) {
console.warn('names: refresh failed —', e.message);
lastError = e.message;
if (attempt < 3) { retryTimer = setTimeout(() => refresh(attempt + 1), 4000); return; }
checked = true;
render();
}
}
// Called right after a nostr login, while that signer is definitely live:
// move the payment address onto the user's real npub and nominate the
// wallet key to keep the record updated afterwards.
async function adoptIdentity(signer, npub) {
if (!signer || !npub || !available()) return null;
const name = npub.slice(0, 12);
if (load().name === name) return name;
await claim(name, { signer, manager: wallet.nostrPubkey() });
render();
return name;
}
// ---- 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 claimForm(big) {
return h('div', { class: 'col', style: 'gap:8px;width:100%' },
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' + (big ? ';font-size:18px' : ''),
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
value: ui.nameClaim || '',
onInput: (e) => { ui.nameClaim = e.target.value.toLowerCase().trim(); },
}),
h('span', { class: 'muted', style: 'align-self:center' }, '@' + DOMAIN)),
h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => {
const name = (ui.nameClaim || '').toLowerCase().trim();
if (!name) return;
ui.busy = true; ui.nameClaimError = null; render();
try {
// Sign with the login identity when one can be (re)attached: the
// registrar lets a migrated coinos user's own identity take their
// name back, which the wallet key alone could never prove.
const signer = await Promise.resolve(hook('nostrLoginResume')).catch(() => null);
await claim(name, signer ? { signer, manager: wallet.nostrPubkey() } : {});
ui.nameClaim = '';
ui.nameEditOpen = false;
toast(t('namesClaimed', { name: `${name}@${DOMAIN}` }));
} catch (e) { ui.nameClaimError = e.message; }
ui.busy = false; render();
} }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')));
}
function namesCard() {
if (!available()) return null;
const st = load();
if (st.name) {
const addr = `${st.name}@${st.domain || 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}@${st.domain || 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('namesCustom')),
h('p', { style: 'margin:4px 0' }, t('namesCustomHow')),
claimForm(false)),
(() => {
const code = hook('nwcOfferString');
return code ? h('details', { class: 'small faint' },
h('summary', {}, t('namesZapCode')),
h('p', { style: 'margin:4px 0' }, t('namesZapCodeHow')),
h('div', { class: 'addr-box break', style: 'font-size:10px' }, code),
copyBtn(code, t('namesZapCodeCopy'))) : null;
})(),
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.${st.domain || DOMAIN}.`)));
}
return h('div', { class: 'card col' },
h('h3', {}, t('namesTitle')),
h('p', { class: 'small muted', style: 'margin:0' }, t('namesDesc')),
claimForm(false));
}
// The Receive tab's default pane: your name, big and scannable.
function namePane(seg) {
const st = load();
if (!st.name && !checked) {
return h('div', { class: 'card col', style: 'align-items:center;gap:10px' },
seg, h('span', { class: 'spinner' }),
h('div', { class: 'small muted' }, t('namesSettingUp')));
}
if (!st.name) {
return h('div', { class: 'card col', style: 'gap:10px' },
seg,
lastError ? h('div', { class: 'notice err small' }, t('namesAutoFailed', { why: lastError })) : null,
h('p', { class: 'small muted', style: 'margin:0' }, t('namesDesc')),
claimForm(false));
}
const addr = `${st.name}@${st.domain || DOMAIN}`;
// The offer section takes over the card when open: two payment codes on
// screen at once is a good way to have someone scan the wrong one.
if (ui.namesOfferOpen) return h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, seg, offerSection());
return h('div', { class: 'card col', style: 'align-items:center;gap:14px' },
seg,
h('div', { html: qrSvg(addr) }),
h('div', { class: 'addr-box addr-editable', style: 'width:100%;font-size:16px' },
addr,
// editing and copying live right where the name is
h('span', { class: 'addr-actions' },
h('button', {
title: t('namesCustom'),
onClick: () => { ui.nameEditOpen = true; ui.nameClaimError = null; render(); },
html: '',
}),
h('button', {
title: t('namesCopy'),
onClick: () => copy(addr),
html: '',
}))),
offerSection());
}
// ---- BOLT 12 offers with a memo ----------------------------------------
// A reusable offer some payers want instead of a lightning address (mining
// pools like Ocean pay to one, and the memo is how you label the payout).
// Settled offer payments ride the registrar's forwarder into Spending, the
// same path the address already uses.
async function loadOffers() {
if (ui.namesOffers) return;
ui.namesOffers = 'loading';
try {
const j = await post('/offer', 'GET');
ui.namesOffers = j.offers || [];
} catch (e) {
ui.namesOffers = [];
ui.namesOfferError = e.message;
}
render();
}
async function createOffer() {
const memo = (ui.namesOfferMemo || '').trim();
ui.namesOfferBusy = true; ui.namesOfferError = null; render();
try {
const j = await post('/offer', 'POST', { memo });
ui.namesOffers = [
...(Array.isArray(ui.namesOffers) ? ui.namesOffers.filter((o) => o.offerId !== j.offerId) : []),
{ offerId: j.offerId, memo: j.memo, bolt12: j.bolt12 },
];
ui.namesOfferMemo = '';
} catch (e) {
ui.namesOfferError = e.message;
}
ui.namesOfferBusy = false; render();
}
function offerSection() {
if (!ui.namesOfferOpen) {
return h('button', {
class: 'linklike small',
onClick: () => { ui.namesOfferOpen = true; loadOffers(); render(); },
}, t('namesOfferOpen'));
}
const offers = Array.isArray(ui.namesOffers) ? ui.namesOffers : [];
return h('div', { class: 'col', style: 'gap:10px;width:100%' },
h('div', { class: 'row between', style: 'align-items:baseline' },
h('strong', {}, t('namesOfferTitle')),
h('button', { class: 'linklike small', onClick: () => { ui.namesOfferOpen = false; render(); } }, t('back'))),
h('div', { class: 'row gap6' },
h('input', {
type: 'text', class: 'grow', maxlength: '120',
placeholder: t('namesOfferMemoPh'),
value: ui.namesOfferMemo || '',
onInput: (e) => { ui.namesOfferMemo = e.target.value; },
}),
h('button', { class: 'btn-sm', disabled: !!ui.namesOfferBusy, onClick: createOffer },
ui.namesOfferBusy ? h('span', { class: 'spinner sm' }) : t('namesOfferCreate'))),
ui.namesOfferError ? h('div', { class: 'notice err small' }, ui.namesOfferError) : null,
ui.namesOffers === 'loading' ? h('span', { class: 'spinner sm' }) : null,
...offers.map((o) =>
h('div', { class: 'col', style: 'gap:6px;width:100%' },
h('div', { class: 'small muted' }, o.memo || t('namesOfferNoMemo')),
h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, o.bolt12),
h('div', { class: 'row gap6' },
copyBtn(o.bolt12, t('copy')),
h('button', { class: 'btn-sm', onClick: () => { ui.namesOfferQr = ui.namesOfferQr === o.offerId ? null : o.offerId; render(); } }, t('namesOfferQr'))),
ui.namesOfferQr === o.offerId
? h('div', { style: 'align-self:center', html: qrSvg(o.bolt12.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) })
: null)));
}
return {
id: 'names',
init() { checked = false; lastError = null; refresh(); },
// The pencil by the address opens this: choosing a name gets a page of
// its own — no balances, no tabs, just the name.
screenView() {
if (ui.screen !== 'wallet' || !ui.nameEditOpen) return null;
const st = load();
const addr = st.name ? `${st.name}@${st.domain || DOMAIN}` : null;
return h('div', { class: 'col', style: 'gap:16px' },
brandHeader(false),
h('div', { class: 'card col' },
h('h3', {}, t('namesCustom')),
addr ? h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr) : null,
h('p', { class: 'small muted', style: 'margin:0' }, t('namesCustomHow')),
claimForm(true)),
h('button', { class: 'btn-ghost btn-block', onClick: () => goBack(() => { ui.nameEditOpen = null; ui.nameClaimError = null; }) }, t('back')));
},
namesAdoptIdentity(signer, npub) { return adoptIdentity(signer, npub); },
// the claimed payment address, for anyone prefilling a lightning address
namesAddress() { const st = load(); return st.name ? `${st.name}@${st.domain || DOMAIN}` : null; },
// the onboarding wizard renders the same claim form on its username step
namesClaimForm() { return claimForm(true); },
// claim a specific name (the migration flow, after coinos.io released it)
namesClaimName(name) { return claim(String(name).toLowerCase()); },
stop() { clearTimeout(retryTimer); clearTimeout(deadline); deadline = null; },
receiveModes() {
if (!available()) return [];
return [{
id: 'name', label: t('receiveNameTab'),
icon: '',
render: (seg) => namePane(seg),
}];
},
// 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()]; },
};
}