sirius/js/dashboard.js
Local Dev 1fdfb9b393 feat(bns): co-sign rule in Ariadne and mobile; live zero-conf balance in the dashboard
Ariadne's resolver copy and the Android indexer still admitted every
REG under a cosign or hidden TLD, so they disagreed with the gateway
and Theseus about which names exist. Ariadne now carries the current
Argus resolver-web.js verbatim (the copy had drifted: no TLD beacon, no
owner tracking, old electrum list); its daemon only imports buildIndex
and normalizeName, both unchanged. Bns.java gains the same rule in
Java: walk the TLD beacon, keep each TLD's TUPD policy timeline, and
drop a REG whose TLD required a co-signature at that height unless the
transaction re-issues the TLD certificate. If the TLD beacon cannot be
read the mobile index degrades open rather than empty. Needs an APK
build (0.19) and an Ariadne zip rebuild to reach devices.

Dashboard balance: it was read once from the gateway at sign-in and
never again, so payments and spends never showed. The sidebar and
Settings figures now come straight from electrum every 15 seconds
while the tab is visible, after every broadcast the page makes, on tab
focus and on a new refresh button; unconfirmed coins count as spendable
(listunspent includes the mempool) with a small "incl. N sat
unconfirmed" line. The header wallet pill asks the dashboard for a
fresh figure when opened, and the register flow keeps its stored
figure current while it watches for funding.
2026-09-17 08:13:30 +02:00

1029 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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=20260917market";
const API = "https://silentmode.st";
const TLD_API = "https://navigate.st/api/tlds?include_hidden=1";
// Operator wallet (chipnet): shows the Admin panel link in the sidebar.
const OPERATOR_ADDRESS = "bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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);
// Operator tools are only useful to the operator wallet.
$("side-admin").classList.toggle("hidden", w.address !== OPERATOR_ADDRESS);
paintSettings();
updateTldPrice(); updateTldButtons();
routeFromHash();
refreshBalance(); startBalancePolling();
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(); refreshBalance(); });
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);
}));
// ---------- live balance ----------
// Straight from electrum, not the gateway: listunspent includes mempool
// coins, so a payment shows the moment it is relayed — zero-conf is money
// here, same as the register flow. Polled while the tab is visible and
// refreshed right after anything this page broadcasts.
const setText = (id, v) => { const el = $(id); if (el) el.textContent = v; };
let balEl = null, balTimer = null, balBusy = false;
async function refreshBalance() {
if (!wallet || balBusy) return;
balBusy = true;
const spinner = $("side-refresh"); spinner?.classList.add("spin");
try {
if (!balEl) balEl = await BNS.connect();
const b = await BNS.getBalance(balEl, wallet.watchedAddresses);
const pending = b.sats - b.confirmedSats;
setText("side-balance", fmtInt(b.sats));
setText("w-sats", `${fmtInt(b.sats)} sat${pending > 0n ? ` (${fmtInt(pending)} unconfirmed, spendable now)` : ""}`);
setText("w-utxos", String(b.coins));
const pend = $("side-pending"); if (pend) { pend.hidden = pending <= 0n; setText("side-pending-sats", fmtInt(pending)); }
H.wallet = { ...(H.wallet || {}), sats: b.sats.toString(), utxo_count: b.coins };
try { localStorage.setItem("siriusWalletInfo", JSON.stringify({ sats: b.sats.toString(), utxos: b.coins, names: H.names.length, tlds: H.tlds.length, at: Date.now() })); } catch {}
window.dispatchEvent(new CustomEvent("sirius:balance", { detail: { sats: b.sats.toString(), pending: pending.toString() } }));
} catch {
try { balEl?.close?.(); } catch {}
balEl = null;
} finally { balBusy = false; spinner?.classList.remove("spin"); }
}
function startBalancePolling() {
clearInterval(balTimer);
balTimer = setInterval(() => { if (document.visibilityState === "visible") refreshBalance(); }, 15000);
}
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible" && wallet) refreshBalance(); });
window.siriusRefreshBalance = refreshBalance;
$("side-refresh").addEventListener("click", () => refreshBalance());
$("names-refresh").addEventListener("click", () => { loadHoldings(); refreshBalance(); });
// ---------- 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 && $("side-balance").textContent === "—") {
const s = fmtInt(H.wallet.sats);
setText("side-balance", s); setText("w-sats", `${s} sat`); setText("w-utxos", String(H.wallet.utxo_count));
}
try { const prev = JSON.parse(localStorage.getItem("siriusWalletInfo") || "{}"); localStorage.setItem("siriusWalletInfo", JSON.stringify({ ...prev, sats: prev.sats ?? String(H.wallet?.sats ?? ""), names: H.names.length, tlds: H.tlds.length, at: Date.now() })); } catch {}
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";
$("sell-log").className = "steps-log"; $("sell-log").innerHTML = ""; setMsg("sell-msg", "");
loadListing(name);
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 &amp; 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(); refreshBalance(); }, 4000); refreshBalance();
} 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 {} }
});
// ---------- signed API calls (owner proves control of the name's key) ----------
async function signedHeaders(msg) {
const ts = String(Date.now());
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg.replace("{ts}", ts))));
const sig = wallet.signMessage(digest);
return { "x-bns-ts": ts, "x-bns-sig": btoa(String.fromCharCode(...sig)) };
}
// ---------- sell (marketplace listing) ----------
const RATE = () => window.siriusPricing?.CHIPNET_SATS_PER_USD || 250000;
let activeListing = null;
function paintSell() {
const st = $("sell-status");
$("sell-rate").textContent = `rate ${fmtInt(RATE())} sat/USD (chipnet)`;
if (activeListing) {
st.className = "status ok"; st.textContent = "This name is listed on the market.";
$("sell-form").classList.add("hidden"); $("sell-active").classList.remove("hidden");
const p = BigInt(activeListing.price_sats);
$("sell-kv").innerHTML = [["Price", `${fmtInt(p)} sat (≈ ${esc(fmtUsd(Math.round(Number(p) / RATE() * 100) / 100))})`], ["Listed", esc(new Date(activeListing.created_at).toLocaleString())], ["Offer", `<code>${esc(activeListing.partial_tx.slice(0, 40))}…</code>`], ["Market page", `<a href="./market.html" target="_blank" rel="noopener">open →</a>`]]
.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
} else {
st.className = "status"; st.textContent = "Not for sale. Set a price to list it.";
$("sell-form").classList.remove("hidden"); $("sell-active").classList.add("hidden");
}
}
async function loadListing(name) {
activeListing = null;
try { const r = await fetch(`${API}/api/market/${encodeURIComponent(name)}`, { cache: "no-store" }); if (r.ok) activeListing = await r.json(); } catch {}
if (current.name === name) paintSell();
}
$("sell-usd").addEventListener("input", () => { const v = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")); $("sell-sats").value = v > 0 ? String(Math.round(v * RATE())) : ""; });
$("sell-sats").addEventListener("input", () => { const s = Number(String($("sell-sats").value).replace(/\D/g, "")); $("sell-usd").value = s > 0 ? String(Math.round(s / RATE() * 100) / 100) : ""; });
$("sell-list").addEventListener("click", async () => {
if (!current.entry || !wallet) return;
if (wallet.source === "wc") return setMsg("sell-msg", "Listing from an external wallet lands in the next drop — sign in with the built-in wallet.", "err");
const sats = BigInt(Number(String($("sell-sats").value).replace(/\D/g, "")) || 0);
if (sats < 10000n) return setMsg("sell-msg", "Set a price of at least 10,000 sat.", "err");
const push = logger("sell-log"); const btn = $("sell-list"); btn.disabled = true; btn.textContent = "Signing…"; setMsg("sell-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");
push("found certificate · signing the offer (SINGLE|ANYONECANPAY)");
const listing = BNS.buildListing({ name: current.name, certificateUtxo: cert, priceSats: sats, sellerAddress: cert.address, addressToLockingBytecode: BNS.addressToLockingBytecode, keyFor: (h) => wallet.keyFor(h) });
const r = await fetch(`${API}/api/market`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(listing) });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `API ${r.status}`);
push("listed on the market");
activeListing = j.listing; paintSell();
setMsg("sell-msg", `Listed for ${fmtInt(sats)} sat. Nothing moves until a buyer pays that price.`, "ok");
} catch (e) { setMsg("sell-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); }
finally { btn.disabled = false; btn.textContent = "List for sale →"; try { el?.close?.(); } catch {} }
});
$("sell-cancel").addEventListener("click", async () => {
if (!current.entry || !wallet || !activeListing) return;
if (!confirm(`Cancel the listing for ${current.name}? This also moves the certificate once so the signed offer becomes void.`)) return;
const push = logger("sell-log"); const btn = $("sell-cancel"); btn.disabled = true; btn.textContent = "Cancelling…"; setMsg("sell-msg", "");
try {
const h = await signedHeaders(`BNS-MARKET1\n${current.name}\n{ts}`);
const r = await fetch(`${API}/api/market/${encodeURIComponent(current.name)}`, { method: "DELETE", headers: h });
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(j.error || `API ${r.status}`);
push("removed from the market");
activeListing = null; paintSell();
push("invalidating the signed offer on chain (no-op update)…");
const ok = await publishRecords(current.entry.records || {}, "sell-log", "sell-msg", "sell-cancel", "Listing cancelled and the offer voided on chain.");
if (!ok) setMsg("sell-msg", "Removed from the market, but the on-chain invalidation failed — retry from Content & hosting (Save records) so the old offer cannot be used.", "err");
} catch (e) { setMsg("sell-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); }
finally { btn.disabled = false; btn.textContent = "Cancel listing →"; }
});
// ---------- 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("");
loadCosign(label);
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>`;
}
// ---------- co-sign queue (registrations under this TLD awaiting the owner) ----------
async function loadCosign(label) {
const box = $("td-cosign");
box.innerHTML = `<div class="empty">Checking…</div>`;
let list = [];
try { const j = await (await fetch(`${API}/api/cosign?tld=${encodeURIComponent(label)}`, { cache: "no-store" })).json(); list = j.requests || []; } catch {}
if (currentTld?.tld !== label) return;
box.innerHTML = list.length
? list.map((r) => `<div class="drow" style="cursor:default"><div class="n"><span>${esc(r.name)}</span><span class="badge b-warn">awaiting you</span></div>
<div class="acts"><button class="btn small acid" data-approve="${esc(r.id)}">Approve &amp; broadcast</button><button class="btn small danger" data-decline="${esc(r.id)}">Decline</button></div>
<div class="meta"><span>buyer ${esc(r.buyer.replace(/^[^:]+:/, "").slice(0, 10))}…</span><span>·</span><span>${esc(new Date(r.created_at).toLocaleString())}</span>${r.note ? `<span>·</span><span>“${esc(r.note)}”</span>` : ""}</div></div>`).join("")
: `<div class="empty">No requests waiting.</div>`;
}
$("td-cosign").addEventListener("click", async (e) => {
const a = e.target.closest("[data-approve]"), d = e.target.closest("[data-decline]");
if (!a && !d) return;
if (!currentTld || !wallet) return;
if (wallet.source === "wc") return setMsg("td-cosign-msg", "Co-signing from an external wallet lands in the next drop — sign in with the built-in wallet.", "err");
const id = (a || d).dataset.approve || (a || d).dataset.decline;
const btn = a || d; btn.disabled = true; setMsg("td-cosign-msg", "");
let el = null;
try {
const h = await signedHeaders(`BNS-COSIGN1
${id}
{ts}`);
if (a) {
const r = await fetch(`${API}/api/cosign/${encodeURIComponent(id)}`, { cache: "no-store" });
if (!r.ok) throw new Error("request is gone (withdrawn or expired)");
const req = await r.json();
const signed = BNS.signCosignRequest(req, (lock) => wallet.keyFor(lock));
if (!signed.signedInputs) throw new Error("this wallet does not hold the certificate input of that transaction");
el = await BNS.connect();
const txid = await BNS.broadcast(el, signed.hex);
setTimeout(refreshBalance, 1500);
setMsg("td-cosign-msg", `Approved — ${req.name} registered in ${txid.slice(0, 16)}…. Your TLD certificate came back to you in the same transaction.`, "ok");
}
const del = await fetch(`${API}/api/cosign/${encodeURIComponent(id)}`, { method: "DELETE", headers: h });
if (!del.ok && d) throw new Error(`API ${del.status}`);
if (d) setMsg("td-cosign-msg", "Declined — the request was removed. The buyer's coins never left their wallet.", "ok");
await loadCosign(currentTld.tld);
if (a) { registryCache = { at: 0, names: [] }; openTld(currentTld.tld); }
} catch (err) { setMsg("td-cosign-msg", "Failed: " + (err.message || err), "err"); btn.disabled = false; }
finally { try { el?.close?.(); } catch {} }
});
$("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; setTimeout(refreshBalance, 1500);
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(); refreshBalance(); }, 2500); refreshBalance();
} 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 46 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"); });