sirius/js/market.js
Local Dev f2c3480d43 sites: scroll-spy tracks scroll position, fires the last section on bottom
The IntersectionObserver-based spy only fired when a section entered a
narrow band 40–45% down the viewport. Short sections at the tail of a
page — Operator key and Roadmap on theseus.x/extensions, whatever came
last on the other pages — never made it into that band because the
page runs out of scroll before their top crosses 40%, so their sidebar
links stayed dim no matter how far the reader scrolled.

Rewrote the spy across every page with a sidebar (theseus.x home /
plug-ins / extensions and sirius.x home / tld / theseus / docs / admin
/ brand): it listens to window scroll, picks the last section whose
top has crossed 35% of the viewport, and — when the window is scrolled
to within 4px of the bottom — snaps to the final section regardless of
where its top is. Every link in the rail can now activate.

theseus.x/extensions also had the spy IIFE bundled with the manifest
loader in one <script> block near the middle of the page, so at run
time the parser hadn't yet reached the last three sections and only
Bundled/Community/Build were ever registered. Moved the spy to its own
<script> just before </body> so it sees every section that ships.
2026-09-20 19:20:30 +02:00

154 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=20260920usd";
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) : "—");
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.CHIPNET_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 chipnet");
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(); });