The portal was a flat page with tabs that asked for the password or PIN on every visit and offered little beyond a records form. Owners need a control panel they can live in. Sign in once: the recovery phrase is kept encrypted under a non-extractable browser key (IndexedDB) so the next visit opens the dashboard silently; sign-out or the Settings toggle destroys it. Payments approve with one click unless "Ask for PIN before payments" is on. Dashboard: left menu (Overview, Domain names, My TLD list, Wallet, Register name/TLD, Settings). Per-name detail with a summary, a DNS record table (A, AAAA, CNAME, MX, TXT, NS, SRV, CAA) that signs the manifest, content & hosting (h, s3, p, ip, tls), a redirect tab (u), ownership transfer (UPD that re-issues the certificate to the recipient) and zone-file/JSON export. Per-TLD detail with policy, on/off, owner registration and the public list of names under it. Overview flags names that point nowhere or lack DNS.
844 lines
50 KiB
JavaScript
844 lines
50 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=20260916dns";
|
||
|
||
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 = ["overview", "domains", "domain", "tlds", "tld", "newtld", "wallet", "settings"];
|
||
function go(pane, arg) {
|
||
if (!PANES.includes(pane)) pane = "overview";
|
||
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();
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
function routeFromHash() {
|
||
const m = /^#([a-z]+)(?:\/(.+))?$/.exec(location.hash || "");
|
||
if (!m) return go("overview");
|
||
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
|
||
$("stat-names").textContent = H.names.length; $("cnt-names").textContent = H.names.length;
|
||
$("stat-tlds").textContent = H.tlds.length; $("cnt-tlds").textContent = H.tlds.length;
|
||
if (H.wallet?.sats != null) {
|
||
const s = fmtInt(H.wallet.sats);
|
||
$("stat-balance").textContent = s; $("side-balance").textContent = s; $("w-sats").textContent = `${s} sat`;
|
||
$("stat-balance-sub").textContent = `sat · ${H.wallet.utxo_count} UTXO${H.wallet.utxo_count === 1 ? "" : "s"}`;
|
||
$("w-utxos").textContent = String(H.wallet.utxo_count);
|
||
}
|
||
if (H.chain?.height != null) $("stat-height").textContent = fmtInt(H.chain.height);
|
||
renderOverview(); renderDomains(); renderTlds();
|
||
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}`;
|
||
|
||
// ---------- overview ----------
|
||
function renderOverview() {
|
||
const recent = [...H.names].sort((a, b) => (b.height || 1e12) - (a.height || 1e12)).slice(0, 6);
|
||
$("ov-recent").innerHTML = recent.length
|
||
? recent.map((e) => domainRow(e, { compact: true })).join("")
|
||
: `<div class="empty">No names yet. <a href="./#search-input">Register your first one →</a></div>`;
|
||
renderAttention();
|
||
}
|
||
function renderAttention() {
|
||
const items = [];
|
||
for (const e of H.names) {
|
||
const pt = pointsTo(e.records);
|
||
const d = dnsCache.get(e.name);
|
||
if (pt.kind === "none") items.push({ e, why: "points nowhere yet", go: "content" });
|
||
else if (d && d.status !== "ok") items.push({ e, why: d.status === "nopointer" ? "DNS needs an s3 pointer" : "no DNS records", go: "dns" });
|
||
}
|
||
$("ov-attention").innerHTML = items.length
|
||
? items.slice(0, 8).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("")
|
||
: `<div class="empty">Everything points somewhere and has DNS. Nice.</div>`;
|
||
}
|
||
|
||
// ---------- 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(); renderOverview();
|
||
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"); });
|