// 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 { decodeNoffer } from '../noffer.js'; import { nip98Header } from '../nip98.js'; import { qrSvg } from '../qr.js'; import { bech32 } from '@scure/base'; import { isArkAddress } from './ark.js'; import { getNetwork } from '../api.js'; import { t } from '../i18n.js'; const REGISTRAR = 'https://names.coinos.io'; // Claims are namespaced by network: staging's mutinynet wallets get // name@staging.coinos.io, so play names (and their testnet ark addresses) // never squat the real coinos.io namespace. Records saved under either // domain keep working — st.domain wins over the default everywhere. const DOMAIN = () => (getNetwork() === 'mutinynet' ? 'staging.coinos.io' : 'coinos.io'); const AUTH_KIND = 21353; export function namesFeature(ctx) { const { h, ui, render, wallet, hook, toast, copy, copyBtn, brandHeader, goBack, setAccount } = 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; } // One name PER DOMAIN: mainnet and staging claims live side by side in the // wallet's state instead of overwriting each other on a network switch. // Legacy flat state ({name, domain, …}) reads/migrates into its domain slot. const loadAll = () => wallet.loadFeatureState('names', {}); const load = () => { const all = loadAll(); if (all.byDomain) return all.byDomain[DOMAIN()] || {}; if (all.name) return (all.domain || 'coinos.io') === DOMAIN() ? all : {}; return {}; }; const save = (st) => { const all = loadAll(); const byDomain = all.byDomain || {}; if (!all.byDomain && all.name) { byDomain[all.domain || 'coinos.io'] = { name: all.name, domain: all.domain || 'coinos.io', uri: all.uri, offerPk: all.offerPk, updated: all.updated }; } byDomain[DOMAIN()] = st; wallet.saveFeatureState('names', { byDomain }); }; // Mainnet and mutinynet: staging's play network gets the full payment- // address experience (claims already registered fine — the record simply // carries a testnet ark address). Ark-to-name sends between mutinynet // wallets resolve and pay normally; only foreign-LNURL receives assume // mainnet. NB the names live in the ONE shared registry either way. const available = () => ['mainnet', 'mutinynet'].includes(getNetwork()) && !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, quietProfile, noffer, lno } = {}) { const uri = await currentUri(); if (!uri) throw new Error(t('namesNeedArk')); // Own-node fields (delivery noffer / published BOLT 12 offer): undefined // keeps whatever the record carries (here or registrar-side), '' clears, // a string sets. const nof = noffer !== undefined ? noffer : (load().noffer || undefined); const ownLno = lno !== undefined ? lno : (load().lno || undefined); 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 } : {}), ...(nof !== undefined ? { noffer: nof } : {}), ...(ownLno !== undefined ? { lno: ownLno } : {}), }, signer); const prev = load().name; save({ ...load(), name, domain: DOMAIN(), uri, offerPk: hook('nwcOfferPubkey') || null, noffer: nof || null, lno: ownLno || null, updated: Date.now() }); // Releasing the previous name is for renames the user meant. A CUSTOM // name must never be released because a placeholder claim raced in: that // exact race once deleted a user's claimed name — adoptIdentity's npub // claim landed in the same instant refresh() was adopting their real // name, and `prev` here picked up the fresher read. const demotion = /^npub1/.test(name) && prev && !/^npub1/.test(prev); if (prev && prev !== name && !demotion) { post('/register', 'DELETE', { name: prev, domain: DOMAIN() }).catch(() => {}); // the old address just died — a kind 0 still pointing at it would send // zaps into the void, so the profile's lud16/nip05 follow the rename // (messages only touches them if they pointed at the released name). // quietProfile: the profile editor claims mid-save and folds these // fields into its own publish — a second publish here would race it. if (!quietProfile) Promise.resolve(hook('addressRenamed', `${prev}@${DOMAIN()}`, `${name}@${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({}); } // Only an explicit, rejected name submission can offer migration. A legacy // account still exists after moving to v3, and its `migrated` flag predates // v3, so the registrar record is what tells us the name has already moved. async function claimRequestedName(name, opts = {}) { const domain = DOMAIN(); try { return await claim(name, opts); } catch (err) { if (domain === 'coinos.io' && ['name is taken', 'name is reserved'].includes(err.message)) { try { const r = await fetch(`${REGISTRAR}/name/${encodeURIComponent(name)}?domain=${encodeURIComponent(domain)}`, { signal: AbortSignal.timeout(8000), cache: 'no-store' }); const record = r.ok ? await r.json() : null; if (record?.name === name && record.domain === domain && record.reserved === true && !record.pubkey) { const legacy = await fetch(`https://coinos.io/api/users/${encodeURIComponent(name)}`, { signal: AbortSignal.timeout(8000), cache: 'no-store' }); const user = legacy.ok ? await legacy.json() : null; if (user?.username?.toLowerCase() === name) err.migrationName = name; } } catch {} // An unavailable lookup leaves the original claim error. } throw err; } } // 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}?domain=${encodeURIComponent(DOMAIN())}`).then((x) => x.json()), 12000, 'registrar'); if (r && r.name) save({ ...load(), name: r.name, domain: r.domain || DOMAIN(), uri: r.uri, ...(r.noffer !== undefined ? { noffer: r.noffer } : {}), ...(r.lno !== undefined ? { lno: r.lno } : {}) }); } 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(); } // A custom name claimed on ANOTHER DEVICE by the login identity beats a // local npub placeholder: the username people know the user by should // show everywhere they sign in, not only where they typed it. if (!st.name || /^npub1/.test(st.name)) { const login = hook('nostrLoginIdentity'); if (login && login.pubkey) { try { const r = await withTimeout( fetch(`${REGISTRAR}/pubkey/${login.pubkey}?domain=${encodeURIComponent(DOMAIN())}`).then((x) => x.json()), 8000, 'registrar'); if (r && r.name && !/^npub1/.test(r.name)) { save({ ...st, name: r.name, domain: r.domain || DOMAIN(), uri: r.uri, ...(r.noffer !== undefined ? { noffer: r.noffer } : {}), ...(r.lno !== undefined ? { lno: r.lno } : {}) }); st = load(); } } catch {} } } // A name the registrar no longer grants us is a costume, not an // address: verify ownership and drop stale local state (a record can // be evicted server-side — a squatted legacy name once lingered in // the squatter's UI this way). Registrar outages keep the benefit of // the doubt. if (st.name) { try { const r = await withTimeout( fetch(`${REGISTRAR}/name/${encodeURIComponent(st.name)}?domain=${encodeURIComponent(st.domain || DOMAIN())}`).then((x) => x.json()), 8000, 'registrar'); // The linked login identity counts even while its signer hasn't // resumed yet (a bunker/Google login comes back seconds after // load): its pubkey is on record. Without this, a name owned by // the login identity — every migrated coinos.io name — was judged // "not ours" in that window, dropped, and replaced by the wallet // key's npub-shaped default: the name visibly flipped to npub… const stored = (() => { try { return (wallet.loadFeatureState('nostrlogin', {}) || {}).pubkey; } catch { return null; } })(); const mine = new Set([wallet.nostrPubkey && wallet.nostrPubkey(), (hook('nostrLoginIdentity') || {}).pubkey, stored].filter(Boolean)); const ours = r && r.taken && (mine.has(r.pubkey) || mine.has(r.manager)); if (r && !ours) { save({}); st = load(); render(); } } catch {} } 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); let st = load(); // A restored wallet may not know its name yet — ask the registrar before // assuming there is none. if (!st.name) { await lookupMine(); st = load(); } // The LOGIN identity may already own a custom name, claimed in a past // session or on another device — find and adopt it BEFORE reaching for a // placeholder (lookupMine only asks by the wallet's own key). if (!st.name || /^npub1/.test(st.name)) { try { const r = await withTimeout( fetch(`${REGISTRAR}/pubkey/${signer.pubkey}?domain=${encodeURIComponent(DOMAIN())}`).then((x) => x.json()), 8000, 'registrar'); if (r && r.name && !/^npub1/.test(r.name)) { save({ ...load(), name: r.name, domain: r.domain || DOMAIN(), uri: r.uri }); render(); return r.name; } } catch {} st = load(); // refresh() may have adopted a name while we were looking } // The npub default is a placeholder for wallets with no name. Adopting it // must never displace a custom name — claim() releases the previous name, // so getting this wrong deletes the user's address from the registrar. if (st.name && !/^npub1/.test(st.name)) return st.name; if (st.name === name) return name; await claim(name, { signer, manager: wallet.nostrPubkey() }); render(); return name; } // ---- sending to a name ------------------------------------------------- // Our own names when DNS is late (a fresh claim still inside the negative- // cache window) or validation hiccups: the registrar IS the source DNS // mirrors, so ask it directly instead of dead-ending in the Lightning // fallback — which for staging names points at a webroot that serves HTML. const OUR_DOMAINS = ['coinos.io', 'staging.coinos.io', 'halwallet.app']; async function registrarLookup(name, domain) { try { const r = await withTimeout( fetch(`${REGISTRAR}/name/${encodeURIComponent(name)}?domain=${encodeURIComponent(domain)}`).then((x) => x.json()), 8000, 'registrar'); return r && r.taken ? { uri: r.uri || null, pubkey: /^[0-9a-f]{64}$/.test(r.pubkey || '') ? r.pubkey : null } : null; } catch { return null; } } const registrarUri = (name, domain) => registrarLookup(name, domain).then((r) => (r && r.uri) || null); function beginResolve(text) { const parsed = parsePaymentName(text); if (!parsed) return false; ui.nameResolve = { text, status: 'resolving' }; render(); (async () => { let uri = null, resolveErr = null; // our registrar also knows WHO owns the name: the send form shows the // person (face + name) rather than the ark address the name resolves // to, which reads as gibberish to anyone new to this const ownerP = OUR_DOMAINS.includes(parsed.domain) ? registrarLookup(parsed.name, parsed.domain) : Promise.resolve(null); try { uri = await resolveBip353(parsed.name, parsed.domain); } catch (e) { resolveErr = e; } if (ui.nameResolve?.text !== text) return; const owner = await ownerP; if (!uri && owner) uri = owner.uri; if (ui.nameResolve?.text !== text) return; ui.nameResolve = null; if (!uri && resolveErr) { ui.sendError = `${parsed.name}@${parsed.domain}: ${resolveErr.message}`; render(); return; } if (uri) { const dec = parseBip21(uri); const ark = dec?.params?.ark; if (ark && isArkAddress(ark) && hook('arkReady')) { // Your own name is a top-up, not a payment: the ark feature routes // it straight to the board panel instead of filling a send form // whose review would only send it back there. if (await Promise.resolve(hook('arkBoardIfOwn', ark)).catch(() => false)) return; ui.send.recipients[0].address = ark; ui.send.recipients[0].via = { name: `${parsed.name}@${parsed.domain}`, ark, pk: (owner && owner.pubkey) || null }; render(); return; } // no payable ark instruction: a bolt12 offer (a foreign node, or a // name delivered to its owner's own node) beats falling to on-chain if (dec?.params?.lno && hook('startBolt12Pay', dec.params.lno, `${parsed.name}@${parsed.domain}`)) { 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 — except // staging names, which have no LNURL endpoint (the zap would fetch the // web app's HTML and choke); an unknown staging name is simply unknown. if (parsed.domain === 'staging.coinos.io' || !hook('lnAddressFallback', text)) { ui.sendError = t('namesNotFound', { name: `${parsed.name}@${parsed.domain}` }); } render(); })(); return true; } // ---- UI --------------------------------------------------------------- // Suggest the identity's own nostr username as the claim default — nobody // should have to retype who they already are — but only when that name is // actually free on the registrar (or already theirs to retake). let lnReceipts = { at: 0, p: null }; let suggestedFor = null; function suggestName() { const me = (hook('nostrLoginIdentity') || {}).pubkey || (wallet.nostrPubkey && wallet.nostrPubkey()); if (!me || suggestedFor === me || ui.nameClaim) return; suggestedFor = me; (async () => { try { const prof = hook('cachedProfile', me); const cand = String((prof && prof.name) || '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 30); if (!cand || /^npub1/.test(cand)) return; const r = await withTimeout( fetch(`${REGISTRAR}/name/${encodeURIComponent(cand)}?domain=${encodeURIComponent(DOMAIN())}`).then((x) => x.json()), 8000, 'registrar'); const free = r && r.taken === false; const mine = r && r.taken && r.pubkey === me; if ((free || mine) && !ui.nameClaim) { ui.nameClaim = cand; render(); } } catch {} })(); } function claimForm(big) { suggestName(); 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(); if (ui.nameClaimMigration) { ui.nameClaimMigration = null; render(); } }, }), 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; ui.nameClaimMigration = 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 claimRequestedName(name, signer ? { signer, manager: wallet.nostrPubkey() } : {}); ui.nameClaim = ''; ui.nameEditOpen = false; toast(t('namesClaimed', { name: `${name}@${DOMAIN()}` })); } catch (e) { ui.nameClaimError = e.message; if (ui.nameClaim === name) ui.nameClaimMigration = e.migrationName || null; } ui.busy = false; render(); } }, ui.busy ? h('span', { class: 'spinner' }) : t('namesClaim')), ui.nameClaimMigration && ui.nameClaimMigration === ui.nameClaim && DOMAIN() === 'coinos.io' ? h('button', { type: 'button', class: 'linklike small', onClick: () => { if (ui.onb?.step === 'spend') { ui.onbError = ''; ui.onb.step = 'legacy'; render(); } else { const st = load(); if (!st.name) return; const addr = `${st.name}@${st.domain || DOMAIN()}`; location.href = `https://coinos.io/migrate?to=${encodeURIComponent(addr)}&back=${encodeURIComponent(location.origin + '/')}`; } }, }, t('onbHaveCoinos')) : null); } // Settings: one card per concern, all laid out (no collapsed sections — // Adam: "I don't like how so much is hidden behind toggles"). The settings // page reverses the collected list, so this returns bottom-to-top. function namesCards() { if (!available()) return []; const st = load(); if (!st.name) { return [h('div', { class: 'card col', 'data-key': 'names' }, h('h3', {}, t('namesTitle')), h('p', { class: 'small muted', style: 'margin:0' }, t('namesDesc')), claimForm(false))]; } const domain = st.domain || DOMAIN(); const addr = `${st.name}@${domain}`; const card = (key, title, how, ...body) => h('div', { class: 'card col', 'data-key': key }, h('h3', {}, title), how ? h('p', { class: 'small muted', style: 'margin:0' }, how) : null, ...body); // POS surfaces are MERCHANT tools: hidden for everyone else behind a // one-line toggle on the address card. The flag lives in the names // feature state, so it follows the account to every device via sync. return [ card('owndomain', t('namesOwnDomain'), 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}.`)), card('ownnode', t('namesOwnNode'), t('namesOwnNodeMenuHow'), ownNodeSection()), card('custom', t('namesCustom'), t('namesCustomHow'), claimForm(false)), card('names', t('namesTitle'), null, h('div', { class: 'addr-box break', style: 'font-size:14px' }, addr), h('div', { class: 'row gap6' }, copyBtn(addr, t('namesCopy')), h('button', { class: 'btn-ghost btn-sm', onClick: async () => { await release(); toast(t('namesReleased')); render(); } }, t('namesRelease')))), ].filter(Boolean); } // The terminal's cards live on the Point of sale settings page now — no // show/hide toggle; a page of their own is the switch. function posSettingsCardsForName() { if (!available()) return []; const st = load(); if (!st.name) return []; return [posLinkCard(st), posCard(st)]; // the page reverses: tokens first, then the tap-to-pay link } // A coinos POS terminal (coinos-pos) takes payments to this name with a // bearer token the registrar mints for the owner: the terminal can ring up // sales and see them settle, never spend. Shown once, never stored — the // token goes straight into the terminal's config at coinos.io/flash. function posCard(st) { const tok = ui.posToken; const body = () => ({ name: st.name, domain: st.domain || DOMAIN() }); return h('div', { class: 'card col', 'data-key': 'pos' }, h('h3', {}, t('namesPos')), h('p', { class: 'small muted', style: 'margin:0' }, t('namesPosHow')), tok ? h('div', { class: 'addr-box break', style: 'font-size:10px' }, tok) : null, h('div', { class: 'row gap6', style: 'flex-wrap:wrap' }, h('button', { class: 'btn-ghost btn-sm', onClick: async () => { try { ui.posToken = (await post('/pos/token', 'POST', body())).token; render(); } catch (e) { toast(e.message); } } }, t('namesPosCreate')), tok ? copyBtn(tok, t('namesPosCopy')) : null, h('button', { class: 'btn-ghost btn-sm', onClick: async () => { try { await post('/pos/token', 'DELETE', body()); ui.posToken = null; toast(t('namesPosRevoked')); render(); } catch (e) { toast(e.message); } } }, t('namesPosRevoke')))); } // The link the terminal broadcasts over NFC: the name's lnurlp on the // registrar (it pins the sale just rung up), as a bech32 LNURL for wallet // scanners and wrapped in coinos.io/ln/ for phones without one. function posLinkCard(st) { const domain = st.domain || DOMAIN(); const payUrl = `${REGISTRAR}/lnurlp/${st.name}${domain === 'coinos.io' ? '' : `?domain=${encodeURIComponent(domain)}`}`; const lnurl = bech32.encode('lnurl', bech32.toWords(new TextEncoder().encode(payUrl)), 2000).toUpperCase(); const link = `https://coinos.io/ln/${lnurl.toLowerCase()}`; return h('div', { class: 'card col', 'data-key': 'poslink' }, h('h3', {}, t('namesPosLink')), h('p', { class: 'small muted', style: 'margin:0' }, t('namesPosLinkHow')), // the QR is itself a lightning: link — tapping it on the very device // that shows it hands the LNURL to whatever wallet owns the scheme h('a', { href: 'lightning:' + lnurl.toLowerCase(), style: 'align-self:center;max-width:220px;display:block', html: qrSvg(lnurl, { ec: 'L', mode: 'Alphanumeric' }), }), h('div', { class: 'addr-box break', style: 'font-size:10px' }, lnurl), h('div', { class: 'row gap6', style: 'flex-wrap:wrap' }, // copied as a lightning: URI so it's tappable/pasteable anywhere; // the QR stays bare uppercase for alphanumeric-mode density copyBtn('lightning:' + lnurl.toLowerCase(), t('namesPosCopyLnurl')), copyBtn(link, t('namesPosCopyLink')))); } // 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 more-options 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.namesMore) return h('div', { class: 'card col', style: 'align-items:center;gap:14px' }, seg, moreSection()); 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, // copying lives right where the name is; changing the name moved to // the profile editor's username field — two doors was one too many h('span', { class: 'addr-actions' }, h('button', { title: t('namesCopy'), onClick: () => copy(addr), html: '', }))), moreSection()); } // ---- 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 offerPane() { const offers = Array.isArray(ui.namesOffers) ? ui.namesOffers : []; return h('div', { class: 'col', style: 'gap:10px;width:100%' }, 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))); } // ---- one-time BOLT 11 invoice (minted by the ASP against this wallet) --- async function createInvoice() { const inv = ui.namesInv || (ui.namesInv = {}); const sat = Math.floor(Number(inv.amount)); if (!sat || sat < 1) { inv.error = t('namesInvBadAmount'); render(); return; } inv.busy = true; inv.error = null; render(); try { const a = await hook('arkMakeInvoice', sat, (inv.memo || '').trim().slice(0, 100)); inv.invoice = a.invoice; } catch (e) { inv.error = e.message; } inv.busy = false; render(); } function invoicePane() { const inv = ui.namesInv || (ui.namesInv = {}); if (inv.invoice) return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, h('div', { html: qrSvg(inv.invoice.toUpperCase(), { ec: 'L', mode: 'Alphanumeric' }) }), h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, inv.invoice), h('div', { class: 'row gap6' }, copyBtn(inv.invoice, t('copy')), h('button', { class: 'btn-sm', onClick: () => { ui.namesInv = {}; render(); } }, t('namesInvNew')))); return h('div', { class: 'col', style: 'gap:10px;width:100%' }, h('input', { type: 'number', inputmode: 'numeric', min: '1', placeholder: t('namesInvAmountPh'), value: inv.amount || '', onInput: (e) => { inv.amount = e.target.value; }, }), h('input', { type: 'text', maxlength: '100', placeholder: t('namesInvMemoPh'), value: inv.memo || '', onInput: (e) => { inv.memo = e.target.value; }, }), inv.error ? h('div', { class: 'notice err small' }, inv.error) : null, h('button', { class: 'btn-primary btn-block', disabled: !!inv.busy, onClick: createInvoice }, inv.busy ? h('span', { class: 'spinner sm' }) : t('namesInvCreate'))); } // ---- the raw ark address & the CLINK offer code ------------------------- function arkPane() { if (!ui.namesArkAddr) { ui.namesArkAddr = 'loading'; Promise.resolve(hook('arkStaticAddress')) .then((a) => { ui.namesArkAddr = a ? { address: a } : { error: t('namesNeedArk') }; }) .catch((e) => { ui.namesArkAddr = { error: e.message }; }) .then(render); } const st = ui.namesArkAddr; if (st === 'loading') return h('span', { class: 'spinner', style: 'align-self:center' }); if (st.error) return h('div', { class: 'notice err small', style: 'width:100%' }, st.error); return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, h('div', { html: qrSvg(st.address) }), h('div', { class: 'addr-box break', style: 'width:100%;font-size:11px' }, st.address), copyBtn(st.address, t('copy'))); } // Point the address at the user's own node, two complementary ways: // - noffer: a CLINK service beside their node (Lightning.Pub etc.) mints // the invoices the registrar hands out and pays — LN delivery lands on // their node instead of in this wallet. // - lno: their node's own BOLT 12 offer goes into the DNS record, so // BIP-353 senders fetch invoices straight from their node — no coinos // hop at all. async function setOwnNode(field, raw) { const st = load(); if (!st.name) { ui.namesOwnErr = t('namesNeedArk'); render(); return; } ui.namesOwnBusy = field; ui.namesOwnErr = null; render(); try { if (raw && field === 'noffer') { const d = decodeNoffer(raw); if (!d.relay) throw new Error(t('namesOwnNodeNoRelay')); } if (raw && field === 'lno' && !/^lno1[a-z0-9]+$/i.test(raw)) throw new Error(t('namesOwnLnoBad')); await claim(st.name, { [field]: raw || '' }); ui.namesOwnInput = { ...ui.namesOwnInput, [field]: '' }; toast(raw ? t('namesOwnNodeSet') : t('namesOwnNodeCleared')); } catch (e) { ui.namesOwnErr = e.message; } ui.namesOwnBusy = null; render(); } function ownNodeField(field, current, placeholder, how) { const busy = ui.namesOwnBusy === field; if (current) { return h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted' }, t(field === 'noffer' ? 'namesOwnNodeActive' : 'namesOwnLnoActive')), h('div', { class: 'addr-box break', style: 'font-size:10px' }, current), h('button', { class: 'btn-ghost btn-sm', disabled: !!ui.namesOwnBusy, onClick: () => setOwnNode(field, '') }, busy ? h('span', { class: 'spinner sm' }) : t('namesOwnNodeClear'))); } return h('div', { class: 'col', style: 'gap:8px' }, h('div', { class: 'small muted' }, how), h('div', { class: 'row gap6' }, h('input', { type: 'text', class: 'grow mono-input', placeholder, value: (ui.namesOwnInput || {})[field] || '', onInput: (e) => { ui.namesOwnInput = { ...ui.namesOwnInput, [field]: e.target.value }; }, }), h('button', { class: 'btn-sm', disabled: !!ui.namesOwnBusy, onClick: () => setOwnNode(field, ((ui.namesOwnInput || {})[field] || '').trim()) }, busy ? h('span', { class: 'spinner sm' }) : t('save')))); } // The section body; the title comes from wherever it's mounted (the More // menu pane, or the Settings card's details summary). function ownNodeSection() { const st = load(); if (!st.name) return h('div', { class: 'small muted' }, t('namesNeedArk')); return h('div', { class: 'col', style: 'gap:10px;width:100%;margin-top:6px' }, ownNodeField('noffer', st.noffer, 'noffer1…', t('namesOwnNodeHow')), ownNodeField('lno', st.lno, 'lno1…', t('namesOwnLnoHow')), ui.namesOwnErr ? h('div', { class: 'notice err small' }, ui.namesOwnErr) : null); } function clinkPane() { const code = hook('nwcOfferString'); if (!code) return h('div', { class: 'notice err small', style: 'width:100%' }, t('namesNeedArk')); return h('div', { class: 'col', style: 'gap:10px;width:100%;align-items:center' }, h('div', { html: qrSvg(code) }), h('div', { class: 'addr-box break', style: 'width:100%;font-size:10px' }, code), copyBtn(code, t('copy')), h('p', { class: 'small muted', style: 'margin:0' }, t('namesZapCodeHow'))); } // ---- more options ------------------------------------------------------- // The address covers almost everyone; behind one link live the other codes // a payer might insist on. function menuPane() { const opt = (id, label, how, onOpen) => h('div', { class: 'item col clickable', style: 'gap:2px;align-items:flex-start', onClick: () => { ui.namesMore = id; if (onOpen) onOpen(); render(); }, }, h('strong', { class: 'small' }, label), h('span', { class: 'small muted' }, how)); return h('div', { class: 'list', style: 'width:100%' }, opt('bolt11', t('namesMoreBolt11'), t('namesMoreBolt11How')), opt('bolt12', t('namesMoreBolt12'), t('namesMoreBolt12How'), loadOffers), opt('ark', t('namesMoreArk'), t('namesMoreArkHow')), opt('clink', t('namesMoreClink'), t('namesMoreClinkHow')), opt('ownnode', t('namesOwnNode'), t('namesOwnNodeMenuHow')), // Not a pane: a plain on-chain address means receiving to Savings, so // this hands over to the savings receive view instead of showing a // second address here. opt('onchain', t('namesMoreOnchain'), t('namesMoreOnchainHow'), () => { ui.namesMore = null; setAccount('savings'); })); } function moreSection() { if (!ui.namesMore) { return h('button', { class: 'linklike small', onClick: () => { ui.namesMore = 'menu'; render(); }, }, t('namesMoreOpen')); } const titles = { menu: t('namesMoreTitle'), bolt11: t('namesMoreBolt11'), bolt12: t('namesMoreBolt12'), ark: t('namesMoreArk'), clink: t('namesMoreClink'), ownnode: t('namesOwnNode'), }; const body = ui.namesMore === 'bolt11' ? invoicePane() : ui.namesMore === 'bolt12' ? offerPane() : ui.namesMore === 'ark' ? arkPane() : ui.namesMore === 'clink' ? clinkPane() : ui.namesMore === 'ownnode' ? ownNodeSection() : menuPane(); return h('div', { class: 'col', style: 'gap:10px;width:100%' }, h('div', { class: 'row between', style: 'align-items:baseline' }, h('strong', {}, titles[ui.namesMore] || titles.menu), h('button', { class: 'linklike small', onClick: () => { ui.namesMore = ui.namesMore === 'menu' ? null : 'menu'; render(); }, }, t('back'))), body); } return { id: 'names', init() { checked = false; lastError = null; // The claim field is a draft for THIS account — a name typed before a // logout used to greet the next account that opened on this device. ui.nameClaim = null; ui.nameClaimError = null; ui.nameClaimMigration = null; suggestedFor = 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); }, // Recent LN settlements the registrar forwarded to this wallet's name(s) // as float arkoors — the ark feature matches them against its receive // movements to put ⚡ on the rows that actually arrived over Lightning. // Lives here because the registrar call needs the wallet's NIP-98 // signer. Cached briefly: a receive burst asks more than once. namesLnReceipts() { if (!available()) return Promise.resolve([]); if (lnReceipts.p && Date.now() - lnReceipts.at < 30_000) return lnReceipts.p; lnReceipts = { at: Date.now(), p: post('/lnreceipts', 'GET') .then((j) => j.receipts || []) .catch(() => { lnReceipts.at = 0; return []; }), }; return lnReceipts.p; }, // the claimed payment address, for anyone prefilling a lightning address namesAddress() { const st = load(); return st.name ? `${st.name}@${st.domain || DOMAIN()}` : null; }, // Sign-in races this lookup against the seed work: by the time the // wallet opens, the answer is usually already in hand… namesLookupByPk(pk) { if (!pk) return null; return fetch(`${REGISTRAR}/pubkey/${pk}?domain=${encodeURIComponent(DOMAIN())}`) .then((x) => x.json()).catch(() => null); }, // …and this seeds it into state just before the first paint, so the // Receive pane opens with the QR instead of "setting up your address". namesSeed(rec) { if (!rec || !rec.name) return null; const st = load(); if (!st.name) save({ name: rec.name, domain: rec.domain || DOMAIN(), uri: rec.uri, noffer: rec.noffer ?? null, lno: rec.lno ?? null }); return true; }, // 'pending' while the restore/claim pass is still running — the wallet // screen holds spending-setup prompts until the name question is settled. namesSettled() { return checked ? 'yes' : 'pending'; }, // the wizard offers Spending only where a name can be claimed — a word, // not a boolean: featureHook reads a false answer as no answer at all namesAvailable() { return available() ? 'yes' : 'no'; }, // A seed minted seconds ago has nothing to restore anywhere: don't make // the spending-setup ask wait out registrar round trips that only exist // for wallets with a history. The background claim still runs. identityGenerated() { checked = true; }, // 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, opts) { return claimRequestedName(String(name).toLowerCase(), opts || {}); }, stop() { clearTimeout(retryTimer); clearTimeout(deadline); deadline = null; }, receiveModes() { if (!available()) return []; return [{ id: 'name', label: t('receiveNameTab'), icon: '', render: (seg) => namePane(seg), }]; }, // Is a typed name real yet? (the send form asks once typing pauses, and // only moves on when something answers — never on a half-typed domain) async probeLnAddress(text) { const parsed = parsePaymentName(text); if (!parsed) return false; if (OUR_DOMAINS.includes(parsed.domain)) return !!(await registrarLookup(parsed.name, parsed.domain).catch(() => null)); return !!(await resolveBip353(parsed.name, parsed.domain).catch(() => null)); }, // 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); }, resetSend() { ui.nameResolve = null; }, // DNS resolution takes visible time — say so under the recipient field // instead of leaving a picked name looking ignored. sendFormNote(a) { const nr = ui.nameResolve; if (nr && nr.text === a) { return h('div', { class: 'row gap6', style: 'align-items:center;justify-content:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('namesResolving', { name: String(a || '').trim() }))); } // A TYPED name on one of our domains: offer the native send (BIP-353 → // instant ark) as a button, mirroring the zap affordance — typed input // never auto-advances, since a half-typed domain parses as a name too. // This note outranks the zaps one (feature order), so our names get the // free instant rail instead of an LNURL round-trip. const parsed = parsePaymentName(a); if (parsed && OUR_DOMAINS.includes(parsed.domain) && hook('arkReady')) { return h('button', { type: 'button', class: 'btn-primary btn-block', onClick: () => beginResolve(a), }, t('namesSendTo', { name: `${parsed.name}@${parsed.domain}` })); } return null; }, settingsCards() { return namesCards(); }, posSettingsCards() { return posSettingsCardsForName(); }, // the Ark server changed: the name's ark= instruction must follow namesRefresh() { refresh().catch(() => {}); return true; }, // Settings → Nostr: the CLINK zap code (a nostr offer, so it lives with // the other nostr settings rather than under the payment address). nostrSettingsCards() { const code = available() && hook('nwcOfferString'); return code ? [h('div', { class: 'card col', 'data-key': 'zapcode' }, h('h3', {}, t('namesZapCode')), h('p', { class: 'small muted', style: 'margin:0' }, t('namesZapCodeHow')), h('div', { class: 'addr-box break', style: 'font-size:10px' }, code), h('div', { class: 'row gap6' }, copyBtn(code, t('namesZapCodeCopy'))))] : []; }, }; }