Two gaps the owner panel left open. First, a hidden TLD was only a UI gate: anyone could still broadcast a REG under it and every indexer admitted it. Second, there was no way to sell a name without trusting the other side. Co-sign rule (consensus, applied in lockstep by bns.js and resolver-web.js): a REG under a TLD whose records at that height say policy "cosign" or hidden 1 is indexed only if the transaction carries the TLD's own certificate. The certificate can only be spent by the owner's key and is re-issued to them in the same transaction, so it is a co-signature nobody can forge and nothing is consumed. The TLD map now keeps the TUPD timeline so policy is evaluated at the REG height. Owners register under their private TLDs with the certificate added from their own wallet; third parties under a "cosign" TLD build the full transaction, sign their inputs and queue it at /api/cosign, where the owner approves it from the dashboard (signCosignRequest refuses to sign unless the certificate returns to the same locking script). Marketplace: a listing is the seller's certificate input plus a price output signed SIGHASH_SINGLE|ANYONECANPAY, stored by the gateway as a bulletin board (/api/market, verified against the on-chain owner and pruned when the certificate moves). The buyer completes it in one transaction, so the seller is paid exactly when the name moves. Cancelling also spends the certificate once so the offer is void. Site: market.html, Sell sub-tab and Pending approvals in the portal, Market link in nav and footer, six dictionaries extended, cache tags bumped. Verified on chipnet: cosigned.sc registered by a throwaway wallet through the queue with the .sc certificate back at the owner; aloevera.test listed and delisted through the API. Ariadne's resolver-web.js copy and the mobile Bns.java port still need the co-sign rule; until then they admit REGs this index rejects.
992 lines
60 KiB
JavaScript
992 lines
60 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=20260917market";
|
||
|
||
const API = "https://silentmode.st";
|
||
const TLD_API = "https://navigate.st/api/tlds?include_hidden=1";
|
||
// Operator wallet (chipnet): shows the Admin panel link in the sidebar.
|
||
const OPERATOR_ADDRESS = "bchtest:qzew3dqcsj0guwe5y3uxf9q2xs5ajgy9lca4z093cu";
|
||
const $ = (id) => document.getElementById(id);
|
||
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||
await loadHoldings();
|
||
}
|
||
|
||
async function signOut({ forget = false } = {}) {
|
||
if (wallet?.source === "wc" && wallet.session) { try { await wallet.session.disconnect(); } catch {} }
|
||
try { await window.siriusSession?.forget?.(); } catch {}
|
||
if (forget) {
|
||
try { BNS.BuiltInWallet.forget(); } catch {}
|
||
try { window.siriusPin?.clear?.(); } catch {}
|
||
}
|
||
clearProfile();
|
||
showSignedOut();
|
||
history.replaceState(null, "", location.pathname);
|
||
await paintInlineSignIn(forget ? "import" : "unlock");
|
||
}
|
||
|
||
window.addEventListener("siriusProfileChanged", async () => {
|
||
if (readProfile()) { if (window.siriusWallet && !wallet) await enter(window.siriusWallet); }
|
||
else if (wallet) { try { await window.siriusSession?.forget?.(); } catch {} showSignedOut(); await paintInlineSignIn(); }
|
||
});
|
||
window.addEventListener("storage", async (e) => {
|
||
if (e.key !== "siriusProfile") return;
|
||
if (!readProfile() && wallet) { showSignedOut(); await paintInlineSignIn(); }
|
||
});
|
||
|
||
(async function bootstrap() {
|
||
const mode = new URLSearchParams(location.search).get("mode");
|
||
if (window.siriusWallet) return enter(window.siriusWallet);
|
||
const w = await restoreSession();
|
||
if (w) { writeProfile(w); return enter(w); }
|
||
await paintInlineSignIn(mode || undefined);
|
||
})();
|
||
|
||
// ---------- navigation ----------
|
||
const PANES = ["studio", "domains", "domain", "tlds", "tld", "newtld", "settings"];
|
||
function go(pane, arg) {
|
||
if (!PANES.includes(pane)) pane = "domains";
|
||
document.querySelectorAll(".pane[data-pane]").forEach((p) => { p.hidden = p.dataset.pane !== pane; });
|
||
const activeItem = pane === "domain" ? "domains" : pane === "tld" ? "tlds" : pane;
|
||
document.querySelectorAll(".side .item[data-go]").forEach((b) => b.classList.toggle("active", b.dataset.go === activeItem));
|
||
const hash = arg ? `#${pane}/${encodeURIComponent(arg)}` : `#${pane}`;
|
||
if (location.hash !== hash) history.replaceState(null, "", hash);
|
||
if (pane === "domain" && arg) openDomain(arg);
|
||
if (pane === "tld" && arg) openTld(arg);
|
||
if (pane === "tlds") renderTlds();
|
||
if (pane === "domains") renderDomains();
|
||
if (pane === "studio") renderStudio();
|
||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||
}
|
||
function routeFromHash() {
|
||
const m = /^#([a-z]+)(?:\/(.+))?$/.exec(location.hash || "");
|
||
if (!m) return go("domains");
|
||
go(m[1], m[2] ? decodeURIComponent(m[2]) : undefined);
|
||
}
|
||
window.addEventListener("hashchange", () => { if (wallet) routeFromHash(); });
|
||
document.addEventListener("click", (e) => {
|
||
const el = e.target.closest("[data-go]");
|
||
if (!el) return;
|
||
e.preventDefault();
|
||
go(el.dataset.go, el.dataset.arg);
|
||
});
|
||
$("side-signout").addEventListener("click", () => signOut());
|
||
$("set-signout").addEventListener("click", () => signOut());
|
||
$("set-forget").addEventListener("click", async () => {
|
||
if (!confirm("Forget the wallet on this device? You will need your recovery phrase to sign in again.")) return;
|
||
await signOut({ forget: true });
|
||
});
|
||
$("refresh-btn").addEventListener("click", () => loadHoldings());
|
||
document.querySelectorAll("[data-copy]").forEach((b) => b.addEventListener("click", () => {
|
||
const t = $(b.dataset.copy)?.textContent || "";
|
||
navigator.clipboard?.writeText(t);
|
||
const o = b.textContent; b.textContent = "Copied"; setTimeout(() => { b.textContent = o; }, 1200);
|
||
}));
|
||
|
||
// ---------- holdings ----------
|
||
async function loadHoldings() {
|
||
const status = $("names-status");
|
||
status.className = "status"; status.textContent = "Loading names from the chain…";
|
||
try {
|
||
const scripthashes = wallet.watchedAddresses.map((a) => BNS.addressToScripthash(a));
|
||
const r = await fetch(`${API}/api/holdings`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ scripthashes }) });
|
||
if (!r.ok) throw new Error(`API ${r.status}: ${(await r.text().catch(() => "")).slice(0, 200)}`);
|
||
const p = await r.json();
|
||
H.names = (p.names || []).map((n) => ({ ...n, label: n.name.split(".")[0] }));
|
||
H.tlds = p.tlds || [];
|
||
H.wallet = p.wallet || null; H.chain = p.chain || null; H.loadedAt = Date.now();
|
||
// header numbers
|
||
const setText = (id, v) => { const el = $(id); if (el) el.textContent = v; };
|
||
setText("cnt-names", H.names.length); setText("cnt-tlds", H.tlds.length);
|
||
if (H.wallet?.sats != null) {
|
||
const s = fmtInt(H.wallet.sats);
|
||
setText("side-balance", s); setText("w-sats", `${s} sat`); setText("w-utxos", String(H.wallet.utxo_count));
|
||
// The header wallet menu (profile-menu.js) shows this on every page.
|
||
try { localStorage.setItem("siriusWalletInfo", JSON.stringify({ sats: String(H.wallet.sats), utxos: H.wallet.utxo_count, names: H.names.length, tlds: H.tlds.length, at: Date.now() })); } catch {}
|
||
}
|
||
if (H.chain?.height != null) setText("stat-height", fmtInt(H.chain.height));
|
||
renderAttention(); renderDomains(); renderTlds(); renderStudio();
|
||
if (current.name) { const e = H.names.find((n) => n.name === current.name); if (e) { current.entry = e; fillSummary(e); } }
|
||
warmDnsCache();
|
||
} catch (e) {
|
||
status.className = "status err";
|
||
status.textContent = "Could not load names: " + (e.message || e);
|
||
}
|
||
}
|
||
|
||
// DNS presence per name, fetched in the background (4 at a time) so the
|
||
// list can show a badge and Overview can flag names without records.
|
||
async function fetchDns(name, force = false) {
|
||
if (!force && dnsCache.has(name)) return dnsCache.get(name);
|
||
let out;
|
||
try {
|
||
const r = await fetch(`${API}/api/records/${encodeURIComponent(name)}`, { cache: "no-store" });
|
||
if (r.status === 404) out = { status: "none", seq: 0, dns: {} };
|
||
else if (r.status === 409) out = { status: "nopointer", seq: 0, dns: {} };
|
||
else if (!r.ok) out = { status: "error", seq: 0, dns: {} };
|
||
else { const j = await r.json(); out = { status: "ok", seq: typeof j.seq === "number" ? j.seq : 0, dns: j.dns || {}, raw: j }; }
|
||
} catch { out = { status: "error", seq: 0, dns: {} }; }
|
||
dnsCache.set(name, out);
|
||
return out;
|
||
}
|
||
async function warmDnsCache() {
|
||
const todo = H.names.filter((n) => !dnsCache.has(n.name)).map((n) => n.name);
|
||
let i = 0;
|
||
const worker = async () => { while (i < todo.length) { const n = todo[i++]; await fetchDns(n); updateDnsBadge(n); } };
|
||
await Promise.all([worker(), worker(), worker(), worker()]);
|
||
renderAttention();
|
||
}
|
||
function dnsCount(d) {
|
||
if (!d) return 0;
|
||
return ["A", "AAAA", "MX", "TXT", "NS", "SRV", "CAA"].reduce((n, k) => n + (Array.isArray(d[k]) ? d[k].length : 0), 0) + (d.CNAME ? 1 : 0);
|
||
}
|
||
function updateDnsBadge(name) {
|
||
const c = dnsCache.get(name);
|
||
document.querySelectorAll(`[data-dnsbadge="${CSS.escape(name)}"]`).forEach((el) => {
|
||
const n = c?.status === "ok" ? dnsCount(c.dns) : 0;
|
||
el.className = "badge " + (n ? "b-ok" : "b-dim");
|
||
el.textContent = n ? `DNS · ${n}` : (c?.status === "nopointer" ? "DNS needs s3" : "no DNS");
|
||
});
|
||
if (current.name === name) fillDnsBadge();
|
||
}
|
||
|
||
// ---------- classification helpers ----------
|
||
function pointsTo(rec) {
|
||
if (!rec) return { kind: "none", label: "not pointed", cls: "b-warn" };
|
||
if (rec.h) return { kind: "h", label: "site on chain", cls: "b-x" };
|
||
if (rec.s3) return { kind: "s3", label: "site on Sia", cls: "b-x" };
|
||
if (rec.p) return { kind: "p", label: "reverse proxy", cls: "b-x" };
|
||
if (rec.ip) return { kind: "ip", label: "own server", cls: "b-x" };
|
||
if (rec.u) return { kind: "u", label: "redirect", cls: "b-x" };
|
||
return { kind: "none", label: "not pointed", cls: "b-warn" };
|
||
}
|
||
const siteUrl = (name) => `${API}/bns/${encodeURIComponent(name)}/`;
|
||
const txUrl = (txid) => `https://chipnet.imaginary.cash/tx/${txid}`;
|
||
|
||
// ---------- attention strip (top of Domain names) ----------
|
||
function renderAttention() {
|
||
const items = [];
|
||
for (const e of H.names) {
|
||
const pt = pointsTo(e.records);
|
||
if (pt.kind === "none") items.push({ e, why: "points nowhere yet", go: "content" });
|
||
}
|
||
const box = $("ov-attention");
|
||
if (!items.length) { box.innerHTML = ""; return; }
|
||
box.innerHTML = `<div class="dim" style="margin:6px 0 4px">Needs attention</div>` +
|
||
items.slice(0, 5).map(({ e, why, go }) => `<div class="drow" data-open="${esc(e.name)}" data-sub="${go}"><div class="n">${nameHtml(e)}</div><span class="badge b-warn">${esc(why)}</span></div>`).join("");
|
||
}
|
||
|
||
// ---------- web-builder (Sirius Studio) ----------
|
||
function renderStudio() {
|
||
const status = $("studio-status"), list = $("studio-list");
|
||
if (!status || !list) return;
|
||
if (!H.names.length) {
|
||
status.className = "status";
|
||
status.innerHTML = `You need a name first. <a href="./#search-input">Register one →</a> — then come back here to build its site.`;
|
||
list.innerHTML = ""; return;
|
||
}
|
||
status.className = "status ok";
|
||
status.textContent = `Pick a name to build or edit its site.`;
|
||
list.innerHTML = [...H.names].sort((a, b) => a.name.localeCompare(b.name)).map((e) => {
|
||
const rec = e.records || {};
|
||
const studioFolder = `bns/${e.name}/`;
|
||
const st = rec.s3 === studioFolder ? { label: "built with Studio", cls: "b-ok" }
|
||
: rec.s3 ? { label: "site on Sia (external)", cls: "b-x" }
|
||
: rec.h ? { label: "inline HTML", cls: "b-x" }
|
||
: rec.u ? { label: "redirect", cls: "b-dim" }
|
||
: rec.p || rec.ip ? { label: "own server", cls: "b-dim" }
|
||
: { label: "no site yet", cls: "b-warn" };
|
||
return `<div class="drow" style="cursor:default">
|
||
<div class="n">${nameHtml(e)} <span class="badge ${st.cls}">${esc(st.label)}</span></div>
|
||
<div class="acts">
|
||
${rec.s3 || rec.h ? `<a class="btn small ghost" href="${siteUrl(e.name)}" target="_blank" rel="noopener">View →</a>` : ""}
|
||
<a class="btn small acid" href="./studio.html?name=${encodeURIComponent(e.name)}">${rec.s3 === studioFolder ? "Edit in Studio" : "Open in Studio"} →</a>
|
||
</div>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
// ---------- domains list ----------
|
||
function nameHtml(e) { return `<span>${esc(e.label)}.<span class="tld">${esc(e.tld)}</span></span>`; }
|
||
function domainRow(e, { compact = false } = {}) {
|
||
const pt = pointsTo(e.records);
|
||
const d = dnsCache.get(e.name);
|
||
const dn = d?.status === "ok" ? dnsCount(d.dns) : 0;
|
||
const dnsBadge = `<span class="badge ${dn ? "b-ok" : "b-dim"}" data-dnsbadge="${esc(e.name)}">${dn ? `DNS · ${dn}` : (d?.status === "nopointer" ? "DNS needs s3" : (d ? "no DNS" : "DNS …"))}</span>`;
|
||
const acts = compact ? "" : `<div class="acts">
|
||
<a class="btn small ghost" href="${siteUrl(e.name)}" target="_blank" rel="noopener" data-stop>Open →</a>
|
||
<button class="btn small ghost" data-open="${esc(e.name)}" data-sub="dns">DNS</button>
|
||
<button class="btn small acid" data-open="${esc(e.name)}" data-sub="summary">Manage</button></div>`;
|
||
return `<div class="drow" data-open="${esc(e.name)}" data-sub="summary">
|
||
<div class="n">${nameHtml(e)} <span class="badge ${pt.cls}">${esc(pt.label)}</span> ${dnsBadge}</div>
|
||
${acts}
|
||
${compact ? "" : `<div class="meta"><span>block ${e.height ? fmtInt(e.height) : "mempool"}</span><span>·</span><span class="mono" style="background:none;border:0;padding:0">${esc((e.category || "").slice(0, 12))}…</span></div>`}
|
||
</div>`;
|
||
}
|
||
function renderDomains() {
|
||
const list = $("names-list"), status = $("names-status");
|
||
const q = ($("dom-search").value || "").trim().toLowerCase();
|
||
const sort = $("dom-sort").value;
|
||
const tldSel = $("dom-tld");
|
||
// TLD filter options (rebuild keeping selection)
|
||
const tlds = [...new Set(H.names.map((n) => n.tld))].sort();
|
||
const keep = tldSel.value;
|
||
tldSel.innerHTML = `<option value="">All TLDs</option>` + tlds.map((t) => `<option value="${esc(t)}"${t === keep ? " selected" : ""}>.${esc(t)}</option>`).join("");
|
||
let rows = H.names.filter((n) => (!q || n.name.includes(q)) && (!tldSel.value || n.tld === tldSel.value));
|
||
rows.sort((a, b) => sort === "tld" ? (a.tld.localeCompare(b.tld) || a.label.localeCompare(b.label))
|
||
: sort === "height" ? ((b.height || 1e12) - (a.height || 1e12))
|
||
: a.name.localeCompare(b.name));
|
||
$("dom-count").textContent = `${rows.length} of ${H.names.length}`;
|
||
if (!H.names.length) {
|
||
status.className = "status"; status.textContent = "No BCNR names found for this wallet. If you just registered, wait a minute for confirmation and refresh.";
|
||
list.innerHTML = ""; return;
|
||
}
|
||
status.className = "status ok"; status.textContent = `You own ${H.names.length} name${H.names.length === 1 ? "" : "s"}.`;
|
||
list.innerHTML = rows.length ? rows.map((e) => domainRow(e)).join("") : `<div class="empty">Nothing matches that filter.</div>`;
|
||
}
|
||
["dom-search", "dom-sort", "dom-tld"].forEach((id) => $(id).addEventListener("input", renderDomains));
|
||
document.addEventListener("click", (e) => {
|
||
if (e.target.closest("[data-stop]")) return;
|
||
const el = e.target.closest("[data-open]");
|
||
if (!el) return;
|
||
e.preventDefault();
|
||
pendingSub = el.dataset.sub || "summary";
|
||
go("domain", el.dataset.open);
|
||
});
|
||
let pendingSub = "summary";
|
||
|
||
// ---------- domain detail ----------
|
||
function setSub(name) {
|
||
document.querySelectorAll(".subtabs [data-sub]").forEach((b) => b.classList.toggle("active", b.dataset.sub === name));
|
||
document.querySelectorAll('.pane[data-pane="domain"] .sub[data-sub]').forEach((s) => { s.hidden = s.dataset.sub !== name; });
|
||
if (name === "export") renderExportPreview();
|
||
}
|
||
document.querySelectorAll(".subtabs [data-sub]").forEach((b) => b.addEventListener("click", () => setSub(b.dataset.sub)));
|
||
|
||
async function openDomain(name) {
|
||
const entry = H.names.find((n) => n.name === name);
|
||
if (!entry) {
|
||
if (!H.names.length && Date.now() - H.loadedAt > 1000) { $("dd-name").textContent = name; $("dd-crumb").textContent = name; return; }
|
||
$("dd-name").textContent = name; $("dd-crumb").textContent = name;
|
||
$("dd-kv").innerHTML = `<div class="k">Status</div><div class="v">This wallet does not hold ${esc(name)}.</div>`;
|
||
return;
|
||
}
|
||
current = { name, entry, manifestSeq: 0, rows: [], dirty: false };
|
||
$("dd-name").textContent = name; $("dd-crumb").textContent = name;
|
||
$("dd-view").href = siteUrl(name);
|
||
fillSummary(entry);
|
||
fillContentForm(entry.records || {});
|
||
$("rd-url").value = entry.records?.u || "";
|
||
$("tr-addr").value = ""; $("tr-ack").checked = false; $("tr-send").disabled = true; setMsg("tr-msg", "");
|
||
setMsg("dns-msg", ""); setMsg("ct-msg", ""); setMsg("rd-msg", "");
|
||
setSub(pendingSub || "summary"); pendingSub = "summary";
|
||
$("sell-log").className = "steps-log"; $("sell-log").innerHTML = ""; setMsg("sell-msg", "");
|
||
loadListing(name);
|
||
await loadDns(name, true);
|
||
}
|
||
function setMsg(id, text, cls = "") { const m = $(id); if (!m) return; m.textContent = text; m.className = "msg " + cls; }
|
||
function logger(id) {
|
||
const log = $(id); log.className = "steps-log on"; log.innerHTML = "";
|
||
return (t) => { const d = document.createElement("div"); d.textContent = String(t); log.appendChild(d); log.scrollTop = log.scrollHeight; };
|
||
}
|
||
|
||
function fillSummary(e) {
|
||
const kv = [
|
||
["Name", `<code>${esc(e.name)}</code>`],
|
||
["TLD", `.${esc(e.tld)}`],
|
||
["Owner", `<code>${esc(wallet.address)}</code>`],
|
||
["Certificate", `<code>${esc(e.category || "")}</code>`],
|
||
["Registered", e.height ? `block ${fmtInt(e.height)} · <a href="${txUrl(e.txid)}" target="_blank" rel="noopener">tx →</a>` : `mempool · <a href="${txUrl(e.txid)}" target="_blank" rel="noopener">tx →</a>`],
|
||
["Last update", e.updated_txid ? `<a href="${txUrl(e.updated_txid)}" target="_blank" rel="noopener">${esc(e.updated_txid.slice(0, 16))}… →</a>` : "none since registration"],
|
||
["Renewal", "never — names do not expire"],
|
||
["Public URLs", `<a href="${siteUrl(e.name)}" target="_blank" rel="noopener">silentmode.st gateway</a> · <a href="https://navigate.st/bns/${encodeURIComponent(e.name)}/" target="_blank" rel="noopener">navigate.st</a>`],
|
||
];
|
||
$("dd-kv").innerHTML = kv.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
|
||
const rec = e.records || {};
|
||
const pt = pointsTo(rec);
|
||
const rows = [["Serves", `<span class="badge ${pt.cls}">${esc(pt.label)}</span>`]];
|
||
const labels = { h: "Inline HTML", s3: "Sia bucket", p: "Reverse proxy", ip: "Server IPv4", tls: "TLS pin", u: "Redirect" };
|
||
for (const k of ["h", "s3", "p", "ip", "tls", "u"]) if (rec[k]) rows.push([labels[k], `<code>${esc(String(rec[k]).slice(0, 120))}</code>`]);
|
||
if (rows.length === 1) rows.push(["Records", `none — <a href="#" data-sub-go="content">point it somewhere →</a>`]);
|
||
$("dd-records").innerHTML = rows.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
|
||
$("dd-records").querySelectorAll("[data-sub-go]").forEach((a) => a.onclick = (ev) => { ev.preventDefault(); setSub(a.dataset.subGo); });
|
||
fillDnsBadge();
|
||
}
|
||
function fillDnsBadge() {
|
||
const c = dnsCache.get(current.name);
|
||
const n = c?.status === "ok" ? dnsCount(c.dns) : 0;
|
||
const b = $("dd-dnsbadge");
|
||
b.className = "badge " + (n ? "b-ok" : "b-dim");
|
||
b.textContent = n ? `DNS · ${n} record${n === 1 ? "" : "s"}` : (c?.status === "nopointer" ? "DNS needs s3" : "no DNS");
|
||
const sum = $("dd-dns-summary");
|
||
if (!c) { sum.textContent = "Loading…"; return; }
|
||
if (c.status === "nopointer") { sum.innerHTML = `This name has no <b>s3</b> record, so there is nowhere to store a DNS manifest yet. Set one under Content & hosting first.`; return; }
|
||
if (!n) { sum.innerHTML = `No DNS records. <a href="#" data-sub-go="dns">Add some →</a>`; sum.querySelector("[data-sub-go]").onclick = (ev) => { ev.preventDefault(); setSub("dns"); }; return; }
|
||
const d = c.dns;
|
||
const parts = [];
|
||
for (const k of ["A", "AAAA", "NS", "TXT"]) if (Array.isArray(d[k]) && d[k].length) parts.push(`<b>${k}</b> ${d[k].map((v) => esc(String(v).slice(0, 48))).join(", ")}`);
|
||
if (d.CNAME) parts.push(`<b>CNAME</b> ${esc(d.CNAME)}`);
|
||
if (Array.isArray(d.MX) && d.MX.length) parts.push(`<b>MX</b> ${d.MX.map((m) => `${m.pref} ${esc(m.host)}`).join(", ")}`);
|
||
if (Array.isArray(d.SRV) && d.SRV.length) parts.push(`<b>SRV</b> ${d.SRV.length}`);
|
||
if (Array.isArray(d.CAA) && d.CAA.length) parts.push(`<b>CAA</b> ${d.CAA.map((x) => `${x.tag} ${esc(x.value)}`).join(", ")}`);
|
||
sum.innerHTML = parts.join("<br>") + `<div class="dim" style="margin-top:6px">manifest seq ${c.seq}</div>`;
|
||
}
|
||
|
||
// ---------- DNS editor ----------
|
||
const DNS_HELP = {
|
||
A: ["IPv4 address", "1.2.3.4"], AAAA: ["IPv6 address", "2001:db8::1"], CNAME: ["Target host (one CNAME per name)", "target.example.com"],
|
||
MX: ["Mail host", "mail.example.com"], TXT: ["Text (SPF, DKIM, verification…)", "v=spf1 include:_spf.silentmode.st -all"],
|
||
NS: ["Nameserver host", "ns1.example.com"], SRV: ["Target host", "sip.example.com"], CAA: ["CA domain or report URL", "letsencrypt.org"],
|
||
};
|
||
function rowsFromManifest(d) {
|
||
const rows = [];
|
||
for (const t of ["A", "AAAA", "TXT", "NS"]) for (const v of (Array.isArray(d?.[t]) ? d[t] : [])) rows.push({ type: t, value: String(v) });
|
||
if (d?.CNAME) rows.push({ type: "CNAME", value: String(d.CNAME) });
|
||
for (const m of (Array.isArray(d?.MX) ? d.MX : [])) rows.push({ type: "MX", value: String(m.host), prio: Number(m.pref) || 0 });
|
||
for (const s of (Array.isArray(d?.SRV) ? d.SRV : [])) rows.push({ type: "SRV", value: String(s.target), prio: Number(s.priority) || 0, weight: Number(s.weight) || 0, port: Number(s.port) || 0 });
|
||
for (const c of (Array.isArray(d?.CAA) ? d.CAA : [])) rows.push({ type: "CAA", value: String(c.value), tag: c.tag || "issue", flags: Number(c.flags) || 0 });
|
||
return rows;
|
||
}
|
||
function manifestFromRows(rows) {
|
||
const d = { A: [], AAAA: [], MX: [], TXT: [], CNAME: null, NS: [] };
|
||
const srv = [], caa = [];
|
||
for (const r of rows) {
|
||
if (r.type === "MX") d.MX.push({ pref: r.prio || 0, host: r.value });
|
||
else if (r.type === "CNAME") d.CNAME = r.value;
|
||
else if (r.type === "SRV") srv.push({ priority: r.prio || 0, weight: r.weight || 0, port: r.port || 0, target: r.value });
|
||
else if (r.type === "CAA") caa.push({ flags: r.flags || 0, tag: r.tag || "issue", value: r.value });
|
||
else if (d[r.type]) d[r.type].push(r.value);
|
||
}
|
||
if (srv.length) d.SRV = srv;
|
||
if (caa.length) d.CAA = caa;
|
||
return d;
|
||
}
|
||
function extraOf(r) {
|
||
if (r.type === "MX") return `priority ${r.prio}`;
|
||
if (r.type === "SRV") return `prio ${r.prio} · weight ${r.weight} · port ${r.port}`;
|
||
if (r.type === "CAA") return `${r.tag}${r.flags ? ` · flags ${r.flags}` : ""}`;
|
||
return "";
|
||
}
|
||
function renderDnsTable() {
|
||
const tb = $("dns-table").querySelector("tbody");
|
||
tb.innerHTML = current.rows.map((r, i) => `<tr>
|
||
<td class="t">${esc(r.type)}</td><td class="v">${esc(r.value)}</td><td class="p">${esc(extraOf(r))}</td>
|
||
<td class="a"><button class="x" data-del="${i}" title="Remove">×</button></td></tr>`).join("");
|
||
$("dns-empty").classList.toggle("hidden", current.rows.length > 0);
|
||
$("dns-seq").textContent = `seq ${current.manifestSeq}${current.dirty ? " · unsaved" : ""}`;
|
||
tb.querySelectorAll("[data-del]").forEach((b) => b.onclick = () => { current.rows.splice(Number(b.dataset.del), 1); current.dirty = true; renderDnsTable(); });
|
||
}
|
||
async function loadDns(name, force) {
|
||
const c = await fetchDns(name, force);
|
||
if (current.name !== name) return;
|
||
current.manifestSeq = c.seq || 0;
|
||
current.rows = c.status === "ok" ? rowsFromManifest(c.dns) : [];
|
||
current.dirty = false;
|
||
renderDnsTable();
|
||
updateDnsBadge(name);
|
||
const pub = $("dns-publish");
|
||
if (c.status === "nopointer") {
|
||
setMsg("dns-msg", "This name has no s3 record on chain, so the manifest has nowhere to live. Set one under Content & hosting first (any Sia bucket key works).", "err");
|
||
pub.disabled = true;
|
||
} else { pub.disabled = false; }
|
||
}
|
||
function updateDnsForm() {
|
||
const t = $("dns-type").value;
|
||
document.querySelectorAll(".addrow .opt").forEach((o) => { o.hidden = !o.dataset.for.split(" ").includes(t); });
|
||
const [label, ph] = DNS_HELP[t] || ["Value", ""];
|
||
$("dns-value-label").textContent = label; $("dns-value").placeholder = ph;
|
||
$("dns-help").textContent = t === "CNAME" ? "A CNAME replaces any existing CNAME; classic DNS does not allow it next to other records on the same host." :
|
||
t === "TXT" ? "One string per record. Long DKIM keys are fine — there is no size cap in the manifest." :
|
||
t === "CAA" ? "Restricts which certificate authorities may issue for this name in classic DNS." : "";
|
||
}
|
||
$("dns-type").addEventListener("change", updateDnsForm); updateDnsForm();
|
||
$("dns-add").addEventListener("click", () => {
|
||
const type = $("dns-type").value;
|
||
const value = $("dns-value").value.trim();
|
||
if (!value) { $("dns-value").focus(); return; }
|
||
if (type === "A" && !/^\d{1,3}(\.\d{1,3}){3}$/.test(value)) return setMsg("dns-msg", "That is not an IPv4 address.", "err");
|
||
if (type === "AAAA" && !/^[0-9a-f:]+$/i.test(value)) return setMsg("dns-msg", "That is not an IPv6 address.", "err");
|
||
const row = { type, value };
|
||
if (type === "MX" || type === "SRV") row.prio = Number($("dns-prio").value) || 0;
|
||
if (type === "SRV") { row.weight = Number($("dns-weight").value) || 0; row.port = Number($("dns-port").value) || 0; }
|
||
if (type === "CAA") { row.tag = $("dns-tag").value; row.flags = 0; }
|
||
if (type === "CNAME") current.rows = current.rows.filter((r) => r.type !== "CNAME");
|
||
current.rows.push(row); current.dirty = true;
|
||
$("dns-value").value = ""; $("dns-prio").value = ""; $("dns-weight").value = ""; $("dns-port").value = "";
|
||
setMsg("dns-msg", ""); renderDnsTable();
|
||
});
|
||
$("dns-value").addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("dns-add").click(); } });
|
||
$("dns-reload").addEventListener("click", () => loadDns(current.name, true));
|
||
$("dns-publish").addEventListener("click", async () => {
|
||
if (!current.name || !wallet) return;
|
||
if (wallet.source === "wc") return setMsg("dns-msg", "External-wallet signing (WizardConnect) for records manifests lands in the next drop — please sign in with the built-in wallet.", "err");
|
||
const push = logger("dns-log"); const btn = $("dns-publish");
|
||
btn.disabled = true; btn.textContent = "Signing…"; setMsg("dns-msg", "");
|
||
try {
|
||
const manifest = { v: 1, name: current.name, seq: (current.manifestSeq || 0) + 1, updated_at: new Date().toISOString(), dns: manifestFromRows(current.rows) };
|
||
push(`composed manifest · seq ${manifest.seq} · ${current.rows.length} record${current.rows.length === 1 ? "" : "s"}`);
|
||
const signed = await BNS.signRecordsManifest(wallet, manifest);
|
||
push("signed with wallet key");
|
||
const r = await fetch(`${API}/api/records/${encodeURIComponent(current.name)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(signed) });
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(`API ${r.status}: ${j.error || "unknown error"}`);
|
||
push(`published · ${j.bytes || "?"} bytes at ${j.sia_key}`);
|
||
current.manifestSeq = manifest.seq; current.dirty = false;
|
||
dnsCache.set(current.name, { status: "ok", seq: manifest.seq, dns: manifest.dns, raw: manifest });
|
||
renderDnsTable(); updateDnsBadge(current.name); renderAttention();
|
||
setMsg("dns-msg", `Published manifest seq ${manifest.seq}. Resolvers pick it up on their next fetch (about 30 s at the gateway).`, "ok");
|
||
} catch (e) { setMsg("dns-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); }
|
||
finally { btn.disabled = false; btn.textContent = "Sign & publish →"; }
|
||
});
|
||
|
||
// ---------- content & hosting (on-chain records) ----------
|
||
const CT_KEYS = ["h", "s3", "p", "ip", "tls"];
|
||
function fillContentForm(rec) { for (const k of CT_KEYS) $("ct-" + k).value = rec[k] || ""; updateBudget(); }
|
||
function collectContent() {
|
||
const r = {};
|
||
for (const k of CT_KEYS) { const v = $("ct-" + k).value.trim(); if (v) r[k] = v; }
|
||
const u = ($("rd-url").value || "").trim(); if (u) r.u = u;
|
||
return r;
|
||
}
|
||
function updateBudget() {
|
||
if (!current.name) return;
|
||
try {
|
||
const max = BNS.payloadBudget(current.name, "UPD");
|
||
const used = new TextEncoder().encode(JSON.stringify(collectContent())).length;
|
||
$("ct-budget-max").textContent = max; $("ct-budget-used").textContent = used;
|
||
$("ct-budget").classList.toggle("over", used > max); $("ct-save").disabled = used > max;
|
||
} catch {}
|
||
}
|
||
CT_KEYS.forEach((k) => $("ct-" + k).addEventListener("input", updateBudget));
|
||
$("rd-url").addEventListener("input", updateBudget);
|
||
$("ct-reset").addEventListener("click", () => fillContentForm(current.entry?.records || {}));
|
||
|
||
async function publishRecords(records, logId, msgId, btnId, doneText) {
|
||
if (!current.name || !wallet) return false;
|
||
const push = logger(logId); const btn = $(btnId); const orig = btn.textContent;
|
||
btn.disabled = true; btn.textContent = "Signing…"; setMsg(msgId, "");
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect(); push("connected to chipnet");
|
||
const res = wallet.source === "wc"
|
||
? await BNS.setRecordsWithExternalWallet(el, { session: wallet.session, name: current.name, records, onProgress: (s) => push(s) })
|
||
: await BNS.setRecordsWithBuiltInWallet(el, { wallet, name: current.name, records, onProgress: (s) => push(s) });
|
||
push("broadcast: " + (res.txid || res.txId || "(no txid)"));
|
||
if (current.entry) { current.entry.records = records; current.entry.updated_txid = res.txid || res.txId || current.entry.updated_txid; }
|
||
fillSummary(current.entry); renderDomains(); renderAttention(); renderStudio();
|
||
setMsg(msgId, doneText, "ok");
|
||
return true;
|
||
} catch (e) { setMsg(msgId, "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); return false; }
|
||
finally { btn.disabled = false; btn.textContent = orig; try { el?.close?.(); } catch {} }
|
||
}
|
||
$("ct-save").addEventListener("click", () => publishRecords(collectContent(), "ct-log", "ct-msg", "ct-save", "Saved. Records refresh on the chain within a block."));
|
||
$("rd-save").addEventListener("click", () => {
|
||
const u = $("rd-url").value.trim();
|
||
if (!/^https?:\/\//i.test(u)) return setMsg("rd-msg", "Enter a full URL starting with http:// or https://", "err");
|
||
publishRecords(collectContent(), "rd-log", "rd-msg", "rd-save", "Redirect saved. Resolvers forward the name within a block.");
|
||
});
|
||
$("rd-clear").addEventListener("click", () => { $("rd-url").value = ""; publishRecords(collectContent(), "rd-log", "rd-msg", "rd-clear", "Redirect removed."); });
|
||
|
||
// ---------- transfer ----------
|
||
function validAddress(a) { try { BNS.addressToLockingBytecode(a); return true; } catch { return false; } }
|
||
function updateTransferBtn() {
|
||
const a = $("tr-addr").value.trim();
|
||
$("tr-send").disabled = !( $("tr-ack").checked && validAddress(a) && a !== wallet?.address );
|
||
}
|
||
$("tr-addr").addEventListener("input", updateTransferBtn);
|
||
$("tr-ack").addEventListener("change", updateTransferBtn);
|
||
$("tr-send").addEventListener("click", async () => {
|
||
if (!current.entry || !wallet) return;
|
||
const to = $("tr-addr").value.trim();
|
||
if (wallet.source === "wc") return setMsg("tr-msg", "Transfers from an external wallet land in the next drop — sign in with the built-in wallet.", "err");
|
||
if (!/^bchtest:/.test(to)) return setMsg("tr-msg", "Chipnet addresses start with bchtest: — a mainnet address would lose the name.", "err");
|
||
if (!confirm(`Transfer ${current.name} to ${to}? This cannot be undone.`)) return;
|
||
const push = logger("tr-log"); const btn = $("tr-send");
|
||
btn.disabled = true; btn.textContent = "Transferring…"; setMsg("tr-msg", "");
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect(); push("connected to chipnet");
|
||
const utxos = await BNS.getUtxosForAddresses(el, wallet.watchedAddresses);
|
||
const raw = BNS.normalizeName(current.name);
|
||
const commitment = [...new TextEncoder().encode(raw)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
const cert = utxos.find((u) => u.token?.category === current.entry.category && u.token?.nft?.commitment === commitment);
|
||
if (!cert) throw new Error("certificate UTXO not found in this wallet (already moved?)");
|
||
push("found certificate · building transfer");
|
||
const built = BNS.buildUpdateTx({
|
||
name: current.name, ownerAddress: wallet.address, ownerTokenAddress: BNS.toTokenAddress(to),
|
||
records: current.entry.records || {}, certificateUtxo: cert, utxos: utxos.filter((u) => !u.token),
|
||
addressToLockingBytecode: BNS.addressToLockingBytecode,
|
||
});
|
||
const signed = BNS.signTransaction(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h));
|
||
push("signed · broadcasting");
|
||
const txid = await BNS.broadcast(el, signed.hex);
|
||
push("broadcast: " + txid);
|
||
setMsg("tr-msg", `Transferred. ${current.name} now belongs to ${to}. It disappears from this list after the next refresh.`, "ok");
|
||
btn.textContent = "Transferred ✓";
|
||
setTimeout(() => loadHoldings(), 4000);
|
||
} catch (e) { setMsg("tr-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); btn.disabled = false; btn.textContent = "Transfer name →"; }
|
||
finally { try { el?.close?.(); } catch {} }
|
||
});
|
||
|
||
// ---------- signed API calls (owner proves control of the name's key) ----------
|
||
async function signedHeaders(msg) {
|
||
const ts = String(Date.now());
|
||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(msg.replace("{ts}", ts))));
|
||
const sig = wallet.signMessage(digest);
|
||
return { "x-bns-ts": ts, "x-bns-sig": btoa(String.fromCharCode(...sig)) };
|
||
}
|
||
|
||
// ---------- sell (marketplace listing) ----------
|
||
const RATE = () => window.siriusPricing?.CHIPNET_SATS_PER_USD || 250000;
|
||
let activeListing = null;
|
||
function paintSell() {
|
||
const st = $("sell-status");
|
||
$("sell-rate").textContent = `rate ${fmtInt(RATE())} sat/USD (chipnet)`;
|
||
if (activeListing) {
|
||
st.className = "status ok"; st.textContent = "This name is listed on the market.";
|
||
$("sell-form").classList.add("hidden"); $("sell-active").classList.remove("hidden");
|
||
const p = BigInt(activeListing.price_sats);
|
||
$("sell-kv").innerHTML = [["Price", `${fmtInt(p)} sat (≈ ${esc(fmtUsd(Math.round(Number(p) / RATE() * 100) / 100))})`], ["Listed", esc(new Date(activeListing.created_at).toLocaleString())], ["Offer", `<code>${esc(activeListing.partial_tx.slice(0, 40))}…</code>`], ["Market page", `<a href="./market.html" target="_blank" rel="noopener">open →</a>`]]
|
||
.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
|
||
} else {
|
||
st.className = "status"; st.textContent = "Not for sale. Set a price to list it.";
|
||
$("sell-form").classList.remove("hidden"); $("sell-active").classList.add("hidden");
|
||
}
|
||
}
|
||
async function loadListing(name) {
|
||
activeListing = null;
|
||
try { const r = await fetch(`${API}/api/market/${encodeURIComponent(name)}`, { cache: "no-store" }); if (r.ok) activeListing = await r.json(); } catch {}
|
||
if (current.name === name) paintSell();
|
||
}
|
||
$("sell-usd").addEventListener("input", () => { const v = Number(String($("sell-usd").value).replace(/[^\d.]/g, "")); $("sell-sats").value = v > 0 ? String(Math.round(v * RATE())) : ""; });
|
||
$("sell-sats").addEventListener("input", () => { const s = Number(String($("sell-sats").value).replace(/\D/g, "")); $("sell-usd").value = s > 0 ? String(Math.round(s / RATE() * 100) / 100) : ""; });
|
||
$("sell-list").addEventListener("click", async () => {
|
||
if (!current.entry || !wallet) return;
|
||
if (wallet.source === "wc") return setMsg("sell-msg", "Listing from an external wallet lands in the next drop — sign in with the built-in wallet.", "err");
|
||
const sats = BigInt(Number(String($("sell-sats").value).replace(/\D/g, "")) || 0);
|
||
if (sats < 10000n) return setMsg("sell-msg", "Set a price of at least 10,000 sat.", "err");
|
||
const push = logger("sell-log"); const btn = $("sell-list"); btn.disabled = true; btn.textContent = "Signing…"; setMsg("sell-msg", "");
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect(); push("connected to chipnet");
|
||
const utxos = await BNS.getUtxosForAddresses(el, wallet.watchedAddresses);
|
||
const raw = BNS.normalizeName(current.name);
|
||
const commitment = [...new TextEncoder().encode(raw)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
||
const cert = utxos.find((u) => u.token?.category === current.entry.category && u.token?.nft?.commitment === commitment);
|
||
if (!cert) throw new Error("certificate UTXO not found in this wallet");
|
||
push("found certificate · signing the offer (SINGLE|ANYONECANPAY)");
|
||
const listing = BNS.buildListing({ name: current.name, certificateUtxo: cert, priceSats: sats, sellerAddress: cert.address, addressToLockingBytecode: BNS.addressToLockingBytecode, keyFor: (h) => wallet.keyFor(h) });
|
||
const r = await fetch(`${API}/api/market`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(listing) });
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(j.error || `API ${r.status}`);
|
||
push("listed on the market");
|
||
activeListing = j.listing; paintSell();
|
||
setMsg("sell-msg", `Listed for ${fmtInt(sats)} sat. Nothing moves until a buyer pays that price.`, "ok");
|
||
} catch (e) { setMsg("sell-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); }
|
||
finally { btn.disabled = false; btn.textContent = "List for sale →"; try { el?.close?.(); } catch {} }
|
||
});
|
||
$("sell-cancel").addEventListener("click", async () => {
|
||
if (!current.entry || !wallet || !activeListing) return;
|
||
if (!confirm(`Cancel the listing for ${current.name}? This also moves the certificate once so the signed offer becomes void.`)) return;
|
||
const push = logger("sell-log"); const btn = $("sell-cancel"); btn.disabled = true; btn.textContent = "Cancelling…"; setMsg("sell-msg", "");
|
||
try {
|
||
const h = await signedHeaders(`BNS-MARKET1\n${current.name}\n{ts}`);
|
||
const r = await fetch(`${API}/api/market/${encodeURIComponent(current.name)}`, { method: "DELETE", headers: h });
|
||
const j = await r.json().catch(() => ({}));
|
||
if (!r.ok) throw new Error(j.error || `API ${r.status}`);
|
||
push("removed from the market");
|
||
activeListing = null; paintSell();
|
||
push("invalidating the signed offer on chain (no-op update)…");
|
||
const ok = await publishRecords(current.entry.records || {}, "sell-log", "sell-msg", "sell-cancel", "Listing cancelled and the offer voided on chain.");
|
||
if (!ok) setMsg("sell-msg", "Removed from the market, but the on-chain invalidation failed — retry from Content & hosting (Save records) so the old offer cannot be used.", "err");
|
||
} catch (e) { setMsg("sell-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); }
|
||
finally { btn.disabled = false; btn.textContent = "Cancel listing →"; }
|
||
});
|
||
|
||
// ---------- export ----------
|
||
function zoneText() {
|
||
const e = current.entry; if (!e) return "";
|
||
const c = dnsCache.get(e.name); const d = c?.status === "ok" ? c.dns : {};
|
||
const L = [`; ${e.name} — exported from Sirius.X ${new Date().toISOString()}`, `$ORIGIN ${e.name}.`, `$TTL 300`, ``];
|
||
const rec = e.records || {};
|
||
L.push(`; on-chain records (authoritative for BCNR resolvers)`);
|
||
for (const k of ["h", "s3", "p", "ip", "tls", "u"]) if (rec[k]) L.push(`; ${k} = ${String(rec[k]).replace(/\n/g, " ")}`);
|
||
if (rec.ip && !(Array.isArray(d.A) && d.A.length)) L.push(`@\tIN\tA\t${rec.ip}`);
|
||
L.push(``, `; signed DNS manifest${c?.seq ? ` (seq ${c.seq})` : ""}`);
|
||
for (const v of d.A || []) L.push(`@\tIN\tA\t${v}`);
|
||
for (const v of d.AAAA || []) L.push(`@\tIN\tAAAA\t${v}`);
|
||
if (d.CNAME) L.push(`@\tIN\tCNAME\t${d.CNAME}.`);
|
||
for (const m of d.MX || []) L.push(`@\tIN\tMX\t${m.pref}\t${m.host}.`);
|
||
for (const v of d.NS || []) L.push(`@\tIN\tNS\t${v}.`);
|
||
for (const v of d.TXT || []) L.push(`@\tIN\tTXT\t"${String(v).replace(/"/g, '\\"')}"`);
|
||
for (const s of d.SRV || []) L.push(`@\tIN\tSRV\t${s.priority} ${s.weight} ${s.port}\t${s.target}.`);
|
||
for (const x of d.CAA || []) L.push(`@\tIN\tCAA\t${x.flags || 0} ${x.tag} "${x.value}"`);
|
||
return L.join("\n") + "\n";
|
||
}
|
||
function renderExportPreview() { $("ex-preview").textContent = zoneText(); }
|
||
function download(filename, text, type = "text/plain") {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([text], { type })); a.download = filename; a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||
}
|
||
$("ex-zone").addEventListener("click", () => download(`${current.name}.zone.txt`, zoneText()));
|
||
$("ex-json").addEventListener("click", () => {
|
||
const c = dnsCache.get(current.name);
|
||
download(`${current.name}.json`, JSON.stringify({ name: current.name, chain: current.entry, dns: c?.raw || null, exported_at: new Date().toISOString() }, null, 2), "application/json");
|
||
});
|
||
$("ex-copy").addEventListener("click", () => { const c = dnsCache.get(current.name); navigator.clipboard?.writeText(JSON.stringify(c?.raw || { dns: {} }, null, 2)); });
|
||
|
||
// ---------- TLDs ----------
|
||
function isTldHidden(records) { const h = records?.hidden; return h === 1 || h === true || h === "1"; }
|
||
function tldPriceOf(records) { const p = records?.price; return typeof p === "number" && Number.isFinite(p) && p >= 0 ? p : null; }
|
||
const fmtUsd = (n) => (window.siriusPricing?.formatUsd || ((x) => "$" + x))(n);
|
||
|
||
function renderTlds() {
|
||
const status = $("tlds-status"), list = $("tlds-list");
|
||
if (!H.tlds.length) {
|
||
status.className = "status";
|
||
status.innerHTML = `This wallet does not hold any TLD certificates yet. <a href="#newtld" data-go="newtld">Register one →</a> — every name registered under it earns you 90% of the service fee.`;
|
||
list.innerHTML = ""; return;
|
||
}
|
||
status.className = "status ok"; status.textContent = `You own ${H.tlds.length} TLD${H.tlds.length === 1 ? "" : "s"}.`;
|
||
list.innerHTML = H.tlds.map((t) => {
|
||
const rec = t.records || {}; const off = isTldHidden(rec); const price = tldPriceOf(rec);
|
||
const under = H.names.filter((n) => n.tld === t.tld).length;
|
||
return `<div class="drow" data-go="tld" data-arg="${esc(t.tld)}">
|
||
<div class="n">.<span class="tld">${esc(t.tld)}</span>
|
||
<span class="badge ${off ? "b-off" : "b-ok"}">${off ? "off · private" : "on · public"}</span>
|
||
<span class="badge b-x">${price != null ? `${esc(fmtUsd(price))} / name` : "tier pricing"}</span></div>
|
||
<div class="acts"><button class="btn small acid" data-go="tld" data-arg="${esc(t.tld)}">Manage</button></div>
|
||
<div class="meta"><span>${under} of your names under it</span><span>·</span><span>policy ${esc(rec.policy || "open")}</span></div>
|
||
</div>`;
|
||
}).join("");
|
||
}
|
||
async function registry() {
|
||
if (Date.now() - registryCache.at < 60000) return registryCache.names;
|
||
try {
|
||
const r = await fetch(`${API}/api/registry`, { cache: "no-store" });
|
||
const j = await r.json(); registryCache = { at: Date.now(), names: j.names || [] };
|
||
} catch { registryCache.at = Date.now(); }
|
||
return registryCache.names;
|
||
}
|
||
async function openTld(label) {
|
||
const t = H.tlds.find((x) => x.tld === label);
|
||
currentTld = t || null;
|
||
$("td-name").textContent = "." + label; $("td-crumb").textContent = "." + label; $("td-suffix").textContent = "." + label;
|
||
setMsg("td-msg", "");
|
||
if (!t) { $("td-kv").innerHTML = `<div class="k">Status</div><div class="v">This wallet does not hold .${esc(label)}.</div>`; return; }
|
||
const rec = t.records || {}; const off = isTldHidden(rec); const price = tldPriceOf(rec);
|
||
const st = $("td-state"); st.className = "badge " + (off ? "b-off" : "b-ok"); st.textContent = off ? "off · private" : "on · public";
|
||
$("td-price").textContent = price != null ? `${fmtUsd(price)} / name` : "tier pricing";
|
||
$("td-toggle").textContent = off ? "Switch on" : "Switch off";
|
||
let pub = null;
|
||
try { const j = await (await fetch(TLD_API, { cache: "no-store" })).json(); pub = (j.tlds || []).find((x) => x.tld === label) || null; } catch {}
|
||
const kv = [
|
||
["Certificate", `<code>${esc(t.category)}</code>`],
|
||
["Owner", `<code>${esc(t.owner || wallet.address)}</code>`],
|
||
["Registered", t.reg_height ? `block ${fmtInt(t.reg_height)}` : "mempool"],
|
||
["Last policy update", t.updated_txid ? `<a href="${txUrl(t.updated_txid)}" target="_blank" rel="noopener">${esc(t.updated_txid.slice(0, 16))}… →</a>` : "none"],
|
||
["Names registered", pub ? String(pub.registered_count) : "—"],
|
||
["Price", price != null ? `${esc(fmtUsd(price))} per name (you receive 90%)` : "length tiers ($0.10 – $5)"],
|
||
["Listing", off ? "off — private: only you can register names" : "on — anyone can register names"],
|
||
["Policy", esc(rec.policy || "open") + (rec.min_len ? ` · min length ${rec.min_len}` : "") + (rec.reserved ? ` · reserved: ${esc(rec.reserved)}` : "")],
|
||
];
|
||
$("td-kv").innerHTML = kv.map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
|
||
loadCosign(label);
|
||
const names = (await registry()).filter((n) => n.tld === label);
|
||
$("td-names").innerHTML = names.length
|
||
? names.map((n) => `<div class="drow" style="cursor:default"><div class="n"><span>${esc(n.name)}</span>${H.names.some((m) => m.name === n.name) ? '<span class="badge b-x">yours</span>' : ""}</div>
|
||
<div class="acts"><a class="btn small ghost" href="${siteUrl(n.name)}" target="_blank" rel="noopener">Open →</a></div>
|
||
<div class="meta"><span>block ${n.height ? fmtInt(n.height) : "mempool"}</span></div></div>`).join("")
|
||
: `<div class="empty">No names registered under .${esc(label)} yet.</div>`;
|
||
}
|
||
// ---------- co-sign queue (registrations under this TLD awaiting the owner) ----------
|
||
async function loadCosign(label) {
|
||
const box = $("td-cosign");
|
||
box.innerHTML = `<div class="empty">Checking…</div>`;
|
||
let list = [];
|
||
try { const j = await (await fetch(`${API}/api/cosign?tld=${encodeURIComponent(label)}`, { cache: "no-store" })).json(); list = j.requests || []; } catch {}
|
||
if (currentTld?.tld !== label) return;
|
||
box.innerHTML = list.length
|
||
? list.map((r) => `<div class="drow" style="cursor:default"><div class="n"><span>${esc(r.name)}</span><span class="badge b-warn">awaiting you</span></div>
|
||
<div class="acts"><button class="btn small acid" data-approve="${esc(r.id)}">Approve & 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);
|
||
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;
|
||
setMsg("td-msg", `${turningOff ? "Off" : "On"} — broadcast ${(res.txid || "").slice(0, 16)}…. The public registry picks it up within a minute.`, "ok");
|
||
openTld(currentTld.tld); renderTlds();
|
||
} catch (e) { setMsg("td-msg", "Failed: " + (e.message || e), "err"); }
|
||
finally { btn.disabled = false; try { el?.close?.(); } catch {} }
|
||
});
|
||
$("td-policy").addEventListener("click", () => { if (currentTld) openTldEditor(currentTld.tld, currentTld.category, currentTld.records || {}); });
|
||
|
||
// ---------- TLD policy editor (TUPD) ----------
|
||
const TLD_POLICY_FIELDS = [
|
||
{ key: "price", id: "tld-ed-price", parse: parseUsdOrNull },
|
||
{ key: "hidden", id: "tld-ed-hidden", parse: (v) => (String(v) === "1" ? 1 : null) },
|
||
{ key: "min_len", id: "tld-ed-minlen", parse: parseIntOrNull },
|
||
{ key: "fee_bps", id: "tld-ed-feebps", parse: parseIntOrNull },
|
||
{ key: "policy", id: "tld-ed-policy", parse: trimOrNull },
|
||
{ key: "reserved", id: "tld-ed-reserved", parse: trimOrNull },
|
||
{ key: "renewal_period", id: "tld-ed-renewal", parse: parseIntOrNull },
|
||
{ key: "renewal_fee", id: "tld-ed-renewalfee", parse: parseIntOrNull },
|
||
{ key: "site", id: "tld-ed-site", parse: trimOrNull },
|
||
];
|
||
function parseIntOrNull(v) { v = String(v).trim(); if (!v) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null; }
|
||
function parseUsdOrNull(v) { v = String(v).trim().replace(/^\$/, ""); if (!v) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) / 100 : null; }
|
||
function trimOrNull(v) { v = String(v).trim(); return v || null; }
|
||
let tldEditor = { tld: null, category: null };
|
||
function openTldEditor(tld, category, records) {
|
||
tldEditor = { tld, category };
|
||
$("tld-ed-name").textContent = "." + tld;
|
||
for (const f of TLD_POLICY_FIELDS) { const cur = records?.[f.key]; $(f.id).value = cur == null ? "" : String(cur); }
|
||
setMsg("tld-ed-msg", ""); $("tld-ed-log").className = "steps-log"; $("tld-ed-log").innerHTML = "";
|
||
$("tld-ed-save").disabled = false; $("tld-ed-save").textContent = "Save policy →";
|
||
updateTldBudget(); $("tld-editor").classList.add("open");
|
||
}
|
||
function closeTldEditor() { $("tld-editor").classList.remove("open"); tldEditor = { tld: null, category: null }; }
|
||
$("tld-ed-close").addEventListener("click", closeTldEditor);
|
||
$("tld-ed-cancel").addEventListener("click", closeTldEditor);
|
||
$("tld-editor").addEventListener("click", (e) => { if (e.target === $("tld-editor")) closeTldEditor(); });
|
||
function collectTldRecords() { const r = {}; for (const f of TLD_POLICY_FIELDS) { const v = f.parse($(f.id).value); if (v !== null && v !== undefined && v !== "") r[f.key] = v; } return r; }
|
||
function updateTldBudget() {
|
||
if (!tldEditor.tld) return;
|
||
try {
|
||
const max = BNS.payloadBudget(tldEditor.tld, "TUPD");
|
||
const used = new TextEncoder().encode(JSON.stringify(collectTldRecords())).length;
|
||
$("tld-ed-budget-max").textContent = max; $("tld-ed-budget-used").textContent = used;
|
||
$("tld-ed-budget").classList.toggle("over", used > max); $("tld-ed-save").disabled = used > max;
|
||
} catch {}
|
||
}
|
||
for (const f of TLD_POLICY_FIELDS) $(f.id).addEventListener("input", updateTldBudget);
|
||
$("tld-ed-save").addEventListener("click", async () => {
|
||
if (!tldEditor.tld || !tldEditor.category || !wallet) return;
|
||
if (wallet.source === "wc") return setMsg("tld-ed-msg", "External-wallet (WizardConnect) TUPD lands in the next drop — please sign in with the built-in wallet.", "err");
|
||
const records = collectTldRecords(); const push = logger("tld-ed-log");
|
||
$("tld-ed-save").disabled = true; $("tld-ed-save").textContent = "Signing…"; setMsg("tld-ed-msg", "");
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect(); push("connected to chipnet");
|
||
const res = await BNS.setTldRecordsWithBuiltInWallet(el, { wallet, tld: tldEditor.tld, records, category: tldEditor.category, onProgress: (s) => push(String(s)) });
|
||
push("broadcast: " + (res.txid || "(no txid)"));
|
||
const t = H.tlds.find((x) => x.tld === tldEditor.tld); if (t) t.records = records;
|
||
setMsg("tld-ed-msg", "Policy saved. It reflects in registrations under this TLD within a block.", "ok");
|
||
$("tld-ed-save").textContent = "Saved ✓";
|
||
renderTlds(); if (currentTld?.tld === tldEditor.tld) openTld(tldEditor.tld);
|
||
setTimeout(closeTldEditor, 1200);
|
||
} catch (e) { setMsg("tld-ed-msg", "Failed: " + (e.message || e), "err"); push("error: " + (e.message || e)); $("tld-ed-save").disabled = false; $("tld-ed-save").textContent = "Save policy →"; }
|
||
finally { try { el?.close?.(); } catch {} }
|
||
});
|
||
|
||
// ---------- new TLD ----------
|
||
const CHIPNET_SATS_PER_USD = 250_000;
|
||
function usdToSats(usd) { const n = Number(String(usd).replace(/[^\d.]/g, "")) || 0; return Math.max(0, Math.round(n * CHIPNET_SATS_PER_USD)); }
|
||
function updateTldPrice() {
|
||
const sats = usdToSats($("tld-usd").value.trim() || "0");
|
||
$("tld-price").innerHTML = `= <b>${sats.toLocaleString()}</b> sats · rate <b>${CHIPNET_SATS_PER_USD.toLocaleString()}</b> sats/USD (chipnet)`;
|
||
$("tld-submit").textContent = `Register (~${sats.toLocaleString()} sats)`;
|
||
}
|
||
const validateTldLabel = (l) => /^[a-z0-9-]{1,16}$/.test(l);
|
||
function updateTldButtons() { const ok = validateTldLabel($("tld-label").value.trim().toLowerCase()); $("tld-check").disabled = !ok || !wallet; $("tld-submit").disabled = !ok || !wallet; }
|
||
$("tld-label").addEventListener("input", () => { $("tld-label").value = $("tld-label").value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 16); updateTldButtons(); });
|
||
$("tld-usd").addEventListener("input", updateTldPrice);
|
||
$("tld-check").addEventListener("click", async () => {
|
||
const label = $("tld-label").value.trim(); const msg = $("tld-msg"); msg.textContent = "";
|
||
try {
|
||
const j = await (await fetch(TLD_API)).json();
|
||
const taken = (j.tlds || []).some((t) => (t.tld || t) === label);
|
||
msg.textContent = taken ? `❌ .${label} is already registered` : `✅ .${label} is available`;
|
||
msg.style.color = taken ? "var(--taken)" : "var(--ok)";
|
||
} catch (e) { msg.textContent = "could not check: " + (e.message || e); msg.style.color = "var(--warn)"; }
|
||
});
|
||
$("tld-submit").addEventListener("click", async () => {
|
||
if (!wallet) return;
|
||
const label = $("tld-label").value.trim(); if (!validateTldLabel(label)) return;
|
||
const sats = BigInt(usdToSats($("tld-usd").value.trim() || "0"));
|
||
const msg = $("tld-msg"); msg.textContent = "";
|
||
const log = $("tld-log"); log.style.display = "block"; log.innerHTML = "";
|
||
const push = (t) => { const d = document.createElement("div"); d.textContent = "› " + t; log.appendChild(d); log.scrollTop = log.scrollHeight; };
|
||
$("tld-submit").disabled = true; $("tld-submit").textContent = "Signing…";
|
||
let el = null;
|
||
try {
|
||
el = await BNS.connect(); push("connected to chipnet");
|
||
const serviceFee = { address: BNS.REGISTRAR?.serviceFee?.address ?? null, sats };
|
||
const args = { tld: label, records: {}, serviceFee, tldListUrl: "https://navigate.st/api/tlds", onProgress: (s) => push(s) };
|
||
const res = wallet.source === "wc"
|
||
? await BNS.registerTldWithExternalWallet(el, { session: wallet.session, ...args })
|
||
: await BNS.registerTldWithBuiltInWallet(el, { wallet, ...args });
|
||
push("broadcast: " + (res.txid || res.txId || "(no txid)"));
|
||
msg.style.color = "var(--ok)"; msg.textContent = `✅ .${label} minted. Category ${(res.category || "").slice(0, 16)}…`;
|
||
$("tld-submit").textContent = "Registered ✓";
|
||
setTimeout(() => loadHoldings(), 2500);
|
||
} catch (e) {
|
||
msg.style.color = "var(--taken)"; const raw = String(e.message || e);
|
||
msg.innerHTML = /wallet is empty/i.test(raw)
|
||
? `Failed: wallet is empty. Fund <code style="font-size:11.5px">${esc(wallet.address)}</code> with chipnet coins first — free from <a href="https://tbch.googol.cash/" target="_blank" rel="noopener">tbch.googol.cash</a>.`
|
||
: "Failed: " + esc(raw);
|
||
push("error: " + raw); $("tld-submit").disabled = false; updateTldPrice();
|
||
} finally { try { el?.close?.(); } catch {} }
|
||
});
|
||
updateTldPrice();
|
||
|
||
// ---------- settings ----------
|
||
function paintSettings() {
|
||
const S = window.siriusSession, P = window.siriusPin;
|
||
$("set-stay").checked = !!S?.isEnabled?.();
|
||
$("set-pinpay").checked = !!S?.requirePinForPayments?.();
|
||
const hasPin = !!P?.hasPin?.();
|
||
$("set-pinstate").textContent = hasPin ? "A PIN is set on this device." : "No PIN set.";
|
||
$("set-clearpin").disabled = !hasPin;
|
||
$("set-setpin").disabled = !wallet?.mnemonic;
|
||
}
|
||
$("set-stay").addEventListener("change", async (e) => {
|
||
const S = window.siriusSession; if (!S) return;
|
||
S.setEnabled(e.target.checked);
|
||
if (e.target.checked && wallet?.mnemonic) await S.remember(wallet.mnemonic, { accountPath: wallet.accountPath });
|
||
setMsg("set-msg", e.target.checked ? "This device stays signed in." : "You will be asked to unlock on your next visit.", "ok");
|
||
});
|
||
$("set-pinpay").addEventListener("change", (e) => { window.siriusSession?.setRequirePinForPayments(e.target.checked); setMsg("set-msg", e.target.checked ? "Payments will ask for your PIN. Set one below if you have none." : "Payments approve with one click.", "ok"); });
|
||
$("set-setpin").addEventListener("click", () => { $("pin-1").value = ""; $("pin-2").value = ""; setMsg("pin-msg", ""); $("pin-modal").classList.add("open"); $("pin-1").focus(); });
|
||
const closePin = () => $("pin-modal").classList.remove("open");
|
||
$("pin-close").addEventListener("click", closePin); $("pin-cancel").addEventListener("click", closePin);
|
||
$("pin-modal").addEventListener("click", (e) => { if (e.target === $("pin-modal")) closePin(); });
|
||
$("pin-save").addEventListener("click", async () => {
|
||
const a = $("pin-1").value, b = $("pin-2").value;
|
||
if (!/^\d{4,6}$/.test(a)) return setMsg("pin-msg", "PIN must be 4–6 digits", "err");
|
||
if (a !== b) return setMsg("pin-msg", "PINs do not match", "err");
|
||
try { await window.siriusPin.savePinBlob(wallet.mnemonic, a); closePin(); paintSettings(); setMsg("set-msg", "PIN saved.", "ok"); }
|
||
catch (e) { setMsg("pin-msg", e.message || String(e), "err"); }
|
||
});
|
||
$("set-clearpin").addEventListener("click", () => { window.siriusPin?.clear?.(); paintSettings(); setMsg("set-msg", "PIN removed.", "ok"); });
|