sirius/js/market.js
Silent Mode b9b7a4e5bd feat(portal): the name's settings show what its subdomains serve, and let you change it
The per-name tab was a DNS record table: eight record types, of which the
stack reads exactly one, and then only as a last resort when a name carries
no content record. Everything else was signed, stored and read by nobody —
furniture that looked like configuration. Meanwhile the thing a name owner
actually has, a folder tree that already answers on matching subdomains, was
not visible anywhere and could not be changed.

The tab now leads with what answers today: the apex, every folder in the
name's storage next to the subdomain it serves on, and which of those a rule
overrides. Below it, the rules — point a subdomain at a different folder,
redirect it, or switch it off.

Classic DNS keeps its table, behind a disclosure that says plainly what
those records do today and what they are waiting for. Nobody loses a record
they published; they just stop being the first thing a name owner is asked
to think about. The table gains a Host column, so records can belong to a
subdomain rather than only the apex.

Both halves are one signed manifest and one publish button — they always
were one file, and two buttons for it only ever invited a half-saved state.
Labels and values are separated in the generated rows so the words
translate and the owner's own folder and URL do not.

The signer accepted v1 manifests only, which would have rejected every
publish from this screen.
2026-09-28 01:21:47 +02:00

155 lines
10 KiB
JavaScript

// Marketplace page: list the gateway's active offers and complete a purchase.
//
// A listing is the seller's partially signed transaction (see
// Argus/src/lib/market-tx.js). Buying = verify it locally, append our own
// funding inputs and the outputs that give us the certificate and update
// the registry, sign our inputs, broadcast. The seller's signature only
// stays valid while their certificate UTXO is unspent, so the gateway prunes
// stale offers and the buyer's broadcast is the final arbiter.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260928hosts";
const API = "https://silentmode.st";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const fmtInt = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
const bch = (sats) => (window.siriusPricing?.formatBch || ((s) => (Number(s) / 1e8).toFixed(8).replace(/0+$/, "").replace(/\.$/, "") + " BCH"))(sats);
const bits = (sats) => (window.siriusPricing?.formatBits || ((s) => (Number(s) / 100).toLocaleString("en-US") + " bits"))(sats);
const usd = (sats) => { const r = window.siriusPricing?.CHIPNET_SATS_PER_USD || 250000; const v = Number(sats) / r; return (window.siriusPricing?.formatUsd || ((n) => "$" + n.toFixed(2)))(Math.round(v * 100) / 100); };
const fmtUsdC = (cents) => (window.siriusPricing?.formatUsd || ((n) => "$" + n.toFixed(2)))(Math.round(Number(cents)) / 100);
const satsForCents = (cents) => Math.round(Number(cents) / 100 * (window.siriusPricing?.CHIPNET_SATS_PER_USD || 400000));
const clampSats = (sats, l) => Math.min(Number(l.ceil_sats), Math.max(Number(l.floor_sats), sats));
const shortAddr = (a) => (a ? a.replace(/^[^:]+:/, "").slice(0, 8) + "…" + a.slice(-5) : "—");
window.siriusNetwork = BNS.NETWORK;
let listings = [];
let wallet = null;
let selected = null;
async function restoreWallet() {
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.NETWORK.prefix, s.accountPath || undefined);
window.siriusWallet = w;
return w;
} catch { return null; }
}
async function load() {
const status = $("status");
try {
const r = await fetch(`${API}/api/market`, { cache: "no-store" });
if (!r.ok) throw new Error("API " + r.status);
const j = await r.json();
listings = (j.listings || []).map((l) => ({ ...l, label: l.name.split(".")[0], tld: l.tld || l.name.split(".").pop() }));
render();
} catch (e) { status.className = "status err"; status.textContent = "Could not load listings: " + (e.message || e); }
}
function render() {
const q = ($("q").value || "").toLowerCase().trim();
const sort = $("sort").value;
const tldSel = $("tld");
const tlds = [...new Set(listings.map((l) => l.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 = listings.filter((l) => (!q || l.name.includes(q)) && (!tldSel.value || l.tld === tldSel.value));
const priceOf = (l) => (l.kind === "usd" ? clampSats(satsForCents(l.target_cents), l) : Number(l.price_sats));
rows.sort((a, b) => sort === "name" ? a.name.localeCompare(b.name) : sort === "new" ? Date.parse(b.created_at) - Date.parse(a.created_at) : priceOf(a) - priceOf(b));
$("count").textContent = `${rows.length} of ${listings.length}`;
const status = $("status");
document.querySelector(".toolbar").hidden = !listings.length;
if (!listings.length) {
status.hidden = true;
$("list").innerHTML = `<div class="empty big">
<div class="ico">🛒</div>
<h3>Nothing is for sale right now</h3>
<p>Names go on sale when their owners list them from the dashboard. Have one to sell, or want a fresh one instead?</p>
<div class="cta"><a class="btn acid" href="./portal.html#domains">Sell a name from your dashboard →</a><a class="btn ghost" href="./#search-input">Register a new name</a></div>
</div>`;
return;
}
status.hidden = false;
status.className = "status"; status.textContent = `${listings.length} name${listings.length === 1 ? "" : "s"} for sale. Prices are set by the sellers; you pay the price plus about 13 bits of chain dust and fee.`;
$("list").innerHTML = rows.length ? rows.map((l) => `
<div class="row">
<div class="n">${esc(l.label)}.<span class="tld">${esc(l.tld)}</span> ${wallet && l.seller === wallet.address ? '<span class="badge">yours</span>' : ""}</div>
<div class="price">${l.kind === "usd"
? `<b>${esc(fmtUsdC(l.target_cents))}</b><span>fixed in USD · today ≈ ${esc(bch(satsForCents(l.target_cents)))}</span>`
: `<b>${esc(bch(l.price_sats))}</b><span>≈ ${esc(usd(l.price_sats))}</span>`}</div>
<div><button class="btn acid small" data-buy="${esc(l.name)}" ${wallet && l.seller === wallet.address ? "disabled" : ""}>Buy →</button></div>
<div class="meta"><span>seller ${esc(shortAddr(l.seller))}</span><span>·</span><span>listed ${esc(new Date(l.created_at).toLocaleDateString())}</span><span>·</span><a href="${API}/bns/${encodeURIComponent(l.name)}/" target="_blank" rel="noopener">view site →</a></div>
</div>`).join("") : `<div class="empty">Nothing matches that filter.</div>`;
}
["q", "sort", "tld"].forEach((id) => $(id).addEventListener("input", render));
document.addEventListener("click", (e) => { const b = e.target.closest("[data-buy]"); if (b) openBuy(b.dataset.buy); });
// ---------- buy ----------
function setMsg(t, cls = "") { $("buy-msg").textContent = t; $("buy-msg").className = "msg " + cls; }
function push(t) { const log = $("buy-steps"); log.className = "steps on"; const d = document.createElement("div"); d.textContent = t; log.appendChild(d); log.scrollTop = 1e6; }
function openBuy(name) {
selected = listings.find((l) => l.name === name); if (!selected) return;
$("buy-name").textContent = name;
const isUsd = selected.kind === "usd";
const price = BigInt(isUsd ? clampSats(satsForCents(selected.target_cents), selected) : selected.price_sats);
const check = isUsd ? BNS.verifyUsdListing(selected) : BNS.verifyListing(selected, { category: selected.category });
$("buy-kv").innerHTML = [
isUsd ? ["Price", `${esc(fmtUsdC(selected.target_cents))} fixed · about ${esc(bch(price))} at today's rate (the exact amount is set by the oracle quote when you buy, between ${esc(bch(selected.floor_sats))} and ${esc(bch(selected.ceil_sats))})`]
: ["Price", `${esc(bch(price))} (≈ ${esc(usd(price))})`],
["Seller", esc(selected.seller)],
["You receive", "the name's certificate, to your token address"],
["Offer check", check.ok ? (isUsd ? "contract terms verified" : "seller signature valid") : "INVALID: " + esc(check.error)],
["Extra", "≈ 16 bits dust + fee"],
].map(([k, v]) => `<div class="k">${k}</div><div class="v">${v}</div>`).join("");
$("buy-steps").className = "steps"; $("buy-steps").innerHTML = ""; setMsg("");
const canBuy = !!wallet && check.ok && wallet.address !== selected.seller;
$("buy-go").disabled = !canBuy; $("buy-go").textContent = "Buy — sign & broadcast";
$("buy-signin").hidden = !!wallet;
if (!wallet) setMsg("Sign in on the dashboard first — the purchase is signed with your wallet in this browser.");
$("buy").classList.add("open");
}
const closeBuy = () => $("buy").classList.remove("open");
$("buy-close").addEventListener("click", closeBuy); $("buy-cancel").addEventListener("click", closeBuy);
$("buy").addEventListener("click", (e) => { if (e.target === $("buy")) closeBuy(); });
$("buy-go").addEventListener("click", async () => {
if (!selected || !wallet) return;
const btn = $("buy-go"); btn.disabled = true; btn.textContent = "Buying…"; setMsg("");
let el = null;
try {
el = await BNS.connect(); push(`connected to ${BNS.NETWORK.label}`);
const utxos = await BNS.getUtxosForAddresses(el, wallet.watchedAddresses);
push(`wallet has ${utxos.filter((u) => !u.token).length} spendable coin(s)`);
let built, signed;
if (selected.kind === "usd") {
const q = await (await fetch(`${API}/api/price/oracle?txid=${encodeURIComponent(selected.outpoint.txid)}`, { cache: "no-store" })).json();
if (!q.message) throw new Error(q.error || "no oracle quote");
push(`oracle quote: 1 BCH = $${(q.price_cents / 100).toFixed(2)} (median of ${q.median_of} exchanges)`);
built = BNS.buildUsdBuyTx({ listing: selected, quote: q, buyerAddress: wallet.address, buyerTokenAddress: wallet.tokenAddress, utxos, records: selected.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode });
push(`you pay ${bch(built.priceSats)} · fee ${bits(built.fee)}`);
signed = BNS.signInputs(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h), { skip: [built.covenantInputIndex] });
} else {
built = BNS.completeSale({ listing: selected, buyerAddress: wallet.address, buyerTokenAddress: wallet.tokenAddress, utxos, records: selected.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode });
push(`built sale · price ${bch(built.costs.priceSats)} · fee ${bits(built.costs.chainFeeSats)}`);
signed = BNS.signInputs(built.transaction, built.sourceOutputs, (h) => wallet.keyFor(h), { skip: [built.sellerInputIndex] });
}
push("signed your inputs (the seller's signature is already in place)");
const txid = await BNS.broadcast(el, signed.hex);
push("broadcast: " + txid);
setMsg(`Done. ${selected.name} is yours — it appears in your dashboard within a minute.`, "ok");
btn.textContent = "Bought ✓";
setTimeout(load, 3000);
} catch (e) {
const raw = String(e.message || e);
setMsg(/not enough funds/i.test(raw) ? `Not enough coins: ${raw}. Fund ${wallet.address} first.` : "Failed: " + raw, "err");
push("error: " + raw); btn.disabled = false; btn.textContent = "Buy — sign & broadcast";
} finally { try { el?.close?.(); } catch {} }
});
(async function boot() {
wallet = await restoreWallet();
await load();
})();
window.addEventListener("sirius:price", () => { if (listings.length) render(); });