sirius/js/pricing.js
Local Dev b5eea9422b feat(sirius-x): prices in BCH, amounts in bits, live BCH/USD rate from several exchanges
Every dollar figure on the site came from a hard-coded 250,000 sats per
dollar, which implies $400 per BCH; the market is near $250, so name
prices, TLD fees and sale listings were shown about 60% too cheap in
dollars. The gateway now serves /api/price: the median of Coinbase,
Kraken, Bitstamp, Binance and CoinGecko public tickers, no keys, cached
60 s, last good answer kept if every source fails. The site reads it
first, queries the same tickers itself if the gateway is unreachable,
remembers the last rate per browser, and only falls back to a constant
for the very first paint. Pages repaint when the rate arrives, and the
rate line says where it came from and how old it is.

Units: satoshi no longer appear anywhere. Sale prices, the seller's
input and the buy dialog are in BCH with the dollar figure beside
them; balances, fees and dust are in bits (1 bit = 100 satoshi).
2026-09-20 18:18:43 +02:00

296 lines
14 KiB
JavaScript

// Sirius.X pricing model — deterministic per-label cost so users can predict
// what a name or TLD will cost before they click Register. Public tiers only;
// operator-level discounts and coupon overrides land in a future revision
// (the plan is oracle-fed rates + coupon-code redemption at mint time).
//
// Two layers decide what a NAME costs:
// 1. The TLD owner's on-chain policy (`price`, `hidden`, `policy`) — read
// from the gateway's /api/tlds snapshot of the TLD beacon. A TLD owner
// who sets `price: 7` sells every name under that TLD for $7, flat.
// 2. Otherwise the length tiers below.
//
// Exposed as window.siriusPricing so both the register-flow modal and the
// inline search widgets on landing/tld.html use one source of truth.
(() => {
// Chipnet placeholder rate: mainnet swaps this for a real BCH/USD oracle.
// The numbers keep the demo transactions in the "few chipnet coins" range
// so a single faucet visit covers a name or a TLD.
// BCH/USD rate. Live: the gateway's /api/price (median of several exchanges)
// or, if that is unreachable, the same public tickers straight from the
// browser; the last good rate is remembered per browser; the constant is
// only the very first paint before anything answers. Chipnet coins have no
// market, so the mainnet rate is what makes "$7 per name" mean something.
const FALLBACK_SATS_PER_USD = 400_000;
let SATS_PER_USD = FALLBACK_SATS_PER_USD;
let priceMeta = { usd: null, at: 0, sources: 0, origin: "fallback" };
try { const c = JSON.parse(localStorage.getItem("siriusPrice") || "null"); if (c && c.usd > 0) { SATS_PER_USD = Math.round(1e8 / c.usd); priceMeta = { ...c, origin: "cached" }; } } catch {}
const usdToSats = (usd) => BigInt(Math.max(0, Math.round(Number(usd) * SATS_PER_USD)));
const PRICE_API = "https://silentmode.st/api/price";
const BROWSER_SOURCES = [
["coinbase", "https://api.coinbase.com/v2/prices/BCH-USD/spot", (j) => Number(j?.data?.amount)],
["kraken", "https://api.kraken.com/0/public/Ticker?pair=BCHUSD", (j) => Number(Object.values(j?.result || {})[0]?.c?.[0])],
["binance", "https://api.binance.com/api/v3/ticker/price?symbol=BCHUSDT", (j) => Number(j?.price)],
["coingecko", "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin-cash&vs_currencies=usd", (j) => Number(j?.["bitcoin-cash"]?.usd)],
];
function applyPrice(usd, sources, origin) {
if (!(usd > 0)) return false;
SATS_PER_USD = Math.round(1e8 / usd);
priceMeta = { usd, at: Date.now(), sources, origin };
try { localStorage.setItem("siriusPrice", JSON.stringify(priceMeta)); } catch {}
try { window.dispatchEvent(new CustomEvent("sirius:price", { detail: priceMeta })); } catch {}
return true;
}
let priceLoad = null;
function loadPrice({ force = false } = {}) {
if (priceLoad && !force) return priceLoad;
priceLoad = (async () => {
try {
const r = await fetch(PRICE_API, { cache: "no-store", signal: AbortSignal.timeout(6000) });
const j = await r.json();
if (j && j.usd > 0 && !j.stale) return applyPrice(j.usd, j.median_of || 0, "gateway");
} catch {}
const got = (await Promise.allSettled(BROWSER_SOURCES.map(async ([name, url, pick]) => {
const r = await fetch(url, { signal: AbortSignal.timeout(6000) }); if (!r.ok) throw new Error(String(r.status));
const v = pick(await r.json()); if (!(v > 0)) throw new Error("no price"); return v;
}))).filter((x) => x.status === "fulfilled").map((x) => x.value).sort((a, b) => a - b);
if (got.length) { const m = Math.floor(got.length / 2); return applyPrice(got.length % 2 ? got[m] : (got[m - 1] + got[m]) / 2, got.length, "exchanges"); }
return false;
})();
return priceLoad;
}
const priceInfo = () => priceMeta.usd ? `${priceMeta.origin === "gateway" || priceMeta.origin === "exchanges" ? `median of ${priceMeta.sources} exchange${priceMeta.sources === 1 ? "" : "s"}` : priceMeta.origin === "cached" ? "last known rate" : "fallback rate"}${priceMeta.at ? ", " + Math.max(0, Math.round((Date.now() - priceMeta.at) / 60000)) + " min ago" : ""}` : "fallback rate";
loadPrice();
setInterval(() => loadPrice({ force: true }), 5 * 60 * 1000);
const cleanLabel = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9-]/g, "");
// ---------- TLD owner policies ----------
// tld -> { price, hidden, frozen, policy, owner, records }
// Filled from /api/tlds (see loadTldPolicies). Unknown TLDs fall through
// to tier pricing and are treated as sellable, so a gateway blip never
// blocks a purchase; the chain is the authority at mint time anyway.
// include_hidden=1 so the policy map knows about switched-off (private)
// TLDs too — their owners register names under them from the portal, and
// the register flow must be able to price and gate them. The public
// listing (visibleTlds) still drops anything hidden.
const TLD_API = "https://navigate.st/api/tlds?include_hidden=1";
const TLD_POLICIES = new Map();
let tldList = null; // raw /api/tlds rows, once loaded (hidden ones excluded)
let tldLoad = null; // memoised in-flight fetch
function normalisePolicy(row) {
const rec = row && row.records && typeof row.records === "object" ? row.records : {};
const priceRaw = row && row.price_usd != null ? row.price_usd : rec.price;
const price = typeof priceRaw === "number" && Number.isFinite(priceRaw) && priceRaw >= 0 ? priceRaw : null;
const hidden = !!(row && row.hidden) || rec.hidden === 1 || rec.hidden === true;
const policy = typeof rec.policy === "string" ? rec.policy : "open";
return {
tld: row.tld,
price,
hidden,
hiddenBy: row && row.hidden_by ? row.hidden_by : (hidden ? "owner" : null),
frozen: policy === "frozen",
// cosign: anyone may register, but the TLD owner must co-sign the
// registration (indexers reject a REG under a cosign/hidden TLD that
// does not carry the TLD certificate). hidden implies the same rule
// on chain; in the UI hidden is owner-only, cosign is request-based.
cosign: policy === "cosign",
policy,
category: row && row.category ? row.category : null,
owner: row && row.owner ? row.owner : null,
records: rec,
};
}
function setTldPolicies(rows) {
TLD_POLICIES.clear();
for (const r of rows || []) {
const row = typeof r === "string" ? { tld: r } : r;
const tld = cleanLabel(row && row.tld);
if (!tld) continue;
TLD_POLICIES.set(tld, normalisePolicy({ ...row, tld }));
}
}
// Fetch the TLD list once per page. Resolves to the array of visible TLD
// rows (or null on failure) — never rejects, callers just fall back.
function loadTldPolicies({ url = TLD_API, force = false } = {}) {
if (tldLoad && !force) return tldLoad;
tldLoad = (async () => {
try {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error("api " + r.status);
const j = await r.json();
const rows = Array.isArray(j.tlds) ? j.tlds : [];
setTldPolicies(rows);
// Only a beacon-sourced list is a sellable list. A gateway still
// serving the legacy `tlds.bch`-derived union includes every suffix
// that ever appeared on a name (".de", ".silentmode", …), which is
// not what the registry sells — keep the page's own fallback then.
tldList = j.source === "TLD_BEACON" ? rows.filter((t) => !t.hidden) : null;
return tldList;
} catch {
return null;
}
})();
return tldLoad;
}
// Resolves once the list is loaded, or after `ms` — search UIs await this
// so the first paint already shows owner prices without ever hanging.
function tldReady(ms = 2500) {
return Promise.race([
loadTldPolicies(),
new Promise((res) => setTimeout(() => res(null), ms)),
]);
}
function tldPolicy(tld) {
return TLD_POLICIES.get(cleanLabel(tld)) || null;
}
// Does one of `addresses` (the signed-in wallet's) hold this TLD's NFT?
// `owner` comes from the gateway's beacon snapshot (mint output, updated
// on every TUPD carrier), so a wallet that owns the TLD matches here.
function isTldOwner(tld, addresses) {
const p = tldPolicy(tld);
if (!p || !p.owner || !addresses) return false;
const list = Array.isArray(addresses) ? addresses : [addresses];
return list.some((a) => typeof a === "string" && a === p.owner);
}
// Can names be sold under this TLD right now?
// hidden → owner switched the TLD off: private TLD. The public cannot
// buy, but the OWNER still can — pass their wallet addresses
// and a hidden TLD they hold answers ok with owner: true.
// frozen → owner set policy "frozen" (no new registrations, owner included)
function tldSellable(tld, addresses) {
const p = tldPolicy(tld);
if (!p) return { ok: true, reason: null, owner: false };
const owner = isTldOwner(tld, addresses);
if (p.frozen) return { ok: false, reason: "frozen", policy: p, owner };
if (p.hidden && !owner) return { ok: false, reason: "hidden", policy: p, owner };
// Sellable, but the registration needs the owner's co-signature: the
// flow builds the request and parks it in the gateway's approval queue.
if (p.cosign && !owner) return { ok: true, reason: "cosign", policy: p, owner };
return { ok: true, reason: null, policy: p, owner };
}
// Visible TLD labels in listing order, or null if no beacon-sourced list
// is available (callers keep their own fallback list then).
function visibleTlds() {
return tldList ? tldList.map((t) => t.tld) : null;
}
// ---------- name pricing ----------
// Owner-set flat price first; else LENGTH-first tiers, then downshift for
// "less brandable" shapes (all-digits, hyphenated). Kept as multiplicative
// modifiers so tiers remain the primary story and mods just soften an
// edge case.
function priceForName(label, tld) {
const s = cleanLabel(label);
if (!s) return { usd: 0, tier: "invalid" };
const p = tld ? tldPolicy(tld) : null;
if (p && p.price != null) {
return { usd: p.price, tier: "tld-set", sats: usdToSats(p.price), tld: p.tld, ownerSet: true };
}
let usd, tier;
if (s.length === 1) { usd = 5.00; tier = "premium-1"; }
else if (s.length === 2) { usd = 3.00; tier = "short-2"; }
else if (s.length === 3) { usd = 2.00; tier = "short-3"; }
else if (s.length <= 5) { usd = 1.00; tier = "standard"; }
else if (s.length <= 7) { usd = 0.50; tier = "long"; }
else if (s.length <= 16) { usd = 0.25; tier = "extended"; }
else { usd = 0.10; tier = "very-long"; }
// Modifiers.
if (/^\d+$/.test(s)) { usd = round2(usd * 0.5); tier += "+digits"; }
if (s.includes("-")) { usd = round2(usd * 0.7); tier += "+hyphen"; }
return { usd, tier, sats: usdToSats(usd) };
}
// Per-label overrides — TLDs the operator wants priced outside the tiers.
// Cheap namespaces (`.test`, `.dev`, `.local`) exist for experimentation,
// school assignments and demo throwaways: length-tier pricing prices them
// like premium TLDs, which isn't the point. Add labels here to opt them
// out of the tier and (optionally) mark them renewal-free.
//
// Shape: label -> { usd, tier, noRenewal? }
const SPECIAL_TLDS = {
"test": { usd: 0.01, tier: "sandbox", noRenewal: true },
};
function priceForTld(label) {
const s = cleanLabel(label);
if (!s) return { usd: 0, tier: "invalid" };
if (Object.prototype.hasOwnProperty.call(SPECIAL_TLDS, s)) {
const o = SPECIAL_TLDS[s];
return { usd: o.usd, tier: o.tier, sats: usdToSats(o.usd), noRenewal: !!o.noRenewal };
}
let usd, tier;
if (s.length === 1) { usd = 500.00; tier = "premium-1"; }
else if (s.length === 2) { usd = 250.00; tier = "short-2"; }
else if (s.length === 3) { usd = 150.00; tier = "short-3"; }
else if (s.length <= 6) { usd = 100.00; tier = "standard"; }
else if (s.length <= 10) { usd = 50.00; tier = "long"; }
else { usd = 25.00; tier = "extended"; }
return { usd, tier, sats: usdToSats(usd) };
}
// Rounded USD → display string. Under $1 shows cents ($0.25); $1 and up
// shows whole dollars ($5) unless there's a fractional part.
function formatUsd(usd) {
const n = Number(usd || 0);
if (n >= 1 && n === Math.floor(n)) return `$${n}`;
return `$${n.toFixed(2)}`;
}
// Money is shown in BCH (prices) and bits (fees, balances); 1 bit = 100
// satoshi, 1 BCH = 1,000,000 bits. Satoshi never appears in the UI.
const group = (s) => String(s).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
function formatBch(sats) {
const n = BigInt(sats || 0n); const neg = n < 0n; const a = neg ? -n : n;
const whole = a / 100000000n, frac = (a % 100000000n).toString().padStart(8, "0").replace(/0+$/, "");
return `${neg ? "-" : ""}${group(whole)}${frac ? "." + frac : ""} BCH`;
}
function formatBits(sats) {
const n = BigInt(sats || 0n); const neg = n < 0n; const a = neg ? -n : n;
const whole = a / 100n, frac = (a % 100n).toString().padStart(2, "0").replace(/0+$/, "");
return `${neg ? "-" : ""}${group(whole)}${frac ? "." + frac : ""} bits`;
}
const formatSats = formatBits; // legacy name, same unit policy
const bchToSats = (bch) => BigInt(Math.max(0, Math.round(Number(String(bch).replace(/[^\d.]/g, "")) * 1e8)));
const satsToBch = (sats) => Number(BigInt(sats || 0n)) / 1e8;
function round2(n) { return Math.round(Number(n) * 100) / 100; }
window.siriusPricing = {
priceForName,
priceForTld,
formatUsd,
formatSats,
formatBch,
formatBits,
bchToSats,
satsToBch,
usdToSats,
// live rate (getters: callers that read the old constant see the current value)
get CHIPNET_SATS_PER_USD() { return SATS_PER_USD; },
get SATS_PER_USD() { return SATS_PER_USD; },
get bchUsd() { return priceMeta.usd || 1e8 / SATS_PER_USD; },
priceInfo,
loadPrice,
priceReady: () => priceLoad || loadPrice(),
// TLD owner policy layer
loadTldPolicies,
setTldPolicies,
tldReady,
tldPolicy,
tldSellable,
isTldOwner,
visibleTlds,
TLD_API,
};
// Warm the policy cache as soon as the script lands; every consumer
// awaits tldReady() before it paints a price.
loadTldPolicies();
})();