sirius/js/pricing.js
Local Dev 55ddee18ec feat(sirius-x): TLD owner panel — owner-set price, on/off switch, private TLDs
A TLD owner needed to run their namespace without the operator: set what
every name under the TLD sells for, take the TLD off the public registry
for a while, and still register names under it themselves.

Both settings live on chain in the TLD's TUPD records (`price`, `hidden`)
because changes are rare and every client already walks the TLD beacon.
The gateway's /api/tlds now carries records, owner, price_usd and
hidden_by per TLD; pricing.js quotes the owner price ahead of the length
tiers and only trusts a TLD_BEACON-sourced list; the register flow refuses
hidden and frozen TLDs for the public but lets the owner through at the
platform share only (the 90% owner cut would be paid to themselves). The
portal's TLDs tab loads real holdings, shows price and on/off state, and
gives each TLD a one-click switch, a price/policy editor and an inline
"register a name under .tld" form. Docs and the design table describe the
two new records.
2026-09-16 21:28:18 +02:00

221 lines
9.3 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.
const CHIPNET_SATS_PER_USD = 250_000;
const usdToSats = (usd) => BigInt(Math.max(0, Math.round(Number(usd) * CHIPNET_SATS_PER_USD)));
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",
policy,
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 };
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)}`;
}
function formatSats(sats) {
return `${BigInt(sats || 0n).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")} sat`;
}
function round2(n) { return Math.round(Number(n) * 100) / 100; }
window.siriusPricing = {
priceForName,
priceForTld,
formatUsd,
formatSats,
usdToSats,
CHIPNET_SATS_PER_USD,
// 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();
})();