sirius/js/market.js

134 lines
8 KiB
JavaScript
Raw Normal View History

feat(bns): name marketplace and TLD-owner co-sign rule 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.
2026-09-17 04:04:22 +02:00
// 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=20260917market";
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 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 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));
rows.sort((a, b) => sort === "name" ? a.name.localeCompare(b.name) : sort === "new" ? Date.parse(b.created_at) - Date.parse(a.created_at) : Number(a.price_sats) - Number(b.price_sats));
$("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;
feat(bns): name marketplace and TLD-owner co-sign rule 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.
2026-09-17 04:04:22 +02:00
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 1,300 sat 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"><b>${fmtInt(l.price_sats)} sat</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 price = BigInt(selected.price_sats);
const check = BNS.verifyListing(selected, { category: selected.category });
$("buy-kv").innerHTML = [
["Price", `${fmtInt(price)} sat (≈ ${esc(usd(price))})`],
["Seller", esc(selected.seller)],
["You receive", "the name's certificate, to your token address"],
["Offer check", check.ok ? "seller signature valid" : "INVALID: " + esc(check.error)],
["Extra", "≈ 1,600 sat 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)`);
const built = BNS.completeSale({ listing: selected, buyerAddress: wallet.address, buyerTokenAddress: wallet.tokenAddress, utxos, records: selected.records || {}, addressToLockingBytecode: BNS.addressToLockingBytecode });
push(`built sale · price ${fmtInt(built.costs.priceSats)} sat · fee ${built.costs.chainFeeSats} sat`);
const 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();
})();