After a successful USD listing the panel tried to show the BCH-style offer hex, which dollar listings do not have, so the seller saw an error and a bare Cancel button. The panel now shows the contract address instead. The gateway pruned listings from its store whenever one electrum lookup did not show the covenant output. A listing a few seconds old can be invisible to one server while another already relayed it; deleting it then loses a real on-chain listing. Absence is now trusted only after 20 minutes; before that the listing is hidden from buyers but kept.
1137 lines
71 KiB
JavaScript
1137 lines
71 KiB
JavaScript
// Sirius.X dashboard — the signed-in control panel for names and TLDs.
|
||
//
|
||
// One page, a left menu, several panes: Overview, Domain names (with a
|
||
// per-name detail: DNS records, content & hosting, redirect, transfer,
|
||
// export), My TLD list (with a per-TLD detail), Wallet, Settings.
|
||
//
|
||
// Trust model is unchanged from the old portal: the wallet lives in this
|
||
// browser; every on-chain change is a transaction signed here; DNS records
|
||
// are a manifest signed here and stored on Sia via the gateway. What is new
|
||
// is the persistent device session (js/session.js): sign in once, come back
|
||
// without a prompt until you sign out.
|
||
|
||
import * as BNS from "https://silentmode.st/js/bns-register.js?v=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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 & 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))})`];
|
||
const proofRow = activeListing.kind === "usd"
|
||
? ["Contract", `<code>${esc(activeListing.covenant_address || "")}</code>`]
|
||
: ["Offer", `<code>${esc(String(activeListing.partial_tx || "").slice(0, 40))}…</code>`];
|
||
$("sell-kv").innerHTML = [priceRow, ["Listed", esc(new Date(activeListing.created_at).toLocaleString())], proofRow, ["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 & 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 4–6 digits", "err");
|
||
if (a !== b) return setMsg("pin-msg", "PINs do not match", "err");
|
||
try { await window.siriusPin.savePinBlob(wallet.mnemonic, a); closePin(); paintSettings(); setMsg("set-msg", "PIN saved.", "ok"); }
|
||
catch (e) { setMsg("pin-msg", e.message || String(e), "err"); }
|
||
});
|
||
$("set-clearpin").addEventListener("click", () => { window.siriusPin?.clear?.(); paintSettings(); setMsg("set-msg", "PIN removed.", "ok"); });
|
||
|
||
// Live BCH/USD rate (pricing.js) — repaint everything priced in USD.
|
||
window.addEventListener("sirius:price", () => { try { updateTldPrice(); } catch {} try { if (current.name) paintSell(); } catch {} });
|