BuiltInWallet.signMessage read `recovery` from libauth's recoverable signature, but libauth v3 names it `recoveryId`. The flag byte came out as 0, so verifiers recovered the right key only when the true recovery id happened to be 1. Signed DNS manifests and Studio uploads failed with "signature does not match current on-chain NFT owner" and a different derived address each attempt. Read the right field, fail loudly if it is missing, rebuild the browser bundle and move every importer to the new bundle URL. Also lands the registrar's signRecordsManifest that the bundle already shipped.
865 lines
51 KiB
JavaScript
865 lines
51 KiB
JavaScript
// Sirius.X dashboard — the signed-in control panel for names and TLDs.
|
||
//
|
||
// One page, a left menu, several panes: Overview, Domain names (with a
|
||
// per-name detail: DNS records, content & hosting, redirect, transfer,
|
||
// export), My TLD list (with a per-TLD detail), Wallet, Settings.
|
||
//
|
||
// Trust model is unchanged from the old portal: the wallet lives in this
|
||
// browser; every on-chain change is a transaction signed here; DNS records
|
||
// are a manifest signed here and stored on Sia via the gateway. What is new
|
||
// is the persistent device session (js/session.js): sign in once, come back
|
||
// without a prompt until you sign out.
|
||
|
||
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260917sig";
|
||
|
||
const API = "https://silentmode.st";
|
||
const TLD_API = "https://navigate.st/api/tlds?include_hidden=1";
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||
const fmtInt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||
const shortAddr = (a) => (a ? a.replace(/^[^:]+:/, "").slice(0, 8) + "…" + a.slice(-6) : "—");
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||
|
||
// ---------- state ----------
|
||
let wallet = null;
|
||
const H = { names: [], tlds: [], wallet: null, chain: null, loadedAt: 0 };
|
||
const dnsCache = new Map(); // name -> { status: "none"|"ok"|"nopointer"|"error", seq, dns, raw }
|
||
let registryCache = { at: 0, names: [] };
|
||
let current = { name: null, entry: null, manifestSeq: 0, rows: [], dirty: false };
|
||
let currentTld = null;
|
||
|
||
// ---------- profile breadcrumb (shared with profile-menu.js) ----------
|
||
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"));
|
||
}
|
||
function readProfile() {
|
||
try { return JSON.parse(localStorage.getItem("siriusProfile") || "null"); } catch { return null; }
|
||
}
|
||
function clearProfile() {
|
||
try { localStorage.removeItem("siriusProfile"); } catch {}
|
||
window.dispatchEvent(new Event("siriusProfileChanged"));
|
||
}
|
||
|
||
// ---------- session ----------
|
||
async function restoreSession() {
|
||
if (window.siriusWallet) return window.siriusWallet;
|
||
const S = window.siriusSession;
|
||
if (!S?.restore) return null;
|
||
try {
|
||
const s = await S.restore();
|
||
if (!s?.mnemonic) return null;
|
||
const w = await BNS.BuiltInWallet.fromMnemonic(s.mnemonic, BNS.CHIPNET_PREFIX, s.accountPath || undefined);
|
||
window.siriusWallet = w;
|
||
return w;
|
||
} catch { return null; }
|
||
}
|
||
|
||
async function ensureSignInFlow() {
|
||
for (let i = 0; i < 80 && typeof window.siriusRenderSignInInline !== "function"; i++) await sleep(50);
|
||
return typeof window.siriusRenderSignInInline === "function";
|
||
}
|
||
async function paintInlineSignIn(mode) {
|
||
if (!(await ensureSignInFlow())) return;
|
||
const hasSaved = (() => { try { return !!localStorage.getItem("bns.wallet.v1"); } catch { return false; } })();
|
||
window.siriusRenderSignInInline($("signin-inline"), mode || (hasSaved ? "unlock" : "import"));
|
||
}
|
||
|
||
function showSignedOut() {
|
||
wallet = null;
|
||
window.siriusWallet = null;
|
||
$("me").classList.add("hidden");
|
||
$("signed-out").classList.remove("hidden");
|
||
}
|
||
|
||
async function enter(w) {
|
||
wallet = w;
|
||
window.siriusWallet = w;
|
||
$("signed-out").classList.add("hidden");
|
||
$("me").classList.remove("hidden");
|
||
$("side-addr").textContent = w.address;
|
||
$("me-addr").textContent = w.address;
|
||
$("me-taddr").textContent = w.tokenAddress;
|
||
$("me-info").textContent = `watched addresses: ${w.watchedAddresses.length}`;
|
||
$("w-watched").textContent = String(w.watchedAddresses.length);
|
||
paintSettings();
|
||
updateTldPrice(); updateTldButtons();
|
||
routeFromHash();
|
||
await loadHoldings();
|
||
}
|
||
|
||
async function signOut({ forget = false } = {}) {
|
||
if (wallet?.source === "wc" && wallet.session) { try { await wallet.session.disconnect(); } catch {} }
|
||
try { await window.siriusSession?.forget?.(); } catch {}
|
||
if (forget) {
|
||
try { BNS.BuiltInWallet.forget(); } catch {}
|
||
try { window.siriusPin?.clear?.(); } catch {}
|
||
}
|
||
clearProfile();
|
||
showSignedOut();
|
||
history.replaceState(null, "", location.pathname);
|
||
await paintInlineSignIn(forget ? "import" : "unlock");
|
||
}
|
||
|
||
window.addEventListener("siriusProfileChanged", async () => {
|
||
if (readProfile()) { if (window.siriusWallet && !wallet) await enter(window.siriusWallet); }
|
||
else if (wallet) { try { await window.siriusSession?.forget?.(); } catch {} showSignedOut(); await paintInlineSignIn(); }
|
||
});
|
||
window.addEventListener("storage", async (e) => {
|
||
if (e.key !== "siriusProfile") return;
|
||
if (!readProfile() && wallet) { showSignedOut(); await paintInlineSignIn(); }
|
||
});
|
||
|
||
(async function bootstrap() {
|
||
const mode = new URLSearchParams(location.search).get("mode");
|
||
if (window.siriusWallet) return enter(window.siriusWallet);
|
||
const w = await restoreSession();
|
||
if (w) { writeProfile(w); return enter(w); }
|
||
await paintInlineSignIn(mode || undefined);
|
||
})();
|
||
|
||
// ---------- navigation ----------
|
||
const PANES = ["studio", "domains", "domain", "tlds", "tld", "newtld", "settings"];
|
||
function go(pane, arg) {
|
||
if (!PANES.includes(pane)) pane = "domains";
|
||
document.querySelectorAll(".pane[data-pane]").forEach((p) => { p.hidden = p.dataset.pane !== pane; });
|
||
const activeItem = pane === "domain" ? "domains" : pane === "tld" ? "tlds" : pane;
|
||
document.querySelectorAll(".side .item[data-go]").forEach((b) => b.classList.toggle("active", b.dataset.go === activeItem));
|
||
const hash = arg ? `#${pane}/${encodeURIComponent(arg)}` : `#${pane}`;
|
||
if (location.hash !== hash) history.replaceState(null, "", hash);
|
||
if (pane === "domain" && arg) openDomain(arg);
|
||
if (pane === "tld" && arg) openTld(arg);
|
||
if (pane === "tlds") renderTlds();
|
||
if (pane === "domains") renderDomains();
|
||
if (pane === "studio") renderStudio();
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
function routeFromHash() {
|
||
const m = /^#([a-z]+)(?:\/(.+))?$/.exec(location.hash || "");
|
||
if (!m) return go("domains");
|
||
go(m[1], m[2] ? decodeURIComponent(m[2]) : undefined);
|
||
}
|
||
window.addEventListener("hashchange", () => { if (wallet) routeFromHash(); });
|
||
document.addEventListener("click", (e) => {
|
||
const el = e.target.closest("[data-go]");
|
||
if (!el) return;
|
||
e.preventDefault();
|
||
go(el.dataset.go, el.dataset.arg);
|
||
});
|
||
$("side-signout").addEventListener("click", () => signOut());
|
||
$("set-signout").addEventListener("click", () => signOut());
|
||
$("set-forget").addEventListener("click", async () => {
|
||
if (!confirm("Forget the wallet on this device? You will need your recovery phrase to sign in again.")) return;
|
||
await signOut({ forget: true });
|
||
});
|
||
$("refresh-btn").addEventListener("click", () => loadHoldings());
|
||
document.querySelectorAll("[data-copy]").forEach((b) => b.addEventListener("click", () => {
|
||
const t = $(b.dataset.copy)?.textContent || "";
|
||
navigator.clipboard?.writeText(t);
|
||
const o = b.textContent; b.textContent = "Copied"; setTimeout(() => { b.textContent = o; }, 1200);
|
||
}));
|
||
|
||
// ---------- holdings ----------
|
||
async function loadHoldings() {
|
||
const status = $("names-status");
|
||
status.className = "status"; status.textContent = "Loading names from the chain…";
|
||
try {
|
||
const scripthashes = wallet.watchedAddresses.map((a) => BNS.addressToScripthash(a));
|
||
const r = await fetch(`${API}/api/holdings`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ scripthashes }) });
|
||
if (!r.ok) throw new Error(`API ${r.status}: ${(await r.text().catch(() => "")).slice(0, 200)}`);
|
||
const p = await r.json();
|
||
H.names = (p.names || []).map((n) => ({ ...n, label: n.name.split(".")[0] }));
|
||
H.tlds = p.tlds || [];
|
||
H.wallet = p.wallet || null; H.chain = p.chain || null; H.loadedAt = Date.now();
|
||
// header numbers
|
||
const setText = (id, v) => { const el = $(id); if (el) el.textContent = v; };
|
||
setText("cnt-names", H.names.length); setText("cnt-tlds", H.tlds.length);
|
||
if (H.wallet?.sats != null) {
|
||
const s = fmtInt(H.wallet.sats);
|
||
setText("side-balance", s); setText("w-sats", `${s} sat`); setText("w-utxos", String(H.wallet.utxo_count));
|
||
}
|
||
if (H.chain?.height != null) setText("stat-height", fmtInt(H.chain.height));
|
||
renderAttention(); renderDomains(); renderTlds(); renderStudio();
|
||
if (current.name) { const e = H.names.find((n) => n.name === current.name); if (e) { current.entry = e; fillSummary(e); } }
|
||
warmDnsCache();
|
||
} catch (e) {
|
||
status.className = "status err";
|
||
status.textContent = "Could not load names: " + (e.message || e);
|
||
}
|
||
}
|
||
|
||
// DNS presence per name, fetched in the background (4 at a time) so the
|
||
// list can show a badge and Overview can flag names without records.
|
||
async function fetchDns(name, force = false) {
|
||
if (!force && dnsCache.has(name)) return dnsCache.get(name);
|
||
let out;
|
||
try {
|
||
const r = await fetch(`${API}/api/records/${encodeURIComponent(name)}`, { cache: "no-store" });
|
||
if (r.status === 404) out = { status: "none", seq: 0, dns: {} };
|
||
else if (r.status === 409) out = { status: "nopointer", seq: 0, dns: {} };
|
||
else if (!r.ok) out = { status: "error", seq: 0, dns: {} };
|
||
else { const j = await r.json(); out = { status: "ok", seq: typeof j.seq === "number" ? j.seq : 0, dns: j.dns || {}, raw: j }; }
|
||
} catch { out = { status: "error", seq: 0, dns: {} }; }
|
||
dnsCache.set(name, out);
|
||
return out;
|
||
}
|
||
async function warmDnsCache() {
|
||
const todo = H.names.filter((n) => !dnsCache.has(n.name)).map((n) => n.name);
|
||
let i = 0;
|
||
const worker = async () => { while (i < todo.length) { const n = todo[i++]; await fetchDns(n); updateDnsBadge(n); } };
|
||
await Promise.all([worker(), worker(), worker(), worker()]);
|
||
renderAttention();
|
||
}
|
||
function dnsCount(d) {
|
||
if (!d) return 0;
|
||
return ["A", "AAAA", "MX", "TXT", "NS", "SRV", "CAA"].reduce((n, k) => n + (Array.isArray(d[k]) ? d[k].length : 0), 0) + (d.CNAME ? 1 : 0);
|
||
}
|
||
function updateDnsBadge(name) {
|
||
const c = dnsCache.get(name);
|
||
document.querySelectorAll(`[data-dnsbadge="${CSS.escape(name)}"]`).forEach((el) => {
|
||
const n = c?.status === "ok" ? dnsCount(c.dns) : 0;
|
||
el.className = "badge " + (n ? "b-ok" : "b-dim");
|
||
el.textContent = n ? `DNS · ${n}` : (c?.status === "nopointer" ? "DNS needs s3" : "no DNS");
|
||
});
|
||
if (current.name === name) fillDnsBadge();
|
||
}
|
||
|
||
// ---------- classification helpers ----------
|
||
function pointsTo(rec) {
|
||
if (!rec) return { kind: "none", label: "not pointed", cls: "b-warn" };
|
||
if (rec.h) return { kind: "h", label: "site on chain", cls: "b-x" };
|
||
if (rec.s3) return { kind: "s3", label: "site on Sia", cls: "b-x" };
|
||
if (rec.p) return { kind: "p", label: "reverse proxy", cls: "b-x" };
|
||
if (rec.ip) return { kind: "ip", label: "own server", cls: "b-x" };
|
||
if (rec.u) return { kind: "u", label: "redirect", cls: "b-x" };
|
||
return { kind: "none", label: "not pointed", cls: "b-warn" };
|
||
}
|
||
const siteUrl = (name) => `${API}/bns/${encodeURIComponent(name)}/`;
|
||
const txUrl = (txid) => `https://chipnet.imaginary.cash/tx/${txid}`;
|
||
|
||
// ---------- attention strip (top of Domain names) ----------
|
||
function renderAttention() {
|
||
const items = [];
|
||
for (const e of H.names) {
|
||
const pt = pointsTo(e.records);
|
||
if (pt.kind === "none") items.push({ e, why: "points nowhere yet", go: "content" });
|
||
}
|
||
const box = $("ov-attention");
|
||
if (!items.length) { box.innerHTML = ""; return; }
|
||
box.innerHTML = `<div class="dim" style="margin:6px 0 4px">Needs attention</div>` +
|
||
items.slice(0, 5).map(({ e, why, go }) => `<div class="drow" data-open="${esc(e.name)}" data-sub="${go}"><div class="n">${nameHtml(e)}</div><span class="badge b-warn">${esc(why)}</span></div>`).join("");
|
||
}
|
||
|
||
// ---------- web-builder (Sirius Studio) ----------
|
||
function renderStudio() {
|
||
const status = $("studio-status"), list = $("studio-list");
|
||
if (!status || !list) return;
|
||
if (!H.names.length) {
|
||
status.className = "status";
|
||
status.innerHTML = `You need a name first. <a href="./#search-input">Register one →</a> — then come back here to build its site.`;
|
||
list.innerHTML = ""; return;
|
||
}
|
||
status.className = "status ok";
|
||
status.textContent = `Pick a name to build or edit its site.`;
|
||
list.innerHTML = [...H.names].sort((a, b) => a.name.localeCompare(b.name)).map((e) => {
|
||
const rec = e.records || {};
|
||
const studioFolder = `bns/${e.name}/`;
|
||
const st = rec.s3 === studioFolder ? { label: "built with Studio", cls: "b-ok" }
|
||
: rec.s3 ? { label: "site on Sia (external)", cls: "b-x" }
|
||
: rec.h ? { label: "inline HTML", cls: "b-x" }
|
||
: rec.u ? { label: "redirect", cls: "b-dim" }
|
||
: rec.p || rec.ip ? { label: "own server", cls: "b-dim" }
|
||
: { label: "no site yet", cls: "b-warn" };
|
||
return `<div class="drow" style="cursor:default">
|
||
<div class="n">${nameHtml(e)} <span class="badge ${st.cls}">${esc(st.label)}</span></div>
|
||
<div class="acts">
|
||
${rec.s3 || rec.h ? `<a class="btn small ghost" href="${siteUrl(e.name)}" target="_blank" rel="noopener">View →</a>` : ""}
|
||
<a class="btn small acid" href="./studio.html?name=${encodeURIComponent(e.name)}">${rec.s3 === studioFolder ? "Edit in Studio" : "Open in Studio"} →</a>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
// ---------- domains list ----------
|
||
function nameHtml(e) { return `<span>${esc(e.label)}.<span class="tld">${esc(e.tld)}</span></span>`; }
|
||
function domainRow(e, { compact = false } = {}) {
|
||
const pt = pointsTo(e.records);
|
||
const d = dnsCache.get(e.name);
|
||
const dn = d?.status === "ok" ? dnsCount(d.dns) : 0;
|
||
const dnsBadge = `<span class="badge ${dn ? "b-ok" : "b-dim"}" data-dnsbadge="${esc(e.name)}">${dn ? `DNS · ${dn}` : (d?.status === "nopointer" ? "DNS needs s3" : (d ? "no DNS" : "DNS …"))}</span>`;
|
||
const acts = compact ? "" : `<div class="acts">
|
||
<a class="btn small ghost" href="${siteUrl(e.name)}" target="_blank" rel="noopener" data-stop>Open →</a>
|
||
<button class="btn small ghost" data-open="${esc(e.name)}" data-sub="dns">DNS</button>
|
||
<button class="btn small acid" data-open="${esc(e.name)}" data-sub="summary">Manage</button></div>`;
|
||
return `<div class="drow" data-open="${esc(e.name)}" data-sub="summary">
|
||
<div class="n">${nameHtml(e)} <span class="badge ${pt.cls}">${esc(pt.label)}</span> ${dnsBadge}</div>
|
||
${acts}
|
||
${compact ? "" : `<div class="meta"><span>block ${e.height ? fmtInt(e.height) : "mempool"}</span><span>·</span><span class="mono" style="background:none;border:0;padding:0">${esc((e.category || "").slice(0, 12))}…</span></div>`}
|
||
</div>`;
|
||
}
|
||
function renderDomains() {
|
||
const list = $("names-list"), status = $("names-status");
|
||
const q = ($("dom-search").value || "").trim().toLowerCase();
|
||
const sort = $("dom-sort").value;
|
||
const tldSel = $("dom-tld");
|
||
// TLD filter options (rebuild keeping selection)
|
||
const tlds = [...new Set(H.names.map((n) => n.tld))].sort();
|
||
const keep = tldSel.value;
|
||
tldSel.innerHTML = `<option value="">All TLDs</option>` + tlds.map((t) => `<option value="${esc(t)}"${t === keep ? " selected" : ""}>.${esc(t)}</option>`).join("");
|
||
let rows = H.names.filter((n) => (!q || n.name.includes(q)) && (!tldSel.value || n.tld === tldSel.value));
|
||
rows.sort((a, b) => sort === "tld" ? (a.tld.localeCompare(b.tld) || a.label.localeCompare(b.label))
|
||
: sort === "height" ? ((b.height || 1e12) - (a.height || 1e12))
|
||
: a.name.localeCompare(b.name));
|
||
$("dom-count").textContent = `${rows.length} of ${H.names.length}`;
|
||
if (!H.names.length) {
|
||
status.className = "status"; status.textContent = "No BCNR names found for this wallet. If you just registered, wait a minute for confirmation and refresh.";
|
||
list.innerHTML = ""; return;
|
||
}
|
||
status.className = "status ok"; status.textContent = `You own ${H.names.length} name${H.names.length === 1 ? "" : "s"}.`;
|
||
list.innerHTML = rows.length ? rows.map((e) => domainRow(e)).join("") : `<div class="empty">Nothing matches that filter.</div>`;
|
||
}
|
||
["dom-search", "dom-sort", "dom-tld"].forEach((id) => $(id).addEventListener("input", renderDomains));
|
||
document.addEventListener("click", (e) => {
|
||
if (e.target.closest("[data-stop]")) return;
|
||
const el = e.target.closest("[data-open]");
|
||
if (!el) return;
|
||
e.preventDefault();
|
||
pendingSub = el.dataset.sub || "summary";
|
||
go("domain", el.dataset.open);
|
||
});
|
||
let pendingSub = "summary";
|
||
|
||
// ---------- domain detail ----------
|
||
function setSub(name) {
|
||
document.querySelectorAll(".subtabs [data-sub]").forEach((b) => b.classList.toggle("active", b.dataset.sub === name));
|
||
document.querySelectorAll('.pane[data-pane="domain"] .sub[data-sub]').forEach((s) => { s.hidden = s.dataset.sub !== name; });
|
||
if (name === "export") renderExportPreview();
|
||
}
|
||
document.querySelectorAll(".subtabs [data-sub]").forEach((b) => b.addEventListener("click", () => setSub(b.dataset.sub)));
|
||
|
||
async function openDomain(name) {
|
||
const entry = H.names.find((n) => n.name === name);
|
||
if (!entry) {
|
||
if (!H.names.length && Date.now() - H.loadedAt > 1000) { $("dd-name").textContent = name; $("dd-crumb").textContent = name; return; }
|
||
$("dd-name").textContent = name; $("dd-crumb").textContent = name;
|
||
$("dd-kv").innerHTML = `<div class="k">Status</div><div class="v">This wallet does not hold ${esc(name)}.</div>`;
|
||
return;
|
||
}
|
||
current = { name, entry, manifestSeq: 0, rows: [], dirty: false };
|
||
$("dd-name").textContent = name; $("dd-crumb").textContent = name;
|
||
$("dd-view").href = siteUrl(name);
|
||
fillSummary(entry);
|
||
fillContentForm(entry.records || {});
|
||
$("rd-url").value = entry.records?.u || "";
|
||
$("tr-addr").value = ""; $("tr-ack").checked = false; $("tr-send").disabled = true; setMsg("tr-msg", "");
|
||
setMsg("dns-msg", ""); setMsg("ct-msg", ""); setMsg("rd-msg", "");
|
||
setSub(pendingSub || "summary"); pendingSub = "summary";
|
||
await loadDns(name, true);
|
||
}
|
||
function setMsg(id, text, cls = "") { const m = $(id); if (!m) return; m.textContent = text; m.className = "msg " + cls; }
|
||
function logger(id) {
|
||
const log = $(id); log.className = "steps-log on"; log.innerHTML = "";
|
||
return (t) => { const d = document.createElement("div"); d.textContent = String(t); log.appendChild(d); log.scrollTop = log.scrollHeight; };
|
||
}
|
||
|
||
function fillSummary(e) {
|
||
const kv = [
|
||
["Name", `<code>${esc(e.name)}</code>`],
|
||
["TLD", `.${esc(e.tld)}`],
|
||
["Owner", `<code>${esc(wallet.address)}</code>`],
|
||
["Certificate", `<code>${esc(e.category || "")}</code>`],
|
||
["Registered", e.height ? `block ${fmtInt(e.height)} · <a href="${txUrl(e.txid)}" target="_blank" rel="noopener">tx →</a>` : `mempool · <a href="${txUrl(e.txid)}" target="_blank" rel="noopener">tx →</a>`],
|
||
["Last update", e.updated_txid ? `<a href="${txUrl(e.updated_txid)}" target="_blank" rel="noopener">${esc(e.updated_txid.slice(0, 16))}… →</a>` : "none since registration"],
|
||
["Renewal", "never — names do not expire"],
|
||
["Public URLs", `<a href="${siteUrl(e.name)}" target="_blank" rel="noopener">silentmode.st gateway</a> · <a href="https://navigate.st/bns/${encodeURIComponent(e.name)}/" target="_blank" rel="noopener">navigate.st</a>`],
|
||
];
|
||
$("dd-kv").innerHTML = kv.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
|
||
const rec = e.records || {};
|
||
const pt = pointsTo(rec);
|
||
const rows = [["Serves", `<span class="badge ${pt.cls}">${esc(pt.label)}</span>`]];
|
||
const labels = { h: "Inline HTML", s3: "Sia bucket", p: "Reverse proxy", ip: "Server IPv4", tls: "TLS pin", u: "Redirect" };
|
||
for (const k of ["h", "s3", "p", "ip", "tls", "u"]) if (rec[k]) rows.push([labels[k], `<code>${esc(String(rec[k]).slice(0, 120))}</code>`]);
|
||
if (rows.length === 1) rows.push(["Records", `none — <a href="#" data-sub-go="content">point it somewhere →</a>`]);
|
||
$("dd-records").innerHTML = rows.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
|
||
$("dd-records").querySelectorAll("[data-sub-go]").forEach((a) => a.onclick = (ev) => { ev.preventDefault(); setSub(a.dataset.subGo); });
|
||
fillDnsBadge();
|
||
}
|
||
function fillDnsBadge() {
|
||
const c = dnsCache.get(current.name);
|
||
const n = c?.status === "ok" ? dnsCount(c.dns) : 0;
|
||
const b = $("dd-dnsbadge");
|
||
b.className = "badge " + (n ? "b-ok" : "b-dim");
|
||
b.textContent = n ? `DNS · ${n} record${n === 1 ? "" : "s"}` : (c?.status === "nopointer" ? "DNS needs s3" : "no DNS");
|
||
const sum = $("dd-dns-summary");
|
||
if (!c) { sum.textContent = "Loading…"; return; }
|
||
if (c.status === "nopointer") { sum.innerHTML = `This name has no <b>s3</b> record, so there is nowhere to store a DNS manifest yet. Set one under Content & hosting first.`; return; }
|
||
if (!n) { sum.innerHTML = `No DNS records. <a href="#" data-sub-go="dns">Add some →</a>`; sum.querySelector("[data-sub-go]").onclick = (ev) => { ev.preventDefault(); setSub("dns"); }; return; }
|
||
const d = c.dns;
|
||
const parts = [];
|
||
for (const k of ["A", "AAAA", "NS", "TXT"]) if (Array.isArray(d[k]) && d[k].length) parts.push(`<b>${k}</b> ${d[k].map((v) => esc(String(v).slice(0, 48))).join(", ")}`);
|
||
if (d.CNAME) parts.push(`<b>CNAME</b> ${esc(d.CNAME)}`);
|
||
if (Array.isArray(d.MX) && d.MX.length) parts.push(`<b>MX</b> ${d.MX.map((m) => `${m.pref} ${esc(m.host)}`).join(", ")}`);
|
||
if (Array.isArray(d.SRV) && d.SRV.length) parts.push(`<b>SRV</b> ${d.SRV.length}`);
|
||
if (Array.isArray(d.CAA) && d.CAA.length) parts.push(`<b>CAA</b> ${d.CAA.map((x) => `${x.tag} ${esc(x.value)}`).join(", ")}`);
|
||
sum.innerHTML = parts.join("<br>") + `<div class="dim" style="margin-top:6px">manifest seq ${c.seq}</div>`;
|
||
}
|
||
|
||
// ---------- DNS editor ----------
|
||
const DNS_HELP = {
|
||
A: ["IPv4 address", "1.2.3.4"], AAAA: ["IPv6 address", "2001:db8::1"], CNAME: ["Target host (one CNAME per name)", "target.example.com"],
|
||
MX: ["Mail host", "mail.example.com"], TXT: ["Text (SPF, DKIM, verification…)", "v=spf1 include:_spf.silentmode.st -all"],
|
||
NS: ["Nameserver host", "ns1.example.com"], SRV: ["Target host", "sip.example.com"], CAA: ["CA domain or report URL", "letsencrypt.org"],
|
||
};
|
||
function rowsFromManifest(d) {
|
||
const rows = [];
|
||
for (const t of ["A", "AAAA", "TXT", "NS"]) for (const v of (Array.isArray(d?.[t]) ? d[t] : [])) rows.push({ type: t, value: String(v) });
|
||
if (d?.CNAME) rows.push({ type: "CNAME", value: String(d.CNAME) });
|
||
for (const m of (Array.isArray(d?.MX) ? d.MX : [])) rows.push({ type: "MX", value: String(m.host), prio: Number(m.pref) || 0 });
|
||
for (const s of (Array.isArray(d?.SRV) ? d.SRV : [])) rows.push({ type: "SRV", value: String(s.target), prio: Number(s.priority) || 0, weight: Number(s.weight) || 0, port: Number(s.port) || 0 });
|
||
for (const c of (Array.isArray(d?.CAA) ? d.CAA : [])) rows.push({ type: "CAA", value: String(c.value), tag: c.tag || "issue", flags: Number(c.flags) || 0 });
|
||
return rows;
|
||
}
|
||
function manifestFromRows(rows) {
|
||
const d = { A: [], AAAA: [], MX: [], TXT: [], CNAME: null, NS: [] };
|
||
const srv = [], caa = [];
|
||
for (const r of rows) {
|
||
if (r.type === "MX") d.MX.push({ pref: r.prio || 0, host: r.value });
|
||
else if (r.type === "CNAME") d.CNAME = r.value;
|
||
else if (r.type === "SRV") srv.push({ priority: r.prio || 0, weight: r.weight || 0, port: r.port || 0, target: r.value });
|
||
else if (r.type === "CAA") caa.push({ flags: r.flags || 0, tag: r.tag || "issue", value: r.value });
|
||
else if (d[r.type]) d[r.type].push(r.value);
|
||
}
|
||
if (srv.length) d.SRV = srv;
|
||
if (caa.length) d.CAA = caa;
|
||
return d;
|
||
}
|
||
function extraOf(r) {
|
||
if (r.type === "MX") return `priority ${r.prio}`;
|
||
if (r.type === "SRV") return `prio ${r.prio} · weight ${r.weight} · port ${r.port}`;
|
||
if (r.type === "CAA") return `${r.tag}${r.flags ? ` · flags ${r.flags}` : ""}`;
|
||
return "";
|
||
}
|
||
function renderDnsTable() {
|
||
const tb = $("dns-table").querySelector("tbody");
|
||
tb.innerHTML = current.rows.map((r, i) => `<tr>
|
||
<td class="t">${esc(r.type)}</td><td class="v">${esc(r.value)}</td><td class="p">${esc(extraOf(r))}</td>
|
||
<td class="a"><button class="x" data-del="${i}" title="Remove">×</button></td></tr>`).join("");
|
||
$("dns-empty").classList.toggle("hidden", current.rows.length > 0);
|
||
$("dns-seq").textContent = `seq ${current.manifestSeq}${current.dirty ? " · unsaved" : ""}`;
|
||
tb.querySelectorAll("[data-del]").forEach((b) => b.onclick = () => { current.rows.splice(Number(b.dataset.del), 1); current.dirty = true; renderDnsTable(); });
|
||
}
|
||
async function loadDns(name, force) {
|
||
const c = await fetchDns(name, force);
|
||
if (current.name !== name) return;
|
||
current.manifestSeq = c.seq || 0;
|
||
current.rows = c.status === "ok" ? rowsFromManifest(c.dns) : [];
|
||
current.dirty = false;
|
||
renderDnsTable();
|
||
updateDnsBadge(name);
|
||
const pub = $("dns-publish");
|
||
if (c.status === "nopointer") {
|
||
setMsg("dns-msg", "This name has no s3 record on chain, so the manifest has nowhere to live. Set one under Content & hosting first (any Sia bucket key works).", "err");
|
||
pub.disabled = true;
|
||
} else { pub.disabled = false; }
|
||
}
|
||
function updateDnsForm() {
|
||
const t = $("dns-type").value;
|
||
document.querySelectorAll(".addrow .opt").forEach((o) => { o.hidden = !o.dataset.for.split(" ").includes(t); });
|
||
const [label, ph] = DNS_HELP[t] || ["Value", ""];
|
||
$("dns-value-label").textContent = label; $("dns-value").placeholder = ph;
|
||
$("dns-help").textContent = t === "CNAME" ? "A CNAME replaces any existing CNAME; classic DNS does not allow it next to other records on the same host." :
|
||
t === "TXT" ? "One string per record. Long DKIM keys are fine — there is no size cap in the manifest." :
|
||
t === "CAA" ? "Restricts which certificate authorities may issue for this name in classic DNS." : "";
|
||
}
|
||
$("dns-type").addEventListener("change", updateDnsForm); updateDnsForm();
|
||
$("dns-add").addEventListener("click", () => {
|
||
const type = $("dns-type").value;
|
||
const value = $("dns-value").value.trim();
|
||
if (!value) { $("dns-value").focus(); return; }
|
||
if (type === "A" && !/^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return setMsg("dns-msg", "That is not an IPv4 address.", "err");
|
||
if (type === "AAAA" && !/^[0-9a-f:]+$/i.test(value)) return setMsg("dns-msg", "That is not an IPv6 address.", "err");
|
||
const row = { type, value };
|
||
if (type === "MX" || type === "SRV") row.prio = Number($("dns-prio").value) || 0;
|
||
if (type === "SRV") { row.weight = Number($("dns-weight").value) || 0; row.port = Number($("dns-port").value) || 0; }
|
||
if (type === "CAA") { row.tag = $("dns-tag").value; row.flags = 0; }
|
||
if (type === "CNAME") current.rows = current.rows.filter((r) => r.type !== "CNAME");
|
||
current.rows.push(row); current.dirty = true;
|
||
$("dns-value").value = ""; $("dns-prio").value = ""; $("dns-weight").value = ""; $("dns-port").value = "";
|
||
setMsg("dns-msg", ""); renderDnsTable();
|
||
});
|
||
$("dns-value").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("dns-add").click(); } });
|
||
$("dns-reload").addEventListener("click", () => loadDns(current.name, true));
|
||
$("dns-publish").addEventListener("click", async () => {
|
||
if (!current.name || !wallet) return;
|
||
if (wallet.source === "wc") return setMsg("dns-msg", "External-wallet signing (WizardConnect) for records manifests lands in the next drop — please sign in with the built-in wallet.", "err");
|
||
const push = logger("dns-log"); const btn = $("dns-publish");
|
||
btn.disabled = true; btn.textContent = "Signing…"; setMsg("dns-msg", "");
|
||
try {
|
||
const manifest = { v: 1, name: current.name, seq: (current.manifestSeq || 0) + 1, updated_at: new Date().toISOString(), dns: manifestFromRows(current.rows) };
|
||
push(`composed manifest · seq ${manifest.seq} · ${current.rows.length} record${current.rows.length === 1 ? "" : "s"}`);
|
||
const signed = await BNS.signRecordsManifest(wallet, manifest);
|
||
push("signed with wallet key");
|
||
const r = await fetch(`${API}/api/records/${encodeURIComponent(current.name)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(signed) });
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(`API ${r.status}: ${j.error || "unknown error"}`);
|
||
push(`published · ${j.bytes || "?"} bytes at ${j.sia_key}`);
|
||
current.manifestSeq = manifest.seq; current.dirty = false;
|
||
dnsCache.set(current.name, { status: "ok", seq: manifest.seq, dns: manifest.dns, raw: manifest });
|
||
renderDnsTable(); updateDnsBadge(current.name); renderAttention();
|
||
setMsg("dns-msg", `Published manifest seq ${manifest.seq}. Resolvers pick it up on their next fetch (about 30 s at the gateway).`, "ok");
|
||
} catch (e) { setMsg("dns-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); }
|
||
finally { btn.disabled = false; btn.textContent = "Sign & publish →"; }
|
||
});
|
||
|
||
// ---------- content & hosting (on-chain records) ----------
|
||
const CT_KEYS = ["h", "s3", "p", "ip", "tls"];
|
||
function fillContentForm(rec) { for (const k of CT_KEYS) $("ct-" + k).value = rec[k] || ""; updateBudget(); }
|
||
function collectContent() {
|
||
const r = {};
|
||
for (const k of CT_KEYS) { const v = $("ct-" + k).value.trim(); if (v) r[k] = v; }
|
||
const u = ($("rd-url").value || "").trim(); if (u) r.u = u;
|
||
return r;
|
||
}
|
||
function updateBudget() {
|
||
if (!current.name) return;
|
||
try {
|
||
const max = BNS.payloadBudget(current.name, "UPD");
|
||
const used = new TextEncoder().encode(JSON.stringify(collectContent())).length;
|
||
$("ct-budget-max").textContent = max; $("ct-budget-used").textContent = used;
|
||
$("ct-budget").classList.toggle("over", used > max); $("ct-save").disabled = used > max;
|
||
} catch {}
|
||
}
|
||
CT_KEYS.forEach((k) => $("ct-" + k).addEventListener("input", updateBudget));
|
||
$("rd-url").addEventListener("input", updateBudget);
|
||
$("ct-reset").addEventListener("click", () => fillContentForm(current.entry?.records || {}));
|
||
|
||
async function publishRecords(records, logId, msgId, btnId, doneText) {
|
||
if (!current.name || !wallet) return false;
|
||
const push = logger(logId); const btn = $(btnId); const orig = btn.textContent;
|
||
btn.disabled = true; btn.textContent = "Signing…"; setMsg(msgId, "");
|
||
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: current.name, records, onProgress: (s) => push(s) })
|
||
: await BNS.setRecordsWithBuiltInWallet(el, { wallet, name: current.name, records, onProgress: (s) => push(s) });
|
||
push("broadcast: " + (res.txid || res.txId || "(no txid)"));
|
||
if (current.entry) { current.entry.records = records; current.entry.updated_txid = res.txid || res.txId || current.entry.updated_txid; }
|
||
fillSummary(current.entry); renderDomains(); renderAttention(); renderStudio();
|
||
setMsg(msgId, doneText, "ok");
|
||
return true;
|
||
} catch (e) { setMsg(msgId, "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); return false; }
|
||
finally { btn.disabled = false; btn.textContent = orig; try { el?.close?.(); } catch {} }
|
||
}
|
||
$("ct-save").addEventListener("click", () => publishRecords(collectContent(), "ct-log", "ct-msg", "ct-save", "Saved. Records refresh on the chain within a block."));
|
||
$("rd-save").addEventListener("click", () => {
|
||
const u = $("rd-url").value.trim();
|
||
if (!/^https?:\/\//i.test(u)) return setMsg("rd-msg", "Enter a full URL starting with http:// or https://", "err");
|
||
publishRecords(collectContent(), "rd-log", "rd-msg", "rd-save", "Redirect saved. Resolvers forward the name within a block.");
|
||
});
|
||
$("rd-clear").addEventListener("click", () => { $("rd-url").value = ""; publishRecords(collectContent(), "rd-log", "rd-msg", "rd-clear", "Redirect removed."); });
|
||
|
||
// ---------- transfer ----------
|
||
function validAddress(a) { try { BNS.addressToLockingBytecode(a); return true; } catch { return false; } }
|
||
function updateTransferBtn() {
|
||
const a = $("tr-addr").value.trim();
|
||
$("tr-send").disabled = !( $("tr-ack").checked && validAddress(a) && a !== wallet?.address );
|
||
}
|
||
$("tr-addr").addEventListener("input", updateTransferBtn);
|
||
$("tr-ack").addEventListener("change", updateTransferBtn);
|
||
$("tr-send").addEventListener("click", async () => {
|
||
if (!current.entry || !wallet) return;
|
||
const to = $("tr-addr").value.trim();
|
||
if (wallet.source === "wc") return setMsg("tr-msg", "Transfers from an external wallet land in the next drop — sign in with the built-in wallet.", "err");
|
||
if (!/^bchtest:/.test(to)) return setMsg("tr-msg", "Chipnet addresses start with bchtest: — a mainnet address would lose the name.", "err");
|
||
if (!confirm(`Transfer ${current.name} to ${to}? This cannot be undone.`)) return;
|
||
const push = logger("tr-log"); const btn = $("tr-send");
|
||
btn.disabled = true; btn.textContent = "Transferring…"; setMsg("tr-msg", "");
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect(); push("connected to chipnet");
|
||
const utxos = await BNS.getUtxosForAddresses(el, wallet.watchedAddresses);
|
||
const raw = BNS.normalizeName(current.name);
|
||
const commitment = [...new TextEncoder().encode(raw)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
const cert = utxos.find((u) => u.token?.category === current.entry.category && u.token?.nft?.commitment === commitment);
|
||
if (!cert) throw new Error("certificate UTXO not found in this wallet (already moved?)");
|
||
push("found certificate · building transfer");
|
||
const built = BNS.buildUpdateTx({
|
||
name: current.name, ownerAddress: wallet.address, ownerTokenAddress: BNS.toTokenAddress(to),
|
||
records: current.entry.records || {}, certificateUtxo: cert, utxos: utxos.filter((u) => !u.token),
|
||
addressToLockingBytecode: BNS.addressToLockingBytecode,
|
||
});
|
||
const signed = BNS.signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
|
||
push("signed · broadcasting");
|
||
const txid = await BNS.broadcast(el, signed.hex);
|
||
push("broadcast: " + txid);
|
||
setMsg("tr-msg", `Transferred. ${current.name} now belongs to ${to}. It disappears from this list after the next refresh.`, "ok");
|
||
btn.textContent = "Transferred ✓";
|
||
setTimeout(() => loadHoldings(), 4000);
|
||
} catch (e) { setMsg("tr-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); btn.disabled = false; btn.textContent = "Transfer name →"; }
|
||
finally { try { el?.close?.(); } catch {} }
|
||
});
|
||
|
||
// ---------- export ----------
|
||
function zoneText() {
|
||
const e = current.entry; if (!e) return "";
|
||
const c = dnsCache.get(e.name); const d = c?.status === "ok" ? c.dns : {};
|
||
const L = [`; ${e.name} — exported from Sirius.X ${new Date().toISOString()}`, `$ORIGIN ${e.name}.`, `$TTL 300`, ``];
|
||
const rec = e.records || {};
|
||
L.push(`; on-chain records (authoritative for BCNR resolvers)`);
|
||
for (const k of ["h", "s3", "p", "ip", "tls", "u"]) if (rec[k]) L.push(`; ${k} = ${String(rec[k]).replace(/\n/g, " ")}`);
|
||
if (rec.ip && !(Array.isArray(d.A) && d.A.length)) L.push(`@\tIN\tA\t${rec.ip}`);
|
||
L.push(``, `; signed DNS manifest${c?.seq ? ` (seq ${c.seq})` : ""}`);
|
||
for (const v of d.A || []) L.push(`@\tIN\tA\t${v}`);
|
||
for (const v of d.AAAA || []) L.push(`@\tIN\tAAAA\t${v}`);
|
||
if (d.CNAME) L.push(`@\tIN\tCNAME\t${d.CNAME}.`);
|
||
for (const m of d.MX || []) L.push(`@\tIN\tMX\t${m.pref}\t${m.host}.`);
|
||
for (const v of d.NS || []) L.push(`@\tIN\tNS\t${v}.`);
|
||
for (const v of d.TXT || []) L.push(`@\tIN\tTXT\t"${String(v).replace(/"/g, '\\"')}"`);
|
||
for (const s of d.SRV || []) L.push(`@\tIN\tSRV\t${s.priority} ${s.weight} ${s.port}\t${s.target}.`);
|
||
for (const x of d.CAA || []) L.push(`@\tIN\tCAA\t${x.flags || 0} ${x.tag} "${x.value}"`);
|
||
return L.join("\n") + "\n";
|
||
}
|
||
function renderExportPreview() { $("ex-preview").textContent = zoneText(); }
|
||
function download(filename, text, type = "text/plain") {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([text], { type })); a.download = filename; a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
}
|
||
$("ex-zone").addEventListener("click", () => download(`${current.name}.zone.txt`, zoneText()));
|
||
$("ex-json").addEventListener("click", () => {
|
||
const c = dnsCache.get(current.name);
|
||
download(`${current.name}.json`, JSON.stringify({ name: current.name, chain: current.entry, dns: c?.raw || null, exported_at: new Date().toISOString() }, null, 2), "application/json");
|
||
});
|
||
$("ex-copy").addEventListener("click", () => { const c = dnsCache.get(current.name); navigator.clipboard?.writeText(JSON.stringify(c?.raw || { dns: {} }, null, 2)); });
|
||
|
||
// ---------- TLDs ----------
|
||
function isTldHidden(records) { const h = records?.hidden; return h === 1 || h === true || h === "1"; }
|
||
function tldPriceOf(records) { const p = records?.price; return typeof p === "number" && Number.isFinite(p) && p >= 0 ? p : null; }
|
||
const fmtUsd = (n) => (window.siriusPricing?.formatUsd || ((x) => "$" + x))(n);
|
||
|
||
function renderTlds() {
|
||
const status = $("tlds-status"), list = $("tlds-list");
|
||
if (!H.tlds.length) {
|
||
status.className = "status";
|
||
status.innerHTML = `This wallet does not hold any TLD certificates yet. <a href="#newtld" data-go="newtld">Register one →</a> — every name registered under it earns you 90% of the service fee.`;
|
||
list.innerHTML = ""; return;
|
||
}
|
||
status.className = "status ok"; status.textContent = `You own ${H.tlds.length} TLD${H.tlds.length === 1 ? "" : "s"}.`;
|
||
list.innerHTML = H.tlds.map((t) => {
|
||
const rec = t.records || {}; const off = isTldHidden(rec); const price = tldPriceOf(rec);
|
||
const under = H.names.filter((n) => n.tld === t.tld).length;
|
||
return `<div class="drow" data-go="tld" data-arg="${esc(t.tld)}">
|
||
<div class="n">.<span class="tld">${esc(t.tld)}</span>
|
||
<span class="badge ${off ? "b-off" : "b-ok"}">${off ? "off · private" : "on · public"}</span>
|
||
<span class="badge b-x">${price != null ? `${esc(fmtUsd(price))} / name` : "tier pricing"}</span></div>
|
||
<div class="acts"><button class="btn small acid" data-go="tld" data-arg="${esc(t.tld)}">Manage</button></div>
|
||
<div class="meta"><span>${under} of your names under it</span><span>·</span><span>policy ${esc(rec.policy || "open")}</span></div>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
async function registry() {
|
||
if (Date.now() - registryCache.at < 60000) return registryCache.names;
|
||
try {
|
||
const r = await fetch(`${API}/api/registry`, { cache: "no-store" });
|
||
const j = await r.json(); registryCache = { at: Date.now(), names: j.names || [] };
|
||
} catch { registryCache.at = Date.now(); }
|
||
return registryCache.names;
|
||
}
|
||
async function openTld(label) {
|
||
const t = H.tlds.find((x) => x.tld === label);
|
||
currentTld = t || null;
|
||
$("td-name").textContent = "." + label; $("td-crumb").textContent = "." + label; $("td-suffix").textContent = "." + label;
|
||
setMsg("td-msg", "");
|
||
if (!t) { $("td-kv").innerHTML = `<div class="k">Status</div><div class="v">This wallet does not hold .${esc(label)}.</div>`; return; }
|
||
const rec = t.records || {}; const off = isTldHidden(rec); const price = tldPriceOf(rec);
|
||
const st = $("td-state"); st.className = "badge " + (off ? "b-off" : "b-ok"); st.textContent = off ? "off · private" : "on · public";
|
||
$("td-price").textContent = price != null ? `${fmtUsd(price)} / name` : "tier pricing";
|
||
$("td-toggle").textContent = off ? "Switch on" : "Switch off";
|
||
let pub = null;
|
||
try { const j = await (await fetch(TLD_API, { cache: "no-store" })).json(); pub = (j.tlds || []).find((x) => x.tld === label) || null; } catch {}
|
||
const kv = [
|
||
["Certificate", `<code>${esc(t.category)}</code>`],
|
||
["Owner", `<code>${esc(t.owner || wallet.address)}</code>`],
|
||
["Registered", t.reg_height ? `block ${fmtInt(t.reg_height)}` : "mempool"],
|
||
["Last policy update", t.updated_txid ? `<a href="${txUrl(t.updated_txid)}" target="_blank" rel="noopener">${esc(t.updated_txid.slice(0, 16))}… →</a>` : "none"],
|
||
["Names registered", pub ? String(pub.registered_count) : "—"],
|
||
["Price", price != null ? `${esc(fmtUsd(price))} per name (you receive 90%)` : "length tiers ($0.10 – $5)"],
|
||
["Listing", off ? "off — private: only you can register names" : "on — anyone can register names"],
|
||
["Policy", esc(rec.policy || "open") + (rec.min_len ? ` · min length ${rec.min_len}` : "") + (rec.reserved ? ` · reserved: ${esc(rec.reserved)}` : "")],
|
||
];
|
||
$("td-kv").innerHTML = kv.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
|
||
const names = (await registry()).filter((n) => n.tld === label);
|
||
$("td-names").innerHTML = names.length
|
||
? names.map((n) => `<div class="drow" style="cursor:default"><div class="n"><span>${esc(n.name)}</span>${H.names.some((m) => m.name === n.name) ? '<span class="badge b-x">yours</span>' : ""}</div>
|
||
<div class="acts"><a class="btn small ghost" href="${siteUrl(n.name)}" target="_blank" rel="noopener">Open →</a></div>
|
||
<div class="meta"><span>block ${n.height ? fmtInt(n.height) : "mempool"}</span></div></div>`).join("")
|
||
: `<div class="empty">No names registered under .${esc(label)} yet.</div>`;
|
||
}
|
||
$("td-register").addEventListener("click", () => {
|
||
if (!currentTld) return;
|
||
const input = $("td-label"); const label = String(input.value || "").toLowerCase().replace(/[^a-z0-9-]/g, "");
|
||
input.value = label; if (!label) return input.focus();
|
||
if (typeof window.siriusRegisterName === "function") window.siriusRegisterName(`${label}.${currentTld.tld}`);
|
||
});
|
||
$("td-label").addEventListener("keydown", (e) => { if (e.key === "Enter") $("td-register").click(); });
|
||
$("td-toggle").addEventListener("click", async () => {
|
||
if (!currentTld || !wallet) return;
|
||
if (wallet.source === "wc") return setMsg("td-msg", "External-wallet (WizardConnect) TUPD lands in the next drop — please sign in with the built-in wallet.", "err");
|
||
const rec = { ...(currentTld.records || {}) }; const turningOff = !isTldHidden(rec);
|
||
if (turningOff) rec.hidden = 1; else delete rec.hidden;
|
||
const btn = $("td-toggle"); btn.disabled = true; btn.textContent = "Signing…";
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect();
|
||
const res = await BNS.setTldRecordsWithBuiltInWallet(el, { wallet, tld: currentTld.tld, records: rec, category: currentTld.category, onProgress: () => {} });
|
||
currentTld.records = rec;
|
||
setMsg("td-msg", `${turningOff ? "Off" : "On"} — broadcast ${(res.txid || "").slice(0, 16)}…. The public registry picks it up within a minute.`, "ok");
|
||
openTld(currentTld.tld); renderTlds();
|
||
} catch (e) { setMsg("td-msg", "Failed: " + (e.message || e), "err"); }
|
||
finally { btn.disabled = false; try { el?.close?.(); } catch {} }
|
||
});
|
||
$("td-policy").addEventListener("click", () => { if (currentTld) openTldEditor(currentTld.tld, currentTld.category, currentTld.records || {}); });
|
||
|
||
// ---------- TLD policy editor (TUPD) ----------
|
||
const TLD_POLICY_FIELDS = [
|
||
{ key: "price", id: "tld-ed-price", parse: parseUsdOrNull },
|
||
{ key: "hidden", id: "tld-ed-hidden", parse: (v) => (String(v) === "1" ? 1 : null) },
|
||
{ key: "min_len", id: "tld-ed-minlen", parse: parseIntOrNull },
|
||
{ key: "fee_bps", id: "tld-ed-feebps", parse: parseIntOrNull },
|
||
{ key: "policy", id: "tld-ed-policy", parse: trimOrNull },
|
||
{ key: "reserved", id: "tld-ed-reserved", parse: trimOrNull },
|
||
{ key: "renewal_period", id: "tld-ed-renewal", parse: parseIntOrNull },
|
||
{ key: "renewal_fee", id: "tld-ed-renewalfee", parse: parseIntOrNull },
|
||
{ key: "site", id: "tld-ed-site", parse: trimOrNull },
|
||
];
|
||
function parseIntOrNull(v) { v = String(v).trim(); if (!v) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null; }
|
||
function parseUsdOrNull(v) { v = String(v).trim().replace(/^\$/, ""); if (!v) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) / 100 : null; }
|
||
function trimOrNull(v) { v = String(v).trim(); return v || null; }
|
||
let tldEditor = { tld: null, category: null };
|
||
function openTldEditor(tld, category, records) {
|
||
tldEditor = { tld, category };
|
||
$("tld-ed-name").textContent = "." + tld;
|
||
for (const f of TLD_POLICY_FIELDS) { const cur = records?.[f.key]; $(f.id).value = cur == null ? "" : String(cur); }
|
||
setMsg("tld-ed-msg", ""); $("tld-ed-log").className = "steps-log"; $("tld-ed-log").innerHTML = "";
|
||
$("tld-ed-save").disabled = false; $("tld-ed-save").textContent = "Save policy →";
|
||
updateTldBudget(); $("tld-editor").classList.add("open");
|
||
}
|
||
function closeTldEditor() { $("tld-editor").classList.remove("open"); tldEditor = { tld: null, category: null }; }
|
||
$("tld-ed-close").addEventListener("click", closeTldEditor);
|
||
$("tld-ed-cancel").addEventListener("click", closeTldEditor);
|
||
$("tld-editor").addEventListener("click", (e) => { if (e.target === $("tld-editor")) closeTldEditor(); });
|
||
function collectTldRecords() { const r = {}; for (const f of TLD_POLICY_FIELDS) { const v = f.parse($(f.id).value); if (v !== null && v !== undefined && v !== "") r[f.key] = v; } return r; }
|
||
function updateTldBudget() {
|
||
if (!tldEditor.tld) return;
|
||
try {
|
||
const max = BNS.payloadBudget(tldEditor.tld, "TUPD");
|
||
const used = new TextEncoder().encode(JSON.stringify(collectTldRecords())).length;
|
||
$("tld-ed-budget-max").textContent = max; $("tld-ed-budget-used").textContent = used;
|
||
$("tld-ed-budget").classList.toggle("over", used > max); $("tld-ed-save").disabled = used > max;
|
||
} catch {}
|
||
}
|
||
for (const f of TLD_POLICY_FIELDS) $(f.id).addEventListener("input", updateTldBudget);
|
||
$("tld-ed-save").addEventListener("click", async () => {
|
||
if (!tldEditor.tld || !tldEditor.category || !wallet) return;
|
||
if (wallet.source === "wc") return setMsg("tld-ed-msg", "External-wallet (WizardConnect) TUPD lands in the next drop — please sign in with the built-in wallet.", "err");
|
||
const records = collectTldRecords(); const push = logger("tld-ed-log");
|
||
$("tld-ed-save").disabled = true; $("tld-ed-save").textContent = "Signing…"; setMsg("tld-ed-msg", "");
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect(); push("connected to chipnet");
|
||
const res = await BNS.setTldRecordsWithBuiltInWallet(el, { wallet, tld: tldEditor.tld, records, category: tldEditor.category, onProgress: (s) => push(String(s)) });
|
||
push("broadcast: " + (res.txid || "(no txid)"));
|
||
const t = H.tlds.find((x) => x.tld === tldEditor.tld); if (t) t.records = records;
|
||
setMsg("tld-ed-msg", "Policy saved. It reflects in registrations under this TLD within a block.", "ok");
|
||
$("tld-ed-save").textContent = "Saved ✓";
|
||
renderTlds(); if (currentTld?.tld === tldEditor.tld) openTld(tldEditor.tld);
|
||
setTimeout(closeTldEditor, 1200);
|
||
} catch (e) { setMsg("tld-ed-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); $("tld-ed-save").disabled = false; $("tld-ed-save").textContent = "Save policy →"; }
|
||
finally { try { el?.close?.(); } catch {} }
|
||
});
|
||
|
||
// ---------- new TLD ----------
|
||
const CHIPNET_SATS_PER_USD = 250_000;
|
||
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 sats = usdToSats($("tld-usd").value.trim() || "0");
|
||
$("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)`;
|
||
}
|
||
const validateTldLabel = (l) => /^[a-z0-9-]{1,16}$/.test(l);
|
||
function updateTldButtons() { const ok = validateTldLabel($("tld-label").value.trim().toLowerCase()); $("tld-check").disabled = !ok || !wallet; $("tld-submit").disabled = !ok || !wallet; }
|
||
$("tld-label").addEventListener("input", () => { $("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 = "";
|
||
try {
|
||
const j = await (await fetch(TLD_API)).json();
|
||
const taken = (j.tlds || []).some((t) => (t.tld || t) === label);
|
||
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 = "";
|
||
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 args = { tld: label, records: {}, serviceFee, tldListUrl: "https://navigate.st/api/tlds", onProgress: (s) => push(s) };
|
||
const res = wallet.source === "wc"
|
||
? await BNS.registerTldWithExternalWallet(el, { session: wallet.session, ...args })
|
||
: await BNS.registerTldWithBuiltInWallet(el, { wallet, ...args });
|
||
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 ✓";
|
||
setTimeout(() => loadHoldings(), 2500);
|
||
} catch (e) {
|
||
msg.style.color = "var(--taken)"; const raw = String(e.message || e);
|
||
msg.innerHTML = /wallet is empty/i.test(raw)
|
||
? `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>.`
|
||
: "Failed: " + esc(raw);
|
||
push("error: " + raw); $("tld-submit").disabled = false; updateTldPrice();
|
||
} finally { try { el?.close?.(); } catch {} }
|
||
});
|
||
updateTldPrice();
|
||
|
||
// ---------- settings ----------
|
||
function paintSettings() {
|
||
const S = window.siriusSession, P = window.siriusPin;
|
||
$("set-stay").checked = !!S?.isEnabled?.();
|
||
$("set-pinpay").checked = !!S?.requirePinForPayments?.();
|
||
const hasPin = !!P?.hasPin?.();
|
||
$("set-pinstate").textContent = hasPin ? "A PIN is set on this device." : "No PIN set.";
|
||
$("set-clearpin").disabled = !hasPin;
|
||
$("set-setpin").disabled = !wallet?.mnemonic;
|
||
}
|
||
$("set-stay").addEventListener("change", async (e) => {
|
||
const S = window.siriusSession; if (!S) return;
|
||
S.setEnabled(e.target.checked);
|
||
if (e.target.checked && wallet?.mnemonic) await S.remember(wallet.mnemonic, { accountPath: wallet.accountPath });
|
||
setMsg("set-msg", e.target.checked ? "This device stays signed in." : "You will be asked to unlock on your next visit.", "ok");
|
||
});
|
||
$("set-pinpay").addEventListener("change", (e) => { window.siriusSession?.setRequirePinForPayments(e.target.checked); setMsg("set-msg", e.target.checked ? "Payments will ask for your PIN. Set one below if you have none." : "Payments approve with one click.", "ok"); });
|
||
$("set-setpin").addEventListener("click", () => { $("pin-1").value = ""; $("pin-2").value = ""; setMsg("pin-msg", ""); $("pin-modal").classList.add("open"); $("pin-1").focus(); });
|
||
const closePin = () => $("pin-modal").classList.remove("open");
|
||
$("pin-close").addEventListener("click", closePin); $("pin-cancel").addEventListener("click", closePin);
|
||
$("pin-modal").addEventListener("click", (e) => { if (e.target === $("pin-modal")) closePin(); });
|
||
$("pin-save").addEventListener("click", async () => {
|
||
const a = $("pin-1").value, b = $("pin-2").value;
|
||
if (!/^\d{4,6}$/.test(a)) return setMsg("pin-msg", "PIN must be 4–6 digits", "err");
|
||
if (a !== b) return setMsg("pin-msg", "PINs do not match", "err");
|
||
try { await window.siriusPin.savePinBlob(wallet.mnemonic, a); closePin(); paintSettings(); setMsg("set-msg", "PIN saved.", "ok"); }
|
||
catch (e) { setMsg("pin-msg", e.message || String(e), "err"); }
|
||
});
|
||
$("set-clearpin").addEventListener("click", () => { window.siriusPin?.clear?.(); paintSettings(); setMsg("set-msg", "PIN removed.", "ok"); });
|