sirius/js/dashboard.js
Local Dev a2bf7c26d8 sirius-x: price mode is a USD / BCH toggle, not a dropdown
Two choices do not need a select: a segmented control shows both options at
once, the active one is obvious, and the explanation moves to a short hint
beside it. Arrow keys switch it like a radio group.
2026-09-20 20:02:43 +02:00

1134 lines
71 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=20260920usd";
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; };
const fmtBits = (sats) => (window.siriusPricing?.formatBits || ((s) => (Number(s) / 100).toLocaleString("en-US") + " bits"))(sats);
const fmtBch = (sats) => (window.siriusPricing?.formatBch || ((s) => (Number(s) / 1e8).toFixed(8).replace(/0+$/, "").replace(/\.$/, "") + " BCH"))(sats);
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", fmtBits(b.sats));
setText("w-sats", `${fmtBits(b.sats)} · ${fmtBch(b.sats)}${pending > 0n ? ` (${fmtBits(pending)} unconfirmed, spendable now)` : ""}`);
setText("w-utxos", String(b.coins));
const pend = $("side-pending"); if (pend) { pend.hidden = pending <= 0n; setText("side-pending-sats", fmtBits(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 === "—") {
setText("side-balance", fmtBits(H.wallet.sats)); setText("w-sats", `${fmtBits(H.wallet.sats)} · ${fmtBch(H.wallet.sats)}`); 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" };
}
// The public relay is navigate.st; silentmode.st is the operator host.
const siteUrl = (name) => `https://navigate.st/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 URL", `<a href="${siteUrl(e.name)}" target="_blank" rel="noopener">Navigate.st gateway</a> <span class="dim">· any BCNR resolver opens https://${esc(e.name)}/ directly</span>`],
];
$("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"];
// s3 is offered as a choice: "Sirius.X hosting" is the name's own folder on
// Sia (bns/<name>/), free, filled by Studio or the upload API. The raw key
// stays reachable under "advanced" for anyone hosting from another folder.
const hostingFolder = () => `bns/${current.name}/`;
const S3_HINTS = {
off: "Nothing is served from Sia. Use inline HTML, a proxy, your own server or a redirect — or switch on Sirius.X hosting.",
sirius: "Free web hosting by Sirius.X: the files are served from your own folder on Sia storage. Build the page in Studio (Publish to Sia) or upload files yourself; only your key can write there. DNS records live in the same folder.",
custom: "Any folder key on the Sirius.X Sia bucket, with a trailing slash. Only your own folder accepts your uploads; other folders must be filled another way.",
};
function paintS3Mode() {
const mode = $("ct-s3-mode").value;
$("ct-s3").hidden = mode !== "custom";
$("ct-s3-hint").textContent = S3_HINTS[mode] + (mode === "sirius" ? ` Folder: ${hostingFolder()}` : "");
if (mode === "sirius") $("ct-s3").value = hostingFolder();
if (mode === "off") $("ct-s3").value = "";
}
function fillContentForm(rec) {
for (const k of CT_KEYS) $("ct-" + k).value = rec[k] || "";
const s3 = String(rec.s3 || "").trim();
$("ct-s3-mode").value = !s3 ? "off" : s3 === hostingFolder() ? "sirius" : "custom";
paintS3Mode(); updateBudget();
}
$("ct-s3-mode").addEventListener("change", () => { paintS3Mode(); updateBudget(); });
function collectContent() {
const r = {};
for (const k of CT_KEYS) { const v = $("ct-" + k).value.trim(); if (v) r[k] = v; }
const mode = $("ct-s3-mode").value;
if (mode === "sirius") r.s3 = hostingFolder(); else if (mode === "off") delete r.s3;
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 sellMode() { return $("sell-mode").dataset.value || "usd"; }
function setSellMode(mode) {
$("sell-mode").dataset.value = mode;
for (const b of $("sell-mode").querySelectorAll("button")) {
const on = b.dataset.mode === mode;
b.classList.toggle("active", on); b.setAttribute("aria-checked", on ? "true" : "false");
}
$("sell-mode-hint").textContent = mode === "usd" ? "The BCH amount follows the market" : "The dollar value follows the market";
paintSellMode();
}
function paintSellMode() {
const usd = sellMode() === "usd";
$("sell-usd-terms").classList.toggle("hidden", !usd);
paintBand();
}
function paintBand() {
const usd = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")) || 0;
const band = Math.min(90, Math.max(5, Number(String($("sell-band").value).replace(/\D/g, "")) || 25));
const sats = Math.round(usd * RATE());
$("sell-band-hint").textContent = usd > 0
? `% of today's rate: you will receive between ${fmtBch(Math.round(sats * (1 - band / 100)))} and ${fmtBch(Math.round(sats * (1 + band / 100)))}`
: "% of today's BCH rate";
}
$("sell-mode").addEventListener("click", (e) => { const b = e.target.closest("button[data-mode]"); if (b) setSellMode(b.dataset.mode); });
$("sell-mode").addEventListener("keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault(); setSellMode(sellMode() === "usd" ? "bch" : "usd"); $("sell-mode").querySelector("button.active").focus();
});
$("sell-band").addEventListener("input", paintBand);
function paintSell() {
const st = $("sell-status");
paintSellMode();
$("sell-rate").textContent = `1 BCH ≈ ${fmtUsd(Math.round(1e8 / RATE() * 100) / 100)}${window.siriusPricing?.priceInfo ? " · " + window.siriusPricing.priceInfo() : ""}`;
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 || 0);
const priceRow = activeListing.kind === "usd"
? ["Price", `${esc(fmtUsd(activeListing.target_cents / 100))} fixed · today ≈ ${esc(fmtBch(Math.round(activeListing.target_cents / 100 * RATE())))} · band ${esc(fmtBch(activeListing.floor_sats))} ${esc(fmtBch(activeListing.ceil_sats))}`]
: ["Price", `${esc(fmtBch(p))} (≈ ${esc(fmtUsd(Math.round(Number(p) / RATE() * 100) / 100))})`];
$("sell-kv").innerHTML = [priceRow, ["Listed", esc(new Date(activeListing.created_at).toLocaleString())], ["Offer", `<code>${esc(activeListing.partial_tx.slice(0, 40))}…</code>`], ["Market page", `<a href="./market/" 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();
}
const bchStr = (sats) => (Number(sats) / 1e8).toFixed(8).replace(/0+$/, "").replace(/\.$/, "");
$("sell-usd").addEventListener("input", () => { const v = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")); $("sell-bch").value = v > 0 ? bchStr(Math.round(v * RATE())) : ""; paintBand(); });
$("sell-bch").addEventListener("input", () => { const b = Number(String($("sell-bch").value).replace(/[^\d.]/g, "")); $("sell-usd").value = b > 0 ? String(Math.round(b * 1e8 / 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(Math.round((Number(String($("sell-bch").value).replace(/[^\d.]/g, "")) || 0) * 1e8));
if (sats < 10000n) return setMsg("sell-msg", "Set a price of at least 0.0001 BCH.", "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");
if (sellMode() === "usd") {
// Dollar price: certificate goes into the UsdListing covenant.
const usd = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")) || 0;
if (usd < 0.5) throw new Error("set a dollar price of at least $0.50");
const band = Math.min(90, Math.max(5, Number(String($("sell-band").value).replace(/\D/g, "")) || 25));
const targetCents = Math.round(usd * 100);
const nowSats = Math.round(usd * RATE());
const floorSats = Math.max(10000, Math.round(nowSats * (1 - band / 100))), ceilSats = Math.round(nowSats * (1 + band / 100));
const orc = await (await fetch(`${API}/api/price/oracle`, { cache: "no-store" })).json();
if (!orc.pubkey) throw new Error("the price oracle is not available right now");
push(`moving the certificate into the contract · $${usd} · band ${fmtBch(floorSats)} ${fmtBch(ceilSats)}`);
const built = BNS.buildUsdListingTx({ name: current.name, certificateUtxo: cert, utxos, sellerAddress: cert.address, oraclePk: orc.pubkey, targetCents, floorSats, ceilSats, records: current.entry.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode });
const signed = BNS.signInputs(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
const txid = await BNS.broadcast(el, signed.hex);
push("broadcast " + txid.slice(0, 16) + "…");
const listing = { v: 2, kind: "usd", name: raw, seller: cert.address, seller_pkh: built.sellerPkh, category: current.entry.category, target_cents: targetCents, floor_sats: String(floorSats), ceil_sats: String(ceilSats), max_age_secs: BNS.DEFAULT_MAX_AGE_SECS, oracle_pk: orc.pubkey, redeem_hex: built.redeemHex, covenant_address: built.address, outpoint: { txid, vout: 0, satoshis: "1000" }, token: { category: cert.token.category, amount: String(cert.token.amount ?? 0), nft: { capability: cert.token.nft.capability, commitment: cert.token.nft.commitment } } };
let posted = null;
for (let i = 0; i < 6 && !posted; i++) {
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) posted = j.listing; else if (r.status === 409) { push("waiting for the contract output to relay…"); await new Promise((res) => setTimeout(res, 4000)); } else throw new Error(j.error || `API ${r.status}`);
}
if (!posted) throw new Error("the market did not see the contract yet — press Refresh in a minute; the certificate is safe in the contract");
activeListing = posted; paintSell(); refreshBalance();
setMsg("sell-msg", `Listed at ${fmtUsd(usd)}. Buyers pay the dollar amount at the oracle rate; you receive between ${fmtBch(floorSats)} and ${fmtBch(ceilSats)}.`, "ok");
return;
}
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 ${fmtBch(sats)}. 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", "");
if (activeListing.kind === "usd") {
let el = null;
try {
el = await BNS.connect(); push("connected to chipnet");
const utxos = await BNS.getUtxosForAddresses(el, wallet.watchedAddresses);
const built = BNS.buildUsdCancelTx({ listing: activeListing, sellerAddress: wallet.address, sellerTokenAddress: wallet.tokenAddress, utxos, records: current.entry.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode, keyFor: (h) => wallet.keyFor(h) });
const txid = await BNS.broadcast(el, built.hex);
push("certificate reclaimed · broadcast " + txid.slice(0, 16) + "…");
const h = await signedHeaders(`BNS-MARKET1\n${current.name}\n{ts}`);
await fetch(`${API}/api/market/${encodeURIComponent(current.name)}`, { method: "DELETE", headers: h }).catch(() => {});
activeListing = null; paintSell(); setTimeout(() => { loadHoldings(); refreshBalance(); }, 3000);
setMsg("sell-msg", "Listing cancelled. The certificate is back in your wallet; every quote issued for that listing is void.", "ok");
} catch (e) { setMsg("sell-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); }
finally { btn.disabled = false; btn.textContent = "Cancel listing →"; try { el?.close?.(); } catch {} }
return;
}
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 ----------
function usdToSats(usd) { const n = Number(String(usd).replace(/[^\d.]/g, "")) || 0; return Math.max(0, Math.round(n * RATE())); }
function updateTldPrice() {
const sats = usdToSats($("tld-usd").value.trim() || "0");
$("tld-price").innerHTML = `= <b>${esc(fmtBits(sats))}</b> · 1 BCH ≈ <b>${esc(fmtUsd(Math.round(1e8 / RATE() * 100) / 100))}</b>${window.siriusPricing?.priceInfo ? " · " + esc(window.siriusPricing.priceInfo()) : ""}`;
$("tld-submit").textContent = `Register (~${fmtBits(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"); });
// Live BCH/USD rate (pricing.js) — repaint everything priced in USD.
window.addEventListener("sirius:price", () => { try { updateTldPrice(); } catch {} try { if (current.name) paintSell(); } catch {} });