sirius/js/register-flow.js
Local Dev 14cc83dfae feat(registrar): 90/10 revenue split — TLD owner earns from name mints
Every second-level name registration under a TLD now routes 90% of the
service fee to whoever holds that TLD's certificate on chain, with 10%
going to the platform address. That's the economic incentive for
minting a TLD: you earn from every name registered under it.

The mechanism, end to end:

1. resolver-web.js fetchTldMap now also records mintScriptHex — the
   scriptPubKey of the TREG output that carries each TLD's NFT.
   Exported so registrar can decode it into a cashaddr with libauth.
   MVP: this is the ORIGINAL owner; NFT transfers after mint are not
   traced yet (a follow-up will walk the chain of transfers).

2. registrar.js gains findTldOwnerAddress(client, tld) and
   splitServiceFee(sats). The split constants live at the top of the
   file (TLD_OWNER_SHARE_NUM/DEN = 90/100) so the ratio moves in one
   place. Rounding: BigInt division favours the platform on odd sat
   counts so the two shares always sum EXACTLY to the input.

3. quoteRegistration wraps the existing flow: it derives the TLD from
   the name, looks up the TLD owner, and if the owner ≠ buyer it asks
   buildRegistrationTx to add a second fee output. If the owner
   couldn't be resolved (TLD not registered, decode failure) the full
   fee stays on the platform address — the buyer still pays the same
   amount either way.

4. register-tx.js buildRegistrationTx accepts tldFeeAddress/tldFeeSats
   and, when set, emits an extra P2PKH output for the TLD owner. Sits
   between the beacon dust and the platform-fee output; outputMap
   records .tldOwnerFee so callers can find it. costs also carries
   tldOwnerFeeSats and netCostSats includes it.

5. priceSummary in registrar-config splits the 'Service fee' row into
   'Service fee — TLD owner (90%)' + 'Service fee — platform (10%)'
   whenever tldOwnerFeeSats > 0, with a per-line note explaining
   where the money goes.

Bundle: re-exported findTldOwnerAddress + splitServiceFee from
register-entry.js. Rebuilt bns-register.js (~34 kB) and deployed;
cache-buster bumped to ?v=20260908split on portal / admin /
register-flow.js.

Verified live: findTldOwnerAddress('.bch') returns the operator
cashaddr; quoteRegistration('tester42.bch') builds cleanly with a
9,000/1,000 split output pair on a 10,000-sat fee; priceSummary
renders both lines. No regressions on the TLD-mint flow (buyer IS
the TLD owner there — split short-circuits and it stays a single
fee output as before).
2026-09-08 02:42:16 +02:00

776 lines
38 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Reusable name-registration mint flow — the modal + wallet setup + on-chain
// mint sequence that used to live inside register.html. Any Sirius.X page can
// include this module and call `window.siriusRegisterName(fullName)` to open
// the flow for a specific `<label>.<tld>`. The landing's search cards call
// it directly; a legacy register.html redirect keeps old links working.
//
// Injects its own modal HTML into <body> and its own CSS into <head> on load
// so the host page needs nothing but a single script include.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260908split";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
const sats = (v) => Number(v).toLocaleString("en-US");
// ---------- inject CSS + modal HTML ----------
// The style block is a straight copy of register.html's modal/sheet styling
// — kept scoped by prefixing every selector with #sirius-register-modal so it
// cannot collide with the host page's own .modal / .sheet classes.
(function injectAssets() {
if (document.getElementById("sirius-register-flow-css")) return;
const css = document.createElement("style");
css.id = "sirius-register-flow-css";
css.textContent = `
#sirius-register-modal{position:fixed;inset:0;background:rgba(6,9,14,.82);backdrop-filter:blur(3px);
display:none;align-items:flex-start;justify-content:center;padding:3vh 1rem;overflow-y:auto;z-index:100}
#sirius-register-modal.open{display:flex}
#sirius-register-modal .sheet{background:var(--panel,#141a24);border:1px solid var(--line,rgba(255,255,255,.09));
border-radius:16px;max-width:620px;width:100%;padding:26px 28px 28px;color:var(--ink,#e7eaf1)}
#sirius-register-modal .sheet h3{margin:0 0 .2rem;font-size:1.25rem}
#sirius-register-modal .sheet .sub{color:var(--mut,#8b98a9);font-size:14px;margin:0 0 1.2rem}
#sirius-register-modal .sheet label{display:block;font-size:13px;color:var(--mut,#8b98a9);margin:14px 0 5px}
#sirius-register-modal .sheet input[type=text],
#sirius-register-modal .sheet input[type=password],
#sirius-register-modal .sheet textarea,
#sirius-register-modal .sheet select{
width:100%;padding:11px 13px;border-radius:10px;border:1px solid #ffffff22;background:#0e131b;
color:var(--ink,#e7eaf1);font-size:14px;font-family:inherit;outline:none}
#sirius-register-modal .sheet textarea{min-height:76px;resize:vertical;font-family:ui-monospace,monospace;font-size:13px}
#sirius-register-modal .sheet input:focus,
#sirius-register-modal .sheet textarea:focus{border-color:#4b7bec}
#sirius-register-modal .row{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}
#sirius-register-modal .row.end{justify-content:flex-end}
#sirius-register-modal .x{position:absolute;top:14px;right:18px;background:none;border:none;color:var(--dim,#5e6678);font-size:22px;cursor:pointer}
#sirius-register-modal .sheetwrap{position:relative;width:100%;max-width:620px}
#sirius-register-modal .phrase{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:10px}
#sirius-register-modal .phrase span{background:#0e131b;border:1px solid var(--line,rgba(255,255,255,.09));
border-radius:8px;padding:8px 10px;font-family:ui-monospace,monospace;font-size:13px}
#sirius-register-modal .phrase span b{color:var(--dim,#5e6678);font-weight:400;margin-right:6px;font-size:11px}
#sirius-register-modal .addr{display:flex;gap:8px;align-items:center;margin-top:8px}
#sirius-register-modal .addr code{flex:1;word-break:break-all;background:#0e131b;
border:1px solid var(--line,rgba(255,255,255,.09));border-radius:8px;padding:9px 11px;
font-family:ui-monospace,monospace;font-size:12.5px}
#sirius-register-modal table.price{width:100%;border-collapse:collapse;margin-top:12px;font-size:14px}
#sirius-register-modal table.price td{padding:7px 0;border-bottom:1px solid var(--line,rgba(255,255,255,.09));color:var(--mut,#8b98a9)}
#sirius-register-modal table.price td:last-child{text-align:right;font-family:ui-monospace,monospace;color:var(--ink,#e7eaf1)}
#sirius-register-modal table.price tr.total td{border-bottom:none;color:var(--ink,#e7eaf1);font-weight:600;padding-top:12px}
#sirius-register-modal table.price tr.total td:last-child{color:var(--acid,#d6ff3d)}
#sirius-register-modal table.price td small{display:block;color:var(--dim,#5e6678);font-size:11.5px}
#sirius-register-modal .steps-log{margin-top:14px;font-family:ui-monospace,monospace;font-size:12.5px;
color:var(--mut,#8b98a9);background:#0e131b;border:1px solid var(--line,rgba(255,255,255,.09));
border-radius:10px;padding:12px 14px;max-height:180px;overflow-y:auto}
#sirius-register-modal .steps-log div::before{content:" ";color:var(--acid,#d6ff3d)}
#sirius-register-modal .err{color:var(--taken,#f6768a);font-size:13.5px;margin-top:12px}
#sirius-register-modal .ok{color:var(--ok,#4fd1a5)}
#sirius-register-modal .pill{display:inline-block;font-size:11px;padding:2px 8px;border-radius:999px;
background:rgba(214,255,61,.13);color:var(--acid,#d6ff3d);margin-left:8px;vertical-align:middle}
#sirius-register-modal .pill.soon{background:rgba(255,255,255,.07);color:var(--dim,#5e6678)}
#sirius-register-modal .wopt{display:flex;gap:14px;align-items:flex-start;background:var(--panel2,#18202c);
border:1px solid var(--line,rgba(255,255,255,.09));border-radius:12px;padding:14px 16px;cursor:pointer;
text-align:left;width:100%;color:inherit;font:inherit;margin-top:10px}
#sirius-register-modal .wopt:hover{border-color:#4b7bec}
#sirius-register-modal .wopt:disabled{opacity:.45;cursor:not-allowed}
#sirius-register-modal .wopt .ic{font-size:22px;line-height:1}
#sirius-register-modal .wopt b{display:block;font-size:14.5px}
#sirius-register-modal .wopt span{color:var(--mut,#8b98a9);font-size:13px}
#sirius-register-modal .chk{display:flex;gap:9px;align-items:flex-start;margin-top:14px;font-size:13.5px;color:var(--mut,#8b98a9)}
#sirius-register-modal .chk input{margin-top:3px}
#sirius-register-modal .signin-tabs{
display:flex;gap:2px;margin:-8px -12px 18px;padding:0 4px 0;
border-bottom:1px solid var(--line,rgba(255,255,255,.09));overflow-x:auto
}
#sirius-register-modal .signin-tabs button{
background:none;border:none;color:var(--mut,#8b98a9);font-family:inherit;font-size:12.5px;
padding:10px 12px;cursor:pointer;border-bottom:2px solid transparent;
white-space:nowrap;letter-spacing:.2px;transition:color .1s,border-color .1s
}
#sirius-register-modal .signin-tabs button:hover{color:var(--ink,#e7eaf1)}
#sirius-register-modal .signin-tabs button.active{
color:var(--acid,#d6ff3d);border-bottom-color:var(--acid,#d6ff3d)
}
#sirius-register-modal .btn{display:inline-block;text-decoration:none;padding:10px 18px;border-radius:10px;
background:#4b7bec;color:#fff;font-size:14px;border:none;cursor:pointer;font-family:inherit}
#sirius-register-modal .btn.ghost{background:transparent;border:1px solid var(--line,rgba(255,255,255,.09));color:var(--ink,#e7eaf1)}
#sirius-register-modal .btn.acid{background:var(--acid,#d6ff3d);color:#0b0e14;font-weight:600}
#sirius-register-modal .btn:hover{filter:brightness(1.12)}
#sirius-register-modal .btn:disabled{opacity:.45;cursor:not-allowed;filter:none}
#sirius-register-modal .mono{background:#0e131b;border:1px solid var(--line,rgba(255,255,255,.09));
border-radius:6px;padding:1px 6px;font-family:ui-monospace,monospace;font-size:13px}
#sirius-register-modal .muted{color:var(--dim,#5e6678);font-size:13px}
#sirius-register-modal .note{background:var(--panel,#141a24);border:1px solid var(--line,rgba(255,255,255,.09));
border-left:3px solid var(--acid,#d6ff3d);border-radius:10px;padding:14px 18px;
color:var(--mut,#8b98a9);font-size:14px;margin-top:1.4rem}
#sirius-register-modal .note b{color:var(--ink,#e7eaf1)}
#sirius-register-modal .note.warn{border-left-color:var(--warn,#ffc75f)}
`;
document.head.appendChild(css);
const modal = document.createElement("div");
modal.id = "sirius-register-modal";
modal.innerHTML = `
<div class="sheetwrap">
<button class="x" id="sirius-reg-close" title="close">×</button>
<div class="sheet" id="sirius-reg-sheet"></div>
</div>
`;
document.body.appendChild(modal);
})();
// ---------- flow state ----------
// signInOnly: when true, the flow is used for wallet onboarding from the nav
// dropdown — no name to mint, so instead of walking through Fund/Confirm/
// Register we stop at 'wallet is ready', write siriusProfile, and close.
const state = { name: null, signInOnly: false, wallet: null, session: null, client: null, quote: null, result: null };
const modal = $("sirius-register-modal");
const sheet = $("sirius-reg-sheet");
function open() { modal.classList.add("open"); }
function close() {
modal.classList.remove("open");
if (state.client) { try { state.client.close(); } catch {} state.client = null; }
// Keep the WC session alive when signing in — later record edits or TLD
// mints will need it; only tear it down for the mint flow.
if (state.session && !state.signInOnly) { try { state.session.disconnect(); } catch {} state.session = null; }
state.signInOnly = false;
}
// Persist wallet identity so profile-menu.js and any other tab see the
// signed-in state immediately (same as portal.html's writeProfile).
function writeProfile(w) {
try {
localStorage.setItem("siriusProfile", JSON.stringify({
address: w.address,
tokenAddress: w.tokenAddress,
source: w.source ?? "seed",
signedInAt: Date.now(),
}));
} catch {}
window.dispatchEvent(new Event("siriusProfileChanged"));
}
// A wallet was just loaded/created/connected. If we are in sign-in-only
// mode, mark siriusProfile and close the modal here; otherwise return
// false so the caller keeps going to Fund → Confirm → Register.
function finishSignIn(w) {
writeProfile({ address: w.address, tokenAddress: w.tokenAddress, source: w.source ?? "seed" });
// Expose the live wallet globally so the portal / TLD page can pick it up
// without re-prompting for a password. In-memory only — cleared on reload,
// sign-out, or navigation away.
window.siriusWallet = w;
if (state.signInOnly) {
render(`<h3><span class="ok">✓</span> Signed in</h3>
<p class="sub">You are signed in as <span class="mono">${esc(w.address)}</span>. Names you own
will appear in the wallet dropdown; the same wallet signs record edits and new registrations.</p>
<div class="row end"><button class="btn acid" id="fin">Done</button></div>`);
$("fin").onclick = close;
return true;
}
return false;
}
$("sirius-reg-close").onclick = close;
modal.addEventListener("click", (e) => { if (e.target === modal) close(); });
function render(html) { sheet.innerHTML = html; }
// Tab bar for sign-in mode — lets the user jump between the four wallet
// actions without going back to a wallet-choice screen. Prepended to the
// sheet content by each sign-in step. Only shown in signInOnly mode.
function renderTabs(active) {
if (!state.signInOnly) return "";
const saved = BNS.BuiltInWallet.exists();
const tabs = [];
if (saved) tabs.push(["unlock", "🔓 Unlock", "Wallet stored on this device"]);
tabs.push(["new", "🆕 Create", "Sign up · fresh phrase"]);
tabs.push(["import", "📥 Import", "Sign in · seed you have"]);
tabs.push(["wc", "🔗 WizardConnect", "Cashonize / Paytaca"]);
return `
<div class="signin-tabs" role="tablist">
${tabs.map(([id, label, hint]) => `
<button role="tab" data-tab="${id}" class="${id === active ? "active" : ""}"
title="${esc(hint)}">${label}</button>
`).join("")}
</div>
`;
}
// Wire tab clicks — event delegation on the sheet so freshly-rendered
// tabs pick up the handler without rewiring per step. Switching away from
// a live WC session tears it down first so we don't leak connections.
sheet.addEventListener("click", async (e) => {
const t = e.target.closest("[data-tab]");
if (!t) return;
const which = t.dataset.tab;
if (which !== "wc" && state.session) {
try { await state.session.disconnect(); } catch {}
state.session = null;
}
if (which === "unlock") stepUnlock();
else if (which === "new") stepCreate();
else if (which === "import") stepImport();
else if (which === "wc") stepExternal();
});
function err(e) {
sheet.querySelectorAll(".err").forEach((n) => n.remove());
const box = document.createElement("div");
box.className = "err";
box.textContent = e?.message ?? String(e);
sheet.appendChild(box);
}
async function client() {
if (!state.client) state.client = await BNS.connect();
return state.client;
}
function startFlow(name) {
state.name = name;
state.tld = null;
state.signInOnly = false;
open();
stepWallet();
}
// Start a TLD mint. Same wallet-choice + fund flow as a name register, but
// the mint step calls registerTldWith(Built|External)Wallet instead of the
// name variant, and the "done" screen skips the record editor since a TLD
// certificate goes to the operator's token address as-is.
function startTldFlow(label, opts = {}) {
state.name = `.${label}`; // used for headings only
state.tld = { label, serviceFeeSats: BigInt(opts.serviceFeeSats ?? 0n) };
state.signInOnly = false;
open();
stepWallet();
}
// Sign-in-only entry point for the nav wallet dropdown. mode = 'new' opens
// the "create wallet" step directly, 'import' opens "import phrase", 'wc'
// starts a WizardConnect session. On success the flow writes siriusProfile
// and closes — no name context, no Fund/Confirm.
function startSignIn(mode) {
state.name = "your wallet"; // header text — used by stepWallet
state.tld = null;
state.signInOnly = true;
open();
// "choose" (default) shows the wallet-choice screen with all four options;
// the other modes are still supported as direct-action entry points from
// deep-links like portal.html?mode=new — but the nav dropdown funnels to
// "choose" so the user always sees the options first.
if (mode === "new") stepCreate();
else if (mode === "import") stepImport();
else if (mode === "wc") stepExternal();
else if (mode === "unlock") stepUnlock();
else stepWallet();
}
// Public API
window.siriusRegisterName = startFlow;
window.siriusRegisterTld = startTldFlow;
window.siriusSignInWallet = startSignIn;
// ---------- step 1: wallet choice ----------
function stepWallet() {
const saved = BNS.BuiltInWallet.exists();
const kind = state.tld ? "TLD" : "name";
const heading = state.signInOnly
? `Sign in <span style="color:var(--acid,#d6ff3d)">or</span> create a wallet`
: `Register ${esc(state.name)}`;
const intro = state.signInOnly
? `Pick one. Your keys stay in this browser — nothing is transmitted for sign-in.`
: `First, the wallet that will <b>own</b> the ${esc(kind)}. The certificate is minted straight to it — we never hold it.`;
render(`
<h3>${heading}</h3>
<p class="sub">${intro}</p>
${saved ? `<button class="wopt" id="w-unlock"><span class="ic">🔓</span><span>
<b>Unlock my browser wallet</b>
<span>You already created one on this device.</span></span></button>` : ``}
<button class="wopt" id="w-new"><span class="ic">✨</span><span>
<b>Create a new wallet<span class="pill">easiest</span></b>
<span>Made here in your browser. You get a recovery phrase to write down — it is the only key.</span></span></button>
<button class="wopt" id="w-import"><span class="ic">🔑</span><span>
<b>Import a recovery phrase</b>
<span>Restore a wallet you already have, from its 12-word phrase.</span></span></button>
<button class="wopt" id="w-wiz"><span class="ic">🪄</span><span>
<b>Connect a wallet — WizardConnect<span class="pill">most private</span></b>
<span>Cashonize 0.9+ or Paytaca. Encrypted end-to-end over Nostr; the relay sees only
ciphertext. Your keys never leave your wallet.</span></span></button>
<div class="note" style="margin-top:18px">Everything below runs on <b>chipnet</b>, Bitcoin Cash's
test network. The coins are free and worth nothing — this is an alpha, and it is the honest
place to try it before real money is involved.</div>
`);
if (saved) $("w-unlock").onclick = stepUnlock;
$("w-new").onclick = stepCreate;
$("w-import").onclick = stepImport;
$("w-wiz").onclick = () => stepExternal("wizard");
}
// ---------- WizardConnect (external wallet) ----------
async function stepExternal() {
const label = "WizardConnect";
render(`${renderTabs("wc")}<h3>Connect with ${esc(label)}</h3><p class="sub">Starting the connection…</p>`);
let session;
try {
const mod = await import("https://silentmode.st/js/wizardconnect.js");
session = await mod.openWizardSession({
dappName: BNS.REGISTRAR.wizardConnect.dappName,
dappIcon: BNS.REGISTRAR.wizardConnect.dappIcon,
prefix: BNS.REGISTRAR.addressPrefix,
crypto: {
pubkeyToAddress: BNS.pubkeyToAddress,
pubkeyToLockingBytecode: BNS.pubkeyToLockingBytecode,
binToHex: BNS.binToHex,
txidOfHex: BNS.txidOfHex,
},
});
} catch (e) {
render(`<h3>Could not start ${esc(label)}</h3><p class="sub">${esc(e.message)}</p>
<div class="row end"><button class="btn ghost" id="back">Back</button></div>`);
$("back").onclick = state.signInOnly ? close : stepWallet;
return;
}
const uri = session.qrUri ?? session.uri;
render(`
${renderTabs("wc")}
<h3>Open your wallet</h3>
<p class="sub">Scan or paste this into ${esc(label)}-capable wallet, then approve the connection.</p>
<div class="addr"><code id="uri">${esc(uri)}</code><button class="btn ghost" id="copy">Copy</button></div>
<div class="note warn" style="margin-top:16px">Waiting for your wallet…
The link stays valid until you close this page.</div>
<div class="row end"><button class="btn ghost" id="cancel">Cancel</button></div>
`);
$("copy").onclick = () => navigator.clipboard?.writeText(uri);
$("cancel").onclick = async () => { try { await session.disconnect(); } catch {} (state.signInOnly ? close : stepWallet)(); };
try { await session.ready; } catch (e) { err(e); return; }
// Sign-in-only path: get the address and persist siriusProfile immediately,
// skip the whole "quote registration" branch since there is no name.
if (state.signInOnly) {
try {
const addresses = await session.getAddresses();
if (!addresses?.length) throw new Error("wallet returned no addresses");
state.session = session; // keep alive for record edits etc.
finishSignIn({
source: "wc",
address: addresses[0],
tokenAddress: BNS.toTokenAddress(addresses[0]),
watchedAddresses: addresses,
});
} catch (e) { err(e); }
return;
}
stepExternalConfirm(session, label);
}
async function stepExternalConfirm(session, label) {
render(`<h3>Connected</h3><p class="sub">Reading your wallet and building the registration…</p>`);
try {
const c = await client();
const addresses = await session.getAddresses();
const owner = addresses[0];
const balance = await BNS.getBalance(c, addresses);
const genesis = BNS.describeGenesisRequirement(balance.hasGenesisInput);
if (balance.sats === 0n) {
render(`<h3>That wallet is empty</h3>
<p class="sub">Fund <span class="mono">${esc(owner)}</span> with chipnet coins and reconnect.</p>
<div class="row end"><button class="btn ghost" id="back">Back</button></div>`);
$("back").onclick = async () => { try { await session.disconnect(); } catch {} stepWallet(); };
return;
}
// TLD external path: keep the session, hand off to the TLD confirm/mint
// steps which know to call registerTldWithExternalWallet.
if (state.tld) { state.session = session; return stepConfirmTld(); }
const quote = await BNS.quoteRegistration(c, { name: state.name, ownerAddress: owner, records: {} });
const rows = BNS.priceSummary(quote.costs).map((l) => `
<tr class="${l.total ? "total" : ""}"><td>${esc(l.label)}${l.note ? `<small>${esc(l.note)}</small>` : ""}</td>
<td>${sats(l.sats)} sat</td></tr>`).join("");
render(`
<h3>Confirm ${esc(quote.displayName)}</h3>
<p class="sub">Connected to ${esc(label)}. The certificate is minted to
<span class="mono">${esc(owner)}</span> — your wallet, your key.</p>
<table class="price">${rows}</table>
${genesis.ready ? "" : `<div class="note warn" style="margin-top:16px">
<b>Two approvals needed.</b> ${esc(genesis.explanation)}</div>`}
<div class="row end"><button class="btn ghost" id="back">Back</button>
<button class="btn acid" id="go">Register — approve in your wallet</button></div>
`);
$("back").onclick = async () => { try { await session.disconnect(); } catch {} stepWallet(); };
$("go").onclick = async () => {
render(`<h3>Approve in your wallet</h3>
<p class="sub">${esc(label)} has sent the transaction to your device. Check the details there
and approve. Do not close this tab.</p><div class="steps-log" id="log"></div>`);
const log = $("log");
const say = (s) => { const d = document.createElement("div"); d.textContent = s; log.appendChild(d); };
try {
const res = await BNS.registerWithExternalWallet(await client(), {
session, name: state.name, ownerAddress: owner, records: {}, onProgress: say,
});
state.result = res;
state.session = session;
stepDone();
} catch (e) {
err(e);
const row = document.createElement("div"); row.className = "row end";
row.innerHTML = `<button class="btn ghost" id="back">Back</button>`;
sheet.appendChild(row);
$("back").onclick = () => stepExternalConfirm(session, label);
}
};
} catch (e) { err(e); }
}
// ---------- built-in: create ----------
function stepCreate() {
// The Register button flow lands here via stepWallet, so "Back" returns to
// the wallet-choice step. The sign-in dropdown calls stepCreate directly
// with no chosen name, so "Back" should just close the modal in that case.
const goBack = state.signInOnly ? close : stepWallet;
render(`
${renderTabs("new")}
<h3>Create your wallet</h3>
<p class="sub">Generated in this browser. The phrase is never sent anywhere — not to us, not to anyone.</p>
<label for="pw">Password (encrypts the wallet on this device)</label>
<input type="password" id="pw" autocomplete="new-password" placeholder="at least 8 characters">
<label for="pw2">Repeat password</label>
<input type="password" id="pw2" autocomplete="new-password">
<div class="note warn" style="margin-top:16px"><b>There is no reset.</b> The password protects this
device; the recovery phrase you will see next <i>is</i> the wallet. Lose both and the name is gone
forever — that is what self-custody means.</div>
<div class="row end"><button class="btn ghost" id="back">Back</button>
<button class="btn acid" id="next">Generate phrase →</button></div>
`);
$("back").onclick = goBack;
$("next").onclick = async () => {
const pw = $("pw").value, pw2 = $("pw2").value;
if (pw.length < 8) return err(new Error("password must be at least 8 characters"));
if (pw !== pw2) return err(new Error("passwords do not match"));
try {
const wallet = await BNS.BuiltInWallet.create();
await wallet.save(pw);
state.wallet = wallet;
stepPhrase();
} catch (e) { err(e); }
};
}
function stepPhrase() {
const words = state.wallet.mnemonic.split(" ");
render(`
<h3>Write this down. Now.</h3>
<p class="sub">These 12 words are your wallet. Anyone who has them owns your names; if you lose them,
nobody — including us — can recover anything.</p>
<div class="phrase">${words.map((w,i)=>`<span><b>${i+1}</b>${esc(w)}</span>`).join("")}</div>
<div class="row"><button class="btn ghost" id="copy">Copy phrase</button></div>
<label class="chk"><input type="checkbox" id="ack">
<span>I have written the phrase down somewhere safe and offline. I understand it cannot be reset.</span></label>
<div class="row end"><button class="btn acid" id="next" disabled>Continue →</button></div>
`);
$("copy").onclick = () => navigator.clipboard?.writeText(state.wallet.mnemonic);
$("ack").onchange = (e) => { $("next").disabled = !e.target.checked; };
$("next").onclick = () => { if (!finishSignIn(state.wallet)) stepFund(); };
}
// ---------- built-in: import / unlock ----------
function stepImport() {
const goBack = state.signInOnly ? close : stepWallet;
render(`
${renderTabs("import")}
<h3>Import a recovery phrase</h3>
<p class="sub">12 words, separated by spaces. It stays in this browser.</p>
<label for="mn">Recovery phrase</label>
<textarea id="mn" spellcheck="false" placeholder="word word word …"></textarea>
<label for="pw">Password to encrypt it on this device</label>
<input type="password" id="pw" autocomplete="new-password">
<div class="row end"><button class="btn ghost" id="back">Back</button>
<button class="btn acid" id="next">Import →</button></div>
`);
$("back").onclick = goBack;
$("next").onclick = async () => {
try {
if ($("pw").value.length < 8) throw new Error("password must be at least 8 characters");
const wallet = await BNS.BuiltInWallet.fromMnemonic($("mn").value);
await wallet.save($("pw").value);
state.wallet = wallet;
if (!finishSignIn(wallet)) stepFund();
} catch (e) { err(e); }
};
}
function stepUnlock() {
const goBack = state.signInOnly ? close : stepWallet;
render(`
${renderTabs("unlock")}
<h3>Unlock your wallet</h3>
<p class="sub">Decrypts the wallet stored in this browser.</p>
<label for="pw">Password</label>
<input type="password" id="pw" autocomplete="current-password">
<div class="row end"><button class="btn ghost" id="back">Back</button>
<button class="btn acid" id="next">Unlock →</button></div>
`);
$("back").onclick = goBack;
$("pw").onkeydown = (e) => { if (e.key === "Enter") $("next").click(); };
$("next").onclick = async () => {
try {
state.wallet = await BNS.BuiltInWallet.load($("pw").value);
if (!finishSignIn(state.wallet)) stepFund();
} catch (e) { err(e); }
};
}
// ---------- fund ----------
let fundTimer = null;
async function stepFund() {
const w = state.wallet;
const faucets = BNS.REGISTRAR.faucets
.map((f) => `<a href="${esc(f.url)}" target="_blank" rel="noopener" style="color:var(--acid,#d6ff3d)">${esc(f.name)}</a>`).join(" · ");
render(`
<h3>Fund your wallet</h3>
<p class="sub">Send test coins to this address. It is yours — derived from your phrase.</p>
<div class="addr"><code id="a">${esc(w.address)}</code>
<button class="btn ghost" id="copy">Copy</button></div>
<div class="muted" style="margin-top:10px">Free chipnet coins: ${faucets}</div>
<div class="note" id="bal" style="margin-top:16px">checking balance…</div>
<div class="row end"><button class="btn ghost" id="back">Back</button>
<button class="btn acid" id="next" disabled>Continue →</button></div>
`);
$("copy").onclick = () => navigator.clipboard?.writeText(w.address);
$("back").onclick = () => { clearInterval(fundTimer); stepWallet(); };
$("next").onclick = () => { clearInterval(fundTimer); stepConfirm(); };
const poll = async () => {
try {
const b = await BNS.getBalance(await client(), w.watchedAddresses);
const enough = b.sats >= 20000n;
$("bal").innerHTML = b.sats > 0n
? `<b class="ok">${sats(b.sats)} sat</b> available (${b.coins} coin${b.coins===1?"":"s"})`
+ (enough ? "" : ` — a little more is needed to cover the registration.`)
: `Waiting for coins… this page is watching the chain live.`;
$("next").disabled = !enough;
} catch (e) { $("bal").textContent = "could not reach the chain: " + e.message; }
};
await poll();
clearInterval(fundTimer);
fundTimer = setInterval(poll, 6000);
}
// ---------- confirm ----------
async function stepConfirm() {
render(`<h3>Preparing…</h3><p class="sub">Building your registration transaction.</p>`);
try {
const c = await client();
// TLD flow skips the name-availability check and the per-name quote —
// registerTldWith*Wallet builds the tx internally and checks the on-chain
// TLD registry itself. Just show a simple confirm screen with the fee.
if (state.tld) return stepConfirmTld();
const avail = await BNS.checkAvailability(c, state.name);
if (!avail.available) throw new Error(`"${avail.name}" was just registered by someone else`);
const quote = await BNS.quoteRegistration(c, {
name: state.name, ownerAddress: state.wallet.address, records: {},
});
state.quote = quote;
const rows = BNS.priceSummary(quote.costs).map((l) => `
<tr class="${l.total ? "total" : ""}"><td>${esc(l.label)}${l.note ? `<small>${esc(l.note)}</small>` : ""}</td>
<td>${sats(l.sats)} sat</td></tr>`).join("");
render(`
<h3>Confirm ${esc(quote.displayName)}</h3>
<p class="sub">One transaction. It mints the certificate to <b>your</b> address and pays the fees.</p>
<table class="price">${rows}</table>
<div class="note" style="margin-top:16px">The certificate will be minted to
<span class="mono">${esc(state.wallet.address)}</span> — your key, your name.
You can set where it points immediately afterwards.</div>
<div class="row end"><button class="btn ghost" id="back">Back</button>
<button class="btn acid" id="go">Register — sign &amp; broadcast</button></div>
`);
$("back").onclick = stepFund;
$("go").onclick = stepRegister;
} catch (e) {
render(`<h3>Could not prepare</h3><p class="sub">${esc(e.message)}</p>
<div class="row end"><button class="btn ghost" id="back">Back</button></div>`);
$("back").onclick = stepFund;
}
}
// ---------- TLD confirm ----------
async function stepConfirmTld() {
const feeSats = state.tld.serviceFeeSats;
const owner = state.wallet ? state.wallet.address : (state.session ? (await state.session.getAddresses())[0] : "your address");
render(`
<h3>Confirm <b>${esc(state.name)}</b></h3>
<p class="sub">One transaction. It mints the TLD certificate to <b>your</b> token address; you become
the registry operator for every second-level name under it.</p>
<table class="price">
<tr><td>Service fee${feeSats === 0n ? " <small>(none on chipnet)</small>" : ""}</td>
<td>${sats(feeSats)} sat</td></tr>
<tr><td>Beacon dust + chain fee <small>(covered by wallet)</small></td>
<td>~1,300 sat</td></tr>
</table>
<div class="note" style="margin-top:16px">Minted to <span class="mono">${esc(owner)}</span>.
That wallet becomes the operator for this TLD.</div>
<div class="row end"><button class="btn ghost" id="back">Back</button>
<button class="btn acid" id="go">Register — sign &amp; broadcast</button></div>
`);
$("back").onclick = stepFund;
$("go").onclick = stepRegisterTld;
}
async function stepRegisterTld() {
render(`<h3>Registering ${esc(state.name)}</h3>
<p class="sub">Signing in your browser and broadcasting to the chain. Do not close this tab.</p>
<div class="steps-log" id="log"></div>`);
const log = $("log");
const say = (s) => { const d = document.createElement("div"); d.textContent = s; log.appendChild(d); log.scrollTop = log.scrollHeight; };
const serviceFee = { address: BNS.REGISTRAR?.serviceFee?.address ?? null, sats: state.tld.serviceFeeSats };
try {
const res = state.session
? await BNS.registerTldWithExternalWallet(await client(), {
session: state.session, tld: state.tld.label, records: {},
serviceFee, tldListUrl: "https://navigate.st/api/tlds", onProgress: say,
})
: await BNS.registerTldWithBuiltInWallet(await client(), {
wallet: state.wallet, tld: state.tld.label, records: {},
serviceFee, tldListUrl: "https://navigate.st/api/tlds", onProgress: say,
});
state.result = res;
stepDoneTld();
} catch (e) {
err(e);
const row = document.createElement("div"); row.className = "row end";
row.innerHTML = `<button class="btn ghost" id="back">Back</button>`;
sheet.appendChild(row);
$("back").onclick = stepConfirmTld;
}
}
function stepDoneTld() {
const r = state.result;
render(`
<h3><span class="ok">✓</span> <b>.${esc(r.tld)}</b> is yours</h3>
<p class="sub">The TLD certificate is on the chain, held by your key. Second-level names under
<b>.${esc(r.tld)}</b> now live in your registry.</p>
<table class="price">
<tr><td>Transaction</td><td>${esc(r.txid.slice(0,20))}…</td></tr>
<tr><td>Certificate ID</td><td>${esc(r.category.slice(0,20))}…</td></tr>
<tr><td>Operator</td><td>${esc(r.ownerAddress.slice(0,22))}…</td></tr>
</table>
<div class="row end"><button class="btn acid" id="fin">Done</button></div>
`);
$("fin").onclick = close;
}
// ---------- register ----------
async function stepRegister() {
render(`<h3>Registering ${esc(state.name)}</h3>
<p class="sub">Signing in your browser and broadcasting to the chain. Do not close this tab.</p>
<div class="steps-log" id="log"></div>`);
const log = $("log");
const say = (s) => { const d = document.createElement("div"); d.textContent = s; log.appendChild(d); log.scrollTop = log.scrollHeight; };
const words = {
"checking-availability": "checking the name is still free",
"loading-coins": "loading your coins",
"preparing-wallet": "preparing your wallet (one-off setup transaction)",
"prepared": "wallet prepared",
"building": "building the registration transaction",
"signing": "signing with your key — in this browser",
"broadcasting": "broadcasting to the Bitcoin Cash network",
"registered": "registered",
};
try {
const res = await BNS.registerWithBuiltInWallet(await client(), {
wallet: state.wallet, name: state.name, records: {},
onProgress: (s) => say(words[s] ?? s),
});
state.result = res;
stepDone();
} catch (e) {
err(e);
const row = document.createElement("div"); row.className = "row end";
row.innerHTML = `<button class="btn ghost" id="back">Back</button>`;
sheet.appendChild(row);
$("back").onclick = stepConfirm;
}
}
// ---------- done + point somewhere ----------
function stepDone() {
const r = state.result;
render(`
<h3><span class="ok">✓</span> ${esc(r.displayName)} is yours</h3>
<p class="sub">The certificate is on the chain, held by your key. Nobody can take it back.</p>
<table class="price">
<tr><td>Transaction</td><td>${esc(r.txid.slice(0,20))}…</td></tr>
<tr><td>Certificate ID</td><td>${esc(r.category.slice(0,20))}…</td></tr>
<tr><td>Owner</td><td>${esc(r.ownerAddress.slice(0,22))}…</td></tr>
</table>
<h3 style="margin-top:24px;font-size:1.05rem">Point it somewhere</h3>
<p class="sub">Optional, and changeable any time — only your key can.</p>
<label for="kind">Record type</label>
<select id="kind">
<option value="h">A tiny site stored on the chain itself (h)</option>
<option value="u">Redirect to a web address (u)</option>
<option value="ip">Your own server's IPv4 address (ip)</option>
<option value="tls">TLS certificate fingerprint (tls)</option>
</select>
<label for="val" id="vl">HTML — kept small; it lives inside the transaction</label>
<textarea id="val" spellcheck="false" placeholder="&lt;h1&gt;hello&lt;/h1&gt;"></textarea>
<div class="muted" id="budget"></div>
<div class="row end"><button class="btn ghost" id="skip">Done for now</button>
<button class="btn acid" id="set">Publish record</button></div>
`);
const labels = {
h: ["HTML — kept small; it lives inside the transaction", "<h1>hello</h1>"],
u: ["The https:// address the name should open", "https://example.org"],
ip: ["IPv4 address of your server", "203.0.113.9"],
tls: ["SHA-256 fingerprint of your TLS certificate (hex)", "a1b2c3…"],
};
const budget = () => {
const used = new TextEncoder().encode($("val").value).length;
const max = 200 - 30 - r.name.length;
$("budget").textContent = `${used} / ~${max} bytes used`;
$("budget").style.color = used > max ? "var(--taken,#f6768a)" : "";
};
$("kind").onchange = () => { const [l, p] = labels[$("kind").value]; $("vl").textContent = l; $("val").placeholder = p; budget(); };
$("val").oninput = budget;
budget();
$("skip").onclick = close;
$("set").onclick = async () => {
const records = { [$("kind").value]: $("val").value };
const external = !state.wallet && state.session;
render(`<h3>Publishing record</h3><p class="sub">This moves the certificate — which is how the chain
proves you are the owner.${external ? " Approve it in your wallet." : ""}</p>
<div class="steps-log" id="log"></div>`);
const log = $("log");
const say = (s) => { const d = document.createElement("div"); d.textContent = s; log.appendChild(d); };
try {
const upd = external
? await BNS.setRecordsWithExternalWallet(await client(), {
session: state.session, name: r.displayName, records, onProgress: say,
})
: await BNS.setRecordsWithBuiltInWallet(await client(), {
wallet: state.wallet, name: r.displayName, records, onProgress: say,
});
render(`<h3><span class="ok">✓</span> ${esc(r.displayName)} now points somewhere</h3>
<p class="sub">Update transaction <span class="mono">${esc(upd.txid.slice(0,20))}…</span> is on the chain.
Resolvers pick it up within a block.</p>
<div class="note">Open it with the Silent Mode resolver, or preview it at
<a href="https://navigate.st/bns/${encodeURIComponent(r.displayName)}" target="_blank" rel="noopener" style="color:var(--acid,#d6ff3d)">navigate.st</a>.</div>
<div class="row end"><button class="btn acid" id="fin">Finish</button></div>`);
$("fin").onclick = close;
} catch (e) {
err(e);
const row = document.createElement("div"); row.className = "row end";
row.innerHTML = `<button class="btn ghost" id="back">Back</button>`;
sheet.appendChild(row); $("back").onclick = stepDone;
}
};
}