Two issues reported after an Import sign-in: 1. Done button was stuck — user saw the ✓ Signed in screen but the portal never switched to the names view. Root cause: finishSignIn fired siriusProfileChanged BEFORE setting window.siriusWallet, so the portal's listener called adoptWalletFromModal() while window.siriusWallet was still null, saw no wallet, and did nothing. Fix: expose the live wallet BEFORE writeProfile so the sync listener sees it and can enterPortal() immediately. 2. Every reload asked for the password again. Cache the wallet's mnemonic in sessionStorage on sign-in — same tab (or a page reload) rebuilds the BuiltInWallet silently via BuiltInWallet.fromMnemonic; a full browser close clears sessionStorage and the user is back at the Unlock tab. sessionStorage is per-origin per-tab so an XSS on Sirius.X pages would still be able to read it — that's the tradeoff for the convenience. Chipnet only; mainnet gets the PIN escrow pattern (3 wrong PIN tries → escalate to password) that Digibyte.x/web already uses. PIN implementation is deferred to its own commit. portal.html adoptWalletFromModal now tries sessionStorage after the in-memory check; register-flow.js startFlow/startTldFlow do the same via a new async adoptSessionWalletAsync so name and TLD mints on any page reuse the session wallet with no re-prompt. profile-menu.js sign-out clears sessionStorage + window.siriusWallet so signing out really does drop the user.
801 lines
36 KiB
HTML
801 lines
36 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Portal — your names on Sirius.X</title>
|
||
<meta name="description" content="Sign in with your Bitcoin Cash seed phrase to see every BCNR name your wallet holds and manage records. Your keys never leave the browser.">
|
||
<link rel="icon" href="./assets/favicon.svg" type="image/svg+xml">
|
||
<link rel="mask-icon" href="./assets/favicon.svg" color="#d6ff3d">
|
||
<meta property="og:type" content="website">
|
||
<meta property="og:site_name" content="Sirius.X">
|
||
<meta property="og:title" content="Sirius.X portal — your BCNR names">
|
||
<meta property="og:description" content="Sign in with your seed phrase to see every name your wallet holds and manage records. Keys never leave the browser.">
|
||
<meta property="og:url" content="https://silentmode.st/sirius-x/portal.html">
|
||
<meta property="og:image" content="https://silentmode.st/sirius-x/brand/banner.svg">
|
||
<meta name="twitter:card" content="summary">
|
||
<style>
|
||
:root{
|
||
--bg:#0b0e14; --panel:#141a24; --panel2:#18202c; --line:rgba(255,255,255,.09);
|
||
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
||
--ok:#4fd1a5; --taken:#f6768a; --warn:#ffc75f;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0;background:radial-gradient(1100px 560px at 50% -12%,#16202e,var(--bg));
|
||
color:var(--ink);font:16px/1.65 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;min-height:100vh}
|
||
a{color:var(--acid)}
|
||
.wrap{max-width:820px;margin:0 auto;padding:0 1.2rem}
|
||
.topnav{position:sticky;top:0;z-index:20;display:flex;gap:2px;align-items:center;flex-wrap:wrap;
|
||
padding:10px 1.2rem;background:rgba(11,14,20,.82);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
|
||
.topnav .brand{font-weight:600;color:var(--ink);text-decoration:none;margin-right:14px;font-size:15px;letter-spacing:.2px}
|
||
.topnav .brand .g{color:var(--acid)}
|
||
.topnav a{color:var(--mut);text-decoration:none;padding:6px 12px;border-radius:8px;font-size:14px}
|
||
.topnav a:hover{color:var(--ink);background:var(--panel)}
|
||
.topnav a.here{color:var(--acid);background:rgba(214,255,61,.08)}
|
||
.topnav a.portal{margin-left:auto;background:rgba(214,255,61,.10);color:var(--acid);font-weight:500}
|
||
.topnav a.portal:hover{background:rgba(214,255,61,.18)}
|
||
header.hero{text-align:center;padding:3rem 1.2rem 1rem}
|
||
.mark{font-size:44px;line-height:1}
|
||
h1{font-size:clamp(1.6rem,4.5vw,2.2rem);margin:.3rem 0 .3rem}
|
||
h1 .g{color:var(--acid)}
|
||
.tag{color:var(--mut);font-size:1rem;max-width:580px;margin:0 auto}
|
||
h2{font-size:1.25rem;margin:0 0 .4rem}
|
||
section{padding:2rem 0;border-top:1px solid var(--line);margin-top:2rem}
|
||
section:first-of-type{border-top:none;margin-top:1rem}
|
||
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin-top:1rem}
|
||
label{display:block;font-size:13px;color:var(--mut);margin:14px 0 5px}
|
||
textarea,input[type=password],input[type=text]{
|
||
width:100%;padding:11px 13px;border-radius:10px;border:1px solid #ffffff22;background:#0e131b;
|
||
color:var(--ink);font-size:14px;font-family:inherit;outline:none
|
||
}
|
||
textarea{min-height:74px;resize:vertical;font-family:ui-monospace,monospace;font-size:13px}
|
||
textarea:focus,input:focus{border-color:#4b7bec}
|
||
.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}
|
||
.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--ink)}
|
||
.btn.acid{background:var(--acid);color:#0b0e14;font-weight:600}
|
||
.btn.small{padding:6px 12px;font-size:12.5px}
|
||
.btn:hover{filter:brightness(1.12)}
|
||
.btn:disabled{opacity:.45;cursor:not-allowed;filter:none}
|
||
.row{display:flex;gap:10px;flex-wrap:wrap;margin-top:16px;align-items:center}
|
||
.muted{color:var(--mut);font-size:13px}
|
||
.dim{color:var(--dim);font-size:12.5px}
|
||
code,.mono{background:#0e131b;border:1px solid var(--line);border-radius:6px;padding:1px 6px;font-family:ui-monospace,monospace;font-size:13px;word-break:break-all}
|
||
.badge{display:inline-block;font-size:11px;padding:2px 10px;border-radius:999px;font-weight:600}
|
||
.b-x{background:rgba(214,255,61,.14);color:var(--acid)}
|
||
.b-warn{background:rgba(255,199,95,.14);color:var(--warn)}
|
||
.err{color:var(--taken);font-size:13.5px;margin-top:10px}
|
||
.status{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:10px 14px;font-size:13px;color:var(--mut);margin-top:12px}
|
||
.status.ok{border-left:3px solid var(--ok)}
|
||
.status.err{border-left:3px solid var(--taken);color:var(--taken)}
|
||
.hidden{display:none}
|
||
.authbox{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px}
|
||
.authbox .card{margin:0}
|
||
.authbox h3{margin:0 0 .3rem;font-size:1rem}
|
||
.authbox p{margin:0;color:var(--mut);font-size:13px}
|
||
.authbox .card > .cta{margin-top:14px}
|
||
.name-row{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;
|
||
padding:14px 16px;background:var(--panel2);border:1px solid var(--line);border-radius:12px;margin-top:8px}
|
||
.name-row .n{font-family:ui-monospace,monospace;font-size:15px}
|
||
.name-row .rec{color:var(--mut);font-family:ui-monospace,monospace;font-size:12px;grid-column:1/-1;margin-top:6px;word-break:break-all}
|
||
.name-row .rec .k{color:var(--dim)}
|
||
/* record editor modal */
|
||
.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:50}
|
||
.modal.open{display:flex}
|
||
.sheet{background:var(--panel);border:1px solid var(--line);border-radius:16px;max-width:620px;width:100%;
|
||
padding:24px 26px;position:relative}
|
||
.sheet h3{margin:0 0 .3rem;font-size:1.15rem;font-family:ui-monospace,monospace}
|
||
.sheet .sub{color:var(--mut);font-size:13px;margin:0 0 1.2rem}
|
||
.sheet .x{position:absolute;top:14px;right:16px;background:none;border:none;color:var(--dim);font-size:22px;cursor:pointer;line-height:1}
|
||
.sheet label{display:block;font-size:12px;color:var(--mut);margin:12px 0 4px;text-transform:uppercase;letter-spacing:.4px}
|
||
.sheet .field-hint{color:var(--dim);font-size:11.5px;margin-left:8px;text-transform:none;letter-spacing:0}
|
||
.sheet input[type=text],.sheet textarea{
|
||
width:100%;padding:9px 12px;border-radius:8px;border:1px solid #ffffff22;background:#0e131b;
|
||
color:var(--ink);font-size:13px;font-family:ui-monospace,monospace;outline:none
|
||
}
|
||
.sheet textarea{min-height:60px;resize:vertical}
|
||
.sheet input:focus,.sheet textarea:focus{border-color:#4b7bec}
|
||
.sheet .budget{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:var(--mut);margin-top:14px;padding:8px 12px;background:var(--panel2);border-radius:8px}
|
||
.sheet .budget.over{background:rgba(246,118,138,.15);color:var(--taken)}
|
||
.sheet .actions{display:flex;gap:10px;justify-content:flex-end;margin-top:16px}
|
||
.sheet .steps-log{margin-top:12px;font-family:ui-monospace,monospace;font-size:12px;color:var(--mut);background:#0e131b;border:1px solid var(--line);border-radius:8px;padding:10px 12px;max-height:120px;overflow-y:auto;display:none}
|
||
.sheet .steps-log.on{display:block}
|
||
.sheet .steps-log div::before{content:"› ";color:var(--acid)}
|
||
.sheet .msg{font-size:13px;margin-top:12px}
|
||
.sheet .msg.err{color:var(--taken)} .sheet .msg.ok{color:var(--ok)}
|
||
footer{border-top:1px solid var(--line);padding:2rem 0 3rem;color:var(--dim);font-size:13px;text-align:center;margin-top:2.5rem}
|
||
@media (max-width:520px){
|
||
.name-row{grid-template-columns:1fr auto}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<nav class="topnav">
|
||
<a class="brand" href="./">⭐ <span class="g">Sirius.X</span></a>
|
||
<a href="./#try">Try it</a>
|
||
<a href="./#search-input">✏️ Register</a>
|
||
<a href="./tld.html">🌐 TLD</a>
|
||
<a href="./theseus/"><svg width="14" height="14" viewBox="0 0 64 64" style="vertical-align:-2px;margin-right:4px" aria-hidden="true"><rect width="64" height="64" rx="12" fill="#0b0e14"/><path d="M32 6 L38 32 L26 32 Z" fill="#e04e4e"/><path d="M38 32 L32 58 L26 32 Z" fill="#d6ff3d"/><circle cx="32" cy="32" r="3" fill="#0b0e14"/></svg>Theseus</a>
|
||
<a href="./portal.html" class="portal here">🔑 Sign in</a>
|
||
</nav>
|
||
|
||
<header class="hero">
|
||
<div class="mark">🔑</div>
|
||
<h1>Your <span class="g">names</span> portal</h1>
|
||
<p class="tag">Sign in with your Bitcoin Cash seed phrase or connect an external wallet to see
|
||
every BCNR name your wallet holds. Your keys never leave this browser tab.</p>
|
||
</header>
|
||
|
||
<div class="wrap">
|
||
|
||
<section id="signin">
|
||
<h2>Sign in or create a wallet</h2>
|
||
<p class="muted">The same wallet that will hold your names does the signing. Pick a tab —
|
||
everything happens on this page; keys never leave your browser.</p>
|
||
<!-- register-flow.js paints the tab bar + active form into this container
|
||
via window.siriusRenderSignInInline(). No modal, no launcher. -->
|
||
<div id="signin-inline" style="margin-top:1rem"></div>
|
||
<div id="signin-err" class="err hidden"></div>
|
||
</section>
|
||
|
||
<section id="me" class="hidden">
|
||
<h2>Signed in</h2>
|
||
|
||
<div class="card" id="tld-register-card">
|
||
<h3 style="margin:0 0 .3rem;font-size:1.05rem">Register a new TLD</h3>
|
||
<p class="muted" style="margin:0 0 .8rem">TLDs are per-registry NFTs on the chipnet TLD beacon.
|
||
Public mints go through a service-fee output to fund the registry. Chipnet uses a placeholder
|
||
rate; mainnet will price by label length via a real oracle.</p>
|
||
<label>Label <span class="dim" style="margin-left:6px">1–16 chars, [a-z0-9-]</span></label>
|
||
<input type="text" id="tld-label" placeholder="myregistry" autocomplete="off" spellcheck="false" maxlength="16">
|
||
<label style="margin-top:12px">Service fee (USD) <span class="dim" style="margin-left:6px">chipnet test rate</span></label>
|
||
<input type="text" id="tld-usd" value="5">
|
||
<div class="status" id="tld-price">= <b>0</b> sats · rate <b>0</b> sats/USD</div>
|
||
<div class="row">
|
||
<button class="btn acid small" id="tld-check" disabled>Check availability</button>
|
||
<button class="btn small" id="tld-submit" disabled>Register (~0 sats)</button>
|
||
</div>
|
||
<div id="tld-log" class="status" style="display:none;font-family:ui-monospace,monospace;font-size:12.5px;margin-top:12px"></div>
|
||
<div id="tld-msg" style="margin-top:10px;font-size:13.5px"></div>
|
||
</div>
|
||
|
||
<h2 style="margin-top:2rem">Wallet</h2>
|
||
<div class="card">
|
||
<label>Receive address</label>
|
||
<div><code id="me-addr">—</code></div>
|
||
<label style="margin-top:14px">Token address (holds your name certificates)</label>
|
||
<div><code id="me-taddr">—</code></div>
|
||
<div class="row">
|
||
<button class="btn ghost small" id="signout-btn">Sign out</button>
|
||
<span class="dim" id="me-info">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<h2 style="margin-top:2rem">Names you hold</h2>
|
||
<div class="status" id="names-status">Loading names from the chain…</div>
|
||
<div id="names-list"></div>
|
||
</section>
|
||
|
||
</div>
|
||
|
||
<!-- record-editor modal — populated by JS when a name's Edit button fires -->
|
||
<div class="modal" id="editor">
|
||
<div class="sheet">
|
||
<button class="x" id="ed-close" title="close">×</button>
|
||
<h3 id="ed-name">—</h3>
|
||
<p class="sub">Edit records. Only your key can sign this UPD; the transaction fee is a few
|
||
cents in chain fees plus the beacon dust. Leave a field blank to omit that record.</p>
|
||
|
||
<label>h <span class="field-hint">inline HTML — served as-is at the site root</span></label>
|
||
<textarea id="ed-h" placeholder="<h1>My site</h1>"></textarea>
|
||
|
||
<label>s3 <span class="field-hint">Sia bucket key, trailing slash for directories</span></label>
|
||
<input type="text" id="ed-s3" placeholder="bns/myname/">
|
||
|
||
<label>ip <span class="field-hint">IPv4 that serves this name over Host header</span></label>
|
||
<input type="text" id="ed-ip" placeholder="1.2.3.4">
|
||
|
||
<label>tls <span class="field-hint">SHA-256 of your leaf cert — chain-pinned TLS trust</span></label>
|
||
<input type="text" id="ed-tls" placeholder="hex — 64 chars, no separators">
|
||
|
||
<label>u <span class="field-hint">redirect target</span></label>
|
||
<input type="text" id="ed-u" placeholder="https://…">
|
||
|
||
<div class="budget" id="ed-budget">payload: <b><span id="ed-budget-used">0</span></b> / <span id="ed-budget-max">?</span> bytes</div>
|
||
|
||
<div class="steps-log" id="ed-log"></div>
|
||
<div class="msg" id="ed-msg"></div>
|
||
|
||
<div class="actions">
|
||
<button class="btn ghost" id="ed-cancel">Cancel</button>
|
||
<button class="btn acid" id="ed-save">Save records →</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<footer id="site-footer"></footer>
|
||
|
||
<script type="importmap">
|
||
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
|
||
</script>
|
||
|
||
<script type="module">
|
||
// The registrar bundle exports everything we need: BuiltInWallet for local
|
||
// wallet unlock, resolveName/buildIndex for chain queries, getUtxosForAddresses
|
||
// for holdings. This portal is a read-first surface; record edits (UPD) will
|
||
// come next and will piggyback on the same signing path the registrar uses.
|
||
// Cache-buster bumped whenever the bundle changes. Chrome's in-memory ES
|
||
// module map ignores Cache-Control on the .js URL for the lifetime of a tab,
|
||
// so a plain "/js/bns-register.js" import can return a stale instance days
|
||
// after deploy. A query string forces the module loader to treat this as a
|
||
// new URL and fetch fresh bytes.
|
||
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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||
|
||
let wallet = null;
|
||
|
||
// Persist signed-in state so the profile dropdown (profile-menu.js) can flip
|
||
// from "onboarding" to "signed in" and any tab on the same origin (sirius.x)
|
||
// reflects the current session. The wallet keys themselves never leave this
|
||
// page — localStorage only holds a small address-only breadcrumb, no seed.
|
||
function writeProfile(w) {
|
||
try {
|
||
localStorage.setItem("siriusProfile", JSON.stringify({
|
||
address: w.address,
|
||
tokenAddress: w.tokenAddress,
|
||
source: w.source ?? "seed", // "seed" | "wc"
|
||
signedInAt: Date.now(),
|
||
}));
|
||
} catch {}
|
||
// profile-menu.js listens for this to re-render the nav dropdown in the
|
||
// same tab (the "storage" event only fires in *other* tabs).
|
||
window.dispatchEvent(new Event("siriusProfileChanged"));
|
||
}
|
||
function clearProfile() {
|
||
try { localStorage.removeItem("siriusProfile"); } catch {}
|
||
window.dispatchEvent(new Event("siriusProfileChanged"));
|
||
}
|
||
|
||
// ---------- sign in (via shared modal) ----------
|
||
// The old inline cards are gone; portal.html now delegates the whole wallet
|
||
// UX to the shared register-flow.js modal (window.siriusSignInWallet). This
|
||
// keeps portal in lockstep with the landing, tld page, and the wallet
|
||
// dropdown — one tabbed modal, everywhere.
|
||
//
|
||
// Signed-in state is picked up from siriusProfile + window.siriusWallet:
|
||
// - profile only (returning user with a saved wallet, no live wallet in
|
||
// memory) -> auto-open the modal at the Unlock tab
|
||
// - profile + wallet -> call enterPortal() and show the names list
|
||
// - no profile -> stay on the launcher card
|
||
async function adoptWalletFromModal() {
|
||
const profile = readProfile();
|
||
if (!profile) return false;
|
||
if (window.siriusWallet) {
|
||
wallet = window.siriusWallet;
|
||
await enterPortal();
|
||
return true;
|
||
}
|
||
// No live wallet in memory — but if a session mnemonic is cached from an
|
||
// earlier sign-in in this browser session, rebuild the wallet silently.
|
||
// Same tab (or a reload) keeps the user signed in; a full browser close
|
||
// clears sessionStorage and drops the user back to Unlock.
|
||
try {
|
||
const cached = sessionStorage.getItem("siriusSessionMnemonic");
|
||
if (cached) {
|
||
const w = await BNS.BuiltInWallet.fromMnemonic(cached);
|
||
wallet = w;
|
||
window.siriusWallet = w;
|
||
await enterPortal();
|
||
return true;
|
||
}
|
||
} catch { /* fall through to sign-in prompt */ }
|
||
return false;
|
||
}
|
||
function readProfile() {
|
||
try { return JSON.parse(localStorage.getItem("siriusProfile") || "null"); } catch { return null; }
|
||
}
|
||
async function ensureSignInFlow() {
|
||
if (typeof window.siriusRenderSignInInline !== "function") {
|
||
// register-flow.js hasn't landed yet — wait briefly.
|
||
for (let i = 0; i < 60 && typeof window.siriusRenderSignInInline !== "function"; i++) {
|
||
await new Promise((r) => setTimeout(r, 50));
|
||
}
|
||
}
|
||
return typeof window.siriusRenderSignInInline === "function";
|
||
}
|
||
|
||
// Paint the tabbed sign-in as the page's main content. The user picks a tab
|
||
// (Unlock / Create / Import / WizardConnect) and the form for that path
|
||
// renders in place — no modal, no launcher.
|
||
async function paintInlineSignIn(mode) {
|
||
if (!(await ensureSignInFlow())) return;
|
||
const hasSaved = (() => { try { return !!localStorage.getItem("bns.wallet.v1"); } catch { return false; } })();
|
||
const startTab = mode || (hasSaved ? "unlock" : "import");
|
||
window.siriusRenderSignInInline($("signin-inline"), startTab);
|
||
}
|
||
|
||
// Re-check on every profile change (fired by register-flow after finishSignIn
|
||
// and by profile-menu after sign-out). Same-tab and cross-tab both fire this.
|
||
window.addEventListener("siriusProfileChanged", async () => {
|
||
if (readProfile()) { await adoptWalletFromModal(); }
|
||
else { signOutUi(); }
|
||
});
|
||
window.addEventListener("storage", async (e) => {
|
||
if (e.key !== "siriusProfile") return;
|
||
if (readProfile()) { await adoptWalletFromModal(); }
|
||
else { signOutUi(); }
|
||
});
|
||
|
||
async function signOutUi() {
|
||
wallet = null;
|
||
window.siriusWallet = null;
|
||
$("me").classList.add("hidden");
|
||
$("signin").classList.remove("hidden");
|
||
$("names-list").innerHTML = "";
|
||
$("names-status").textContent = "Loading names from the chain…";
|
||
// Re-paint the sign-in tabs so a returning user can go straight to Unlock
|
||
// (or Import if no saved wallet).
|
||
await paintInlineSignIn();
|
||
}
|
||
|
||
// (old inline seed/create/wc handlers deleted — see the modal launcher above)
|
||
/* HANDLERS_REMOVED_MARKER
|
||
$("new-btn").addEventListener("click", async () => {
|
||
const btn = $("new-btn");
|
||
const errBox = $("new-err");
|
||
errBox.classList.add("hidden");
|
||
btn.disabled = true; btn.textContent = "Generating…";
|
||
let fresh;
|
||
try { fresh = await BNS.BuiltInWallet.create(); }
|
||
catch (e) {
|
||
errBox.textContent = "Could not generate a phrase: " + (e.message || e);
|
||
errBox.classList.remove("hidden");
|
||
btn.disabled = false; btn.textContent = "Generate a phrase";
|
||
return;
|
||
}
|
||
wallet = fresh;
|
||
$("new-seed").value = fresh.mnemonic;
|
||
$("new-phrase").classList.remove("hidden");
|
||
btn.disabled = true; btn.textContent = "Phrase generated";
|
||
});
|
||
|
||
$("new-ack").addEventListener("change", (e) => {
|
||
$("new-signin-btn").disabled = !e.target.checked;
|
||
});
|
||
|
||
$("new-copy-btn").addEventListener("click", () => {
|
||
navigator.clipboard?.writeText($("new-seed").value);
|
||
});
|
||
|
||
$("new-signin-btn").addEventListener("click", async () => {
|
||
if (!wallet) return;
|
||
// Wipe the visible phrase immediately — it's in the wallet object now.
|
||
$("new-seed").value = "";
|
||
$("new-phrase").classList.add("hidden");
|
||
writeProfile(wallet);
|
||
await enterPortal();
|
||
});
|
||
|
||
// ---------- WizardConnect ----------
|
||
// Ports the WC flow from register.html into a sign-in surface. The portal
|
||
// doesn't need to mint — it only reads holdings and signs UPDs — so this is
|
||
// simpler than the register flow: open a session, show the URI/QR, wait for
|
||
// approval, wrap the returned addresses in a wallet-shaped object with a
|
||
// `source: "wc"` flag so downstream code (record editor, sign-out) can
|
||
// dispatch to the external-wallet path.
|
||
$("wc-btn").addEventListener("click", async () => {
|
||
const btn = $("wc-btn");
|
||
const errBox = $("wc-err");
|
||
const uriBox = $("wc-uri-box");
|
||
errBox.classList.add("hidden");
|
||
btn.disabled = true; btn.textContent = "Starting…";
|
||
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,
|
||
// libauth is only bundled once (in the main bundle); wizardconnect.js
|
||
// needs these pubkey/hash helpers passed through.
|
||
crypto: {
|
||
pubkeyToAddress: BNS.pubkeyToAddress,
|
||
pubkeyToLockingBytecode: BNS.pubkeyToLockingBytecode,
|
||
binToHex: BNS.binToHex,
|
||
txidOfHex: BNS.txidOfHex,
|
||
},
|
||
});
|
||
} catch (e) {
|
||
errBox.textContent = "Could not start WizardConnect: " + (e.message || e);
|
||
errBox.classList.remove("hidden");
|
||
btn.disabled = false; btn.textContent = "Connect wallet";
|
||
return;
|
||
}
|
||
const uri = session.qrUri ?? session.uri;
|
||
$("wc-uri").textContent = uri;
|
||
uriBox.classList.remove("hidden");
|
||
btn.textContent = "Waiting for wallet…";
|
||
|
||
try {
|
||
await session.ready;
|
||
} catch (e) {
|
||
errBox.textContent = "Wallet did not approve: " + (e.message || e);
|
||
errBox.classList.remove("hidden");
|
||
uriBox.classList.add("hidden");
|
||
btn.disabled = false; btn.textContent = "Connect wallet";
|
||
try { await session.disconnect(); } catch {}
|
||
return;
|
||
}
|
||
|
||
let addresses;
|
||
try { addresses = await session.getAddresses(); }
|
||
catch (e) {
|
||
errBox.textContent = "Wallet returned no addresses: " + (e.message || e);
|
||
errBox.classList.remove("hidden");
|
||
uriBox.classList.add("hidden");
|
||
btn.disabled = false; btn.textContent = "Connect wallet";
|
||
try { await session.disconnect(); } catch {}
|
||
return;
|
||
}
|
||
if (!addresses?.length) {
|
||
errBox.textContent = "The connected wallet exposed no addresses.";
|
||
errBox.classList.remove("hidden");
|
||
uriBox.classList.add("hidden");
|
||
btn.disabled = false; btn.textContent = "Connect wallet";
|
||
try { await session.disconnect(); } catch {}
|
||
return;
|
||
}
|
||
|
||
// Wallet-shaped wrapper — enough for holdings lookup and dispatching the
|
||
// WC path in the record editor. External wallets don't expose keyFor(),
|
||
// so any codepath that needs it (TLD mint via built-in path) is guarded
|
||
// by `wallet.source !== "wc"`.
|
||
wallet = {
|
||
source: "wc",
|
||
address: addresses[0],
|
||
tokenAddress: BNS.toTokenAddress(addresses[0]),
|
||
watchedAddresses: addresses,
|
||
session,
|
||
};
|
||
writeProfile(wallet);
|
||
uriBox.classList.add("hidden");
|
||
btn.disabled = false; btn.textContent = "Connect wallet";
|
||
await enterPortal();
|
||
});
|
||
|
||
$("wc-cancel").addEventListener("click", async () => {
|
||
if (wallet?.session) { try { await wallet.session.disconnect(); } catch {} }
|
||
$("wc-uri-box").classList.add("hidden");
|
||
$("wc-btn").disabled = false;
|
||
$("wc-btn").textContent = "Connect wallet";
|
||
});
|
||
|
||
$("wc-copy").addEventListener("click", () => {
|
||
navigator.clipboard?.writeText($("wc-uri").textContent);
|
||
});
|
||
|
||
HANDLERS_REMOVED_MARKER */
|
||
|
||
// Signed-in section's own sign-out button. Delegates the profile+wallet
|
||
// teardown to the shared modal's signOutUi() (already declared above).
|
||
$("signout-btn")?.addEventListener("click", async () => {
|
||
if (wallet?.source === "wc" && wallet.session) {
|
||
try { await wallet.session.disconnect(); } catch {}
|
||
}
|
||
clearProfile();
|
||
signOutUi();
|
||
});
|
||
|
||
// ---------- ?mode= deep-link ----------
|
||
// portal.html?mode=new|import|wc|unlock opens the sign-in section on that
|
||
// tab. Sign-in is INLINE — the tabbed UI is the page, no modal.
|
||
(async function bootstrap() {
|
||
const profile = readProfile();
|
||
const mode = new URLSearchParams(location.search).get("mode");
|
||
if (profile) {
|
||
// Signed-in section takes over when we have a live wallet; otherwise
|
||
// paint the sign-in section at the Unlock tab so a returning user just
|
||
// enters their password.
|
||
const adopted = await adoptWalletFromModal();
|
||
if (adopted) return;
|
||
await paintInlineSignIn(mode || "unlock");
|
||
return;
|
||
}
|
||
await paintInlineSignIn(mode);
|
||
})();
|
||
|
||
async function enterPortal() {
|
||
$("signin").classList.add("hidden");
|
||
$("me").classList.remove("hidden");
|
||
$("me-addr").textContent = wallet.address;
|
||
$("me-taddr").textContent = wallet.tokenAddress;
|
||
$("me-info").textContent = "watched addresses: " + wallet.watchedAddresses.length;
|
||
// Initialise the TLD-register card now that the wallet is available.
|
||
if (typeof updateTldPrice === "function") { updateTldPrice(); updateTldButtons(); }
|
||
await loadNames();
|
||
}
|
||
|
||
// ---------- load names ----------
|
||
// Approach: derive the scripthash for each watched address locally (the
|
||
// wallet code already knows how), send them in one POST to
|
||
// /api/holdings, and render whatever the gateway returns. Doing the
|
||
// wallet-holdings query server-side is what makes this work on browsers
|
||
// stuck behind firewalls that block chipnet electrum ports (50004,
|
||
// 50011, 62002) — the API is same-origin HTTPS on port 443, and the
|
||
// gateway can reach the full electrums the browser can't.
|
||
async function loadNames() {
|
||
const status = $("names-status");
|
||
const list = $("names-list");
|
||
list.innerHTML = "";
|
||
status.className = "status";
|
||
status.textContent = "Preparing your addresses…";
|
||
try {
|
||
const addresses = wallet.watchedAddresses;
|
||
const scripthashes = addresses.map((a) => BNS.addressToScripthash(a));
|
||
status.textContent = `Asking the gateway about ${addresses.length} addresses…`;
|
||
const r = await fetch("https://silentmode.st/api/holdings", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ scripthashes }),
|
||
});
|
||
if (!r.ok) {
|
||
const detail = await r.text().catch(() => "");
|
||
throw new Error(`API ${r.status}: ${detail.slice(0, 200)}`);
|
||
}
|
||
const { count, names: mine } = await r.json();
|
||
if (!count) {
|
||
status.className = "status";
|
||
status.textContent = "No BCNR names found for this wallet. If you just registered, wait a minute for confirmation and reload.";
|
||
return;
|
||
}
|
||
status.className = "status ok";
|
||
status.textContent = `You own ${count} name${count === 1 ? "" : "s"}.`;
|
||
for (const e of mine) {
|
||
const div = document.createElement("div");
|
||
div.className = "name-row";
|
||
const records = e.records && Object.keys(e.records).length
|
||
? Object.entries(e.records).map(([k, v]) => `<span class="k">${esc(k)}=</span>${esc(String(v).slice(0, 80))}`).join(" · ")
|
||
: '<span class="k">(no records set)</span>';
|
||
const recJson = esc(JSON.stringify(e.records || {}));
|
||
div.innerHTML =
|
||
`<div class="n">${esc(e.name)}</div>` +
|
||
'<span class="badge b-x">on chain</span>' +
|
||
`<a class="btn small ghost" href="https://silentmode.st/bns/${encodeURIComponent(e.name)}/" target="_blank" rel="noopener">view →</a>` +
|
||
`<button class="btn small acid" data-edit="${esc(e.name)}" data-records='${recJson}'>Edit</button>` +
|
||
`<div class="rec">${records}</div>`;
|
||
list.appendChild(div);
|
||
}
|
||
// Wire Edit buttons after render.
|
||
list.querySelectorAll("[data-edit]").forEach((btn) => {
|
||
btn.onclick = () => openEditor(btn.getAttribute("data-edit"), JSON.parse(btn.getAttribute("data-records") || "{}"));
|
||
});
|
||
} catch (e) {
|
||
status.className = "status err";
|
||
status.textContent = "Could not load names: " + (e.message || e);
|
||
}
|
||
}
|
||
|
||
// ---------- record editor ----------
|
||
// Modal that composes a UPD payload from a small form and signs+broadcasts
|
||
// with the same code path register.html uses (BNS.setRecordsWithBuiltInWallet).
|
||
// External-wallet (WizardConnect) support piggybacks the same adapter and is a
|
||
// follow-up — the built-in path is the one this session's wallet flow can already
|
||
// sign with.
|
||
const EDITABLE_KEYS = ["h", "s3", "ip", "tls", "u"];
|
||
let editorName = null;
|
||
|
||
function openEditor(name, records) {
|
||
editorName = name;
|
||
$("ed-name").textContent = name;
|
||
for (const k of EDITABLE_KEYS) $("ed-" + k).value = records[k] || "";
|
||
$("ed-msg").textContent = "";
|
||
$("ed-msg").className = "msg";
|
||
$("ed-log").className = "steps-log";
|
||
$("ed-log").innerHTML = "";
|
||
$("ed-save").disabled = false;
|
||
$("ed-save").textContent = "Save records →";
|
||
updateBudget();
|
||
$("editor").classList.add("open");
|
||
}
|
||
function closeEditor() {
|
||
$("editor").classList.remove("open");
|
||
editorName = null;
|
||
}
|
||
$("ed-close").addEventListener("click", closeEditor);
|
||
$("ed-cancel").addEventListener("click", closeEditor);
|
||
$("editor").addEventListener("click", (e) => { if (e.target === $("editor")) closeEditor(); });
|
||
|
||
// Bytes budget: BNS payload is capped at 200 bytes total, of which the envelope
|
||
// (op + name) uses some. Show the user how many bytes their records take vs the
|
||
// remaining budget, and disable Save if they've gone over.
|
||
function collectRecords() {
|
||
const r = {};
|
||
for (const k of EDITABLE_KEYS) {
|
||
const v = $("ed-" + k).value.trim();
|
||
if (v) r[k] = v;
|
||
}
|
||
return r;
|
||
}
|
||
function updateBudget() {
|
||
if (!editorName) return;
|
||
try {
|
||
const max = BNS.payloadBudget(editorName, "UPD");
|
||
// The records object encodes to JSON inside the payload. Rough size:
|
||
// JSON.stringify(records).length gives a close-enough client-side estimate
|
||
// (the actual envelope is composed by the bundle at send time).
|
||
const used = new TextEncoder().encode(JSON.stringify(collectRecords())).length;
|
||
$("ed-budget-max").textContent = max;
|
||
$("ed-budget-used").textContent = used;
|
||
const over = used > max;
|
||
$("ed-budget").classList.toggle("over", over);
|
||
$("ed-save").disabled = over;
|
||
} catch { /* budget helper not available yet — ignore */ }
|
||
}
|
||
for (const k of EDITABLE_KEYS) $("ed-" + k).addEventListener("input", updateBudget);
|
||
|
||
$("ed-save").addEventListener("click", async () => {
|
||
if (!editorName || !wallet) return;
|
||
const records = collectRecords();
|
||
const msg = $("ed-msg");
|
||
const log = $("ed-log");
|
||
log.className = "steps-log on";
|
||
log.innerHTML = "";
|
||
const push = (t) => { const d = document.createElement("div"); d.textContent = t; log.appendChild(d); log.scrollTop = log.scrollHeight; };
|
||
msg.textContent = ""; msg.className = "msg";
|
||
$("ed-save").disabled = true;
|
||
$("ed-save").textContent = "Signing…";
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect();
|
||
push("connected to chipnet");
|
||
const res = wallet.source === "wc"
|
||
? await BNS.setRecordsWithExternalWallet(el, {
|
||
session: wallet.session, name: editorName, records,
|
||
onProgress: (step) => push(step),
|
||
})
|
||
: await BNS.setRecordsWithBuiltInWallet(el, {
|
||
wallet, name: editorName, records,
|
||
onProgress: (step) => push(step),
|
||
});
|
||
push("broadcast: " + (res.txid || res.txId || "(no txid)"));
|
||
msg.className = "msg ok";
|
||
msg.textContent = "Saved. Records will refresh on the chain within a block.";
|
||
$("ed-save").textContent = "Saved ✓";
|
||
// Refresh the names list in the background so the user sees the new
|
||
// records once they land (may take a few seconds via cache/mempool).
|
||
setTimeout(() => loadNames(), 2500);
|
||
} catch (e) {
|
||
msg.className = "msg err";
|
||
msg.textContent = "Failed: " + (e.message || e);
|
||
push("error: " + (e.message || e));
|
||
$("ed-save").disabled = false;
|
||
$("ed-save").textContent = "Save records →";
|
||
} finally {
|
||
try { el?.close?.(); } catch {}
|
||
}
|
||
});
|
||
|
||
// ---------- TLD register (chipnet MVP) ----------
|
||
// Simple oracle: static chipnet rate for testing. Replace with a live feed
|
||
// (CoinGecko, Kraken, or a Chainlink adapter) before mainnet. The `sats` output
|
||
// is what actually gets locked into a service-fee output on the TREG transaction.
|
||
const CHIPNET_SATS_PER_USD = 250_000; // 1 BCH ≈ $400 test rate
|
||
function usdToSats(usd) {
|
||
const n = Number(String(usd).replace(/[^\d.]/g, "")) || 0;
|
||
return Math.max(0, Math.round(n * CHIPNET_SATS_PER_USD));
|
||
}
|
||
|
||
function updateTldPrice() {
|
||
const usd = $("tld-usd").value.trim() || "0";
|
||
const sats = usdToSats(usd);
|
||
$("tld-price").innerHTML = `= <b>${sats.toLocaleString()}</b> sats · rate <b>${CHIPNET_SATS_PER_USD.toLocaleString()}</b> sats/USD (chipnet)`;
|
||
$("tld-submit").textContent = `Register (~${sats.toLocaleString()} sats)`;
|
||
}
|
||
|
||
function validateTldLabel(label) {
|
||
return /^[a-z0-9-]{1,16}$/.test(label);
|
||
}
|
||
|
||
function updateTldButtons() {
|
||
const label = $("tld-label").value.trim().toLowerCase();
|
||
const valid = validateTldLabel(label);
|
||
$("tld-check").disabled = !valid || !wallet;
|
||
$("tld-submit").disabled = !valid || !wallet;
|
||
}
|
||
|
||
$("tld-label").addEventListener("input", () => {
|
||
// normalise as user types
|
||
$("tld-label").value = $("tld-label").value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 16);
|
||
updateTldButtons();
|
||
});
|
||
$("tld-usd").addEventListener("input", updateTldPrice);
|
||
|
||
$("tld-check").addEventListener("click", async () => {
|
||
const label = $("tld-label").value.trim();
|
||
const msg = $("tld-msg"); msg.textContent = ""; msg.className = "";
|
||
try {
|
||
const r = await fetch("https://navigate.st/api/tlds");
|
||
if (!r.ok) throw new Error("api " + r.status);
|
||
const j = await r.json();
|
||
const taken = (j.tlds || []).some((t) => (t.tld || t) === label);
|
||
msg.className = taken ? "err" : "ok";
|
||
msg.textContent = taken ? `❌ .${label} is already registered` : `✅ .${label} is available`;
|
||
msg.style.color = taken ? "var(--taken)" : "var(--ok)";
|
||
} catch (e) {
|
||
msg.textContent = "could not check: " + (e.message || e);
|
||
msg.style.color = "var(--warn)";
|
||
}
|
||
});
|
||
|
||
$("tld-submit").addEventListener("click", async () => {
|
||
if (!wallet) return;
|
||
const label = $("tld-label").value.trim();
|
||
if (!validateTldLabel(label)) return;
|
||
const sats = BigInt(usdToSats($("tld-usd").value.trim() || "0"));
|
||
const msg = $("tld-msg"); msg.textContent = ""; msg.className = "";
|
||
const log = $("tld-log"); log.style.display = "block"; log.innerHTML = "";
|
||
const push = (t) => { const d = document.createElement("div"); d.textContent = "› " + t; log.appendChild(d); log.scrollTop = log.scrollHeight; };
|
||
$("tld-submit").disabled = true;
|
||
$("tld-submit").textContent = "Signing…";
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect();
|
||
push("connected to chipnet");
|
||
const serviceFee = {
|
||
address: BNS.REGISTRAR?.serviceFee?.address ?? null,
|
||
sats,
|
||
};
|
||
const res = wallet.source === "wc"
|
||
? await BNS.registerTldWithExternalWallet(el, {
|
||
session: wallet.session,
|
||
tld: label,
|
||
records: {},
|
||
serviceFee,
|
||
tldListUrl: "https://navigate.st/api/tlds",
|
||
onProgress: (step) => push(step),
|
||
})
|
||
: await BNS.registerTldWithBuiltInWallet(el, {
|
||
wallet,
|
||
tld: label,
|
||
records: {},
|
||
serviceFee,
|
||
tldListUrl: "https://navigate.st/api/tlds",
|
||
onProgress: (step) => push(step),
|
||
});
|
||
push("broadcast: " + (res.txid || res.txId || "(no txid)"));
|
||
msg.style.color = "var(--ok)";
|
||
msg.textContent = `✅ .${label} minted. Category ${(res.category || "").slice(0, 16)}…`;
|
||
$("tld-submit").textContent = "Registered ✓";
|
||
// Trigger a name-list refresh — new TLD cert is an NFT held by this wallet.
|
||
setTimeout(() => loadNames(), 2500);
|
||
} catch (e) {
|
||
msg.style.color = "var(--taken)";
|
||
const raw = String(e.message || e);
|
||
// The most common failure by far: wallet has no chipnet coins. Turn the
|
||
// opaque error into a nudge toward a faucet.
|
||
if (/wallet is empty/i.test(raw)) {
|
||
msg.innerHTML = `Failed: wallet is empty. Fund <code style="font-size:11.5px">${esc(wallet.address)}</code>
|
||
with chipnet coins first — free from
|
||
<a href="https://tbch.googol.cash/" target="_blank" rel="noopener">tbch.googol.cash</a> or
|
||
<a href="https://chipnet.bch.ninja/faucet" target="_blank" rel="noopener">chipnet.bch.ninja/faucet</a>.`;
|
||
} else {
|
||
msg.textContent = "Failed: " + raw;
|
||
}
|
||
push("error: " + raw);
|
||
$("tld-submit").disabled = false;
|
||
updateTldPrice(); // reset button label
|
||
} finally {
|
||
try { el?.close?.(); } catch {}
|
||
}
|
||
});
|
||
// Initial paint on page load so the card shows the price even before sign-in.
|
||
updateTldPrice();
|
||
</script>
|
||
<script defer src="./js/site-footer.js?v=20260907rel"></script>
|
||
<script type="module" src="./js/register-flow.js?v=20260908tabs"></script>
|
||
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
|
||
</body>
|
||
</html>
|