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.
This commit is contained in:
Local Dev 2026-09-16 21:28:18 +02:00
parent 7635ed7721
commit 55ddee18ec
7 changed files with 1115 additions and 54 deletions

View file

@ -0,0 +1,154 @@
# Signed records manifest (BNS records v1)
**Status:** shipped v1 — gateway `GET/POST /api/records/<name>` and
verified `GET /api/dns/<name>` live on `silentmode.st/navigate.st`;
owner-tracking in the indexer live; portal DNS editor + wallet-side
`signMessage` + `signRecordsManifest` shipped. Native DNS server
(UDP :53 → BCDN name → verified records) is a v2 follow-up.
**Related:** `DESIGN-tld-registry.md`, `PROTOCOL.md`, public explainer at
`silentmode.st/sirius-x/docs/#signed-records`. Mirrored to
`site-sirius-x/docs/DESIGN-signed-records-manifest.md` so it rides the
next push-split to the sirius forge.
## Problem
Every records edit today is a chain UPD:
- costs ~1,300 sat + service fee
- caps records at ~150 bytes of payload
- rules out DNS-style records (A/AAAA/MX/TXT/CNAME/NS) at any useful scale
- makes TXT-verification churn (ACME, DKIM, DMARC) actively expensive
We want records to be:
- **free to edit** — no chain tx per change
- **unlimited size** — accommodate whole DNS record sets
- **trustless** — no operator can substitute their own records for the owner's
- **backwards compatible** — existing on-chain `h` / `s3` / `p` / `u` records keep working
## Design
The chain says *who owns the name*. A signed manifest says *what the owner's
records are*. Resolvers verify both.
### The manifest — `_records.json`
Lives at the Sia bucket the name's on-chain `records.s3` already points at,
under the fixed filename `_records.json`. Shape:
```json
{
"v": 1,
"name": "bitcoin.cash",
"seq": 42,
"updated_at": "2026-09-12T00:00:00Z",
"dns": {
"A": ["1.2.3.4"],
"AAAA": ["2001:db8::1"],
"MX": [{"pref": 10, "host": "mail.example.com"}],
"TXT": ["v=spf1 include:_spf.silentmode.st -all"],
"CNAME": null,
"NS": []
},
"meta": { "note": "optional free-form" },
"sig": "H3n… (BCH message signature, base64)"
}
```
**Rules:**
- `v`: schema version, currently `1`.
- `name`: the fully-qualified name the manifest belongs to. Resolver rejects
a manifest whose `name` field doesn't match the URL it was fetched from.
- `seq`: monotonic integer. Resolvers cache the highest seen; blobs with an
older or equal `seq` are rejected as replays.
- `updated_at`: ISO-8601 UTC. Human-readable only — verification is on `seq`.
- `dns.*`: DNS record slots. `null` or `[]` means "not set". `A`/`AAAA`/`TXT`
are string arrays. `MX` is objects `{pref, host}`. `NS` is a string array
of host names. Additional slots (`CAA`, `SRV`) reserved for v2.
- `meta`: unstructured; ignored by DNS resolvers, useful for portal UI.
- `sig`: BCH-style message signature (same format `libauth.signMessage`
produces) over the canonical bytes of the envelope (see below).
### Canonicalisation and signing
The signable bytes are the JSON serialisation of the manifest **without the
`sig` field**, with:
- keys sorted lexicographically at every level
- no insignificant whitespace (`JSON.stringify` with no `space` arg)
- `\u`-escape every non-ASCII character
- `null` fields OMITTED (`"CNAME": null` becomes absent)
The signature is `sign(sha256(canonical_bytes))` with the wallet key whose
address currently holds the name's NFT certificate.
Verification, at the resolver:
1. Read the name's chain record. Follow `records.s3` to the Sia bucket.
2. Fetch `_records.json` from that bucket.
3. Extract `sig`, recompute `sha256(canonical_bytes)` over the rest.
4. Recover the signing pubkey → derive the CashAddress → compare to the
current NFT owner address from the chain.
5. Compare `seq` to the cached last-seen `seq` for this name. Reject if not
strictly greater.
6. If everything matches: cache the new `seq`, apply the DNS records.
Any step failing means "no records" — the resolver falls back to whatever
was already on the chain (`h`, `s3` for content, etc.).
### Endpoints (gateway)
**GET `/api/records/<name>`** — read-through of the signed manifest.
Returns 200 with the JSON if present, 404 if missing, 5xx on Sia errors.
Cacheable for a minute. Anyone can call this; it's public.
**POST `/api/records/<name>`** — write. Body IS the manifest JSON.
The gateway:
1. Verifies the signature matches the current on-chain NFT owner.
2. Verifies `seq` is strictly greater than the last-seen `seq` (from
`GET`ing the current manifest if any).
3. Uploads the JSON to the name's Sia bucket at `_records.json`.
4. Returns 200 with `{seq, bytes, sia_key}`.
Rate-limit: 1 write / 5 s per name. Refuses if the on-chain record has no
`s3` pointer yet — the name has no storage location, no place to write.
### Precedence and interaction with existing records
The signed manifest **complements** the on-chain records — it doesn't replace
them:
- **Content** (`h`, `s3`, `p`, `u`, `ip`) still comes from chain.
- **DNS records** come from the manifest — chain has never carried them.
A future extension (v2) could allow the manifest to override `h`/`s3`/etc.
for owners who want everything off-chain. Not shipped in v1 because the
existing content model already works.
### Threat model
- **Operator tampers with the manifest.** Signature verification catches it —
the resolver rejects and falls back to on-chain records.
- **Replay of an old manifest.** `seq` monotonicity catches it — resolvers
keep the highest-seen `seq`.
- **Owner's key compromised.** Same as chain-side compromise: attacker
controls the NFT and can sign whatever. Move the name to a new key.
- **Sia object deleted.** The name still resolves via chain records; DNS
records disappear until the manifest is republished.
- **Gateway hostility.** The gateway signs nothing itself — it only relays
and enforces `seq` monotonicity. A hostile gateway can refuse to accept
writes but cannot forge them; users can bypass it entirely by writing
directly to Sia with their own credentials (v2 flow).
### What ships in v1
- The spec (this doc).
- Gateway `/api/records/<name>` (GET + POST) with signature verification and
Sia read-through.
- Portal DNS editor that composes a manifest, asks the wallet to sign, and
POSTs to the gateway.
- Resolver support in Theseus + Ariadne staged behind a flag until the
portal-side has been in production for a week.
### What ships in v2
- Direct-to-Sia write flow for users who hold their own S3 credentials.
- Owner-controlled manifest that overrides `h`/`s3`/etc. entirely.
- CAA/SRV/HTTPS records.
- Federated resolver caches for `seq` (mitigates the case where two writes
race and each thinks its `seq` is winning).

View file

@ -120,6 +120,7 @@
<a href="#tld-registry">TLD registry</a>
<a href="#register-name">Register a name</a>
<a href="#records">Records &amp; hosting</a>
<a href="#signed-records">Signed off-chain records</a>
<a href="#host">Host your name</a>
<a href="#resolver">Resolver / gateway</a>
<a href="#pricing">Pricing (mainnet)</a>
@ -193,6 +194,89 @@
(Host-header semantics). Full rule is in <code>Argus/src/lib/record-picker.js</code>.</p>
</section>
<section id="signed-records">
<h2>Free edits — signed off-chain records</h2>
<p class="lede">The on-chain records above (<code>h</code>, <code>s3</code>, <code>ip</code>, <code>u</code>,
<code>tls</code>, <code>np</code>, <code>el</code>) cost a chain transaction to change and are capped
at 200 bytes of OP_RETURN. For DNS-style records (A / AAAA / MX / TXT / CNAME / NS) and any records
you edit often, Sirius.X uses a companion mechanism: a <b>signed off-chain manifest</b> that costs
nothing per change and has no size cap.</p>
<p class="lede" style="margin-top:1rem"><b>The chain says who owns the name. A signed blob says what
the records are. Resolvers verify both.</b> No third party is trusted for records: the manifest is
signed by the same key that holds the on-chain NFT certificate, so an operator (or Sia farmer, or
anyone else in the middle) can't rewrite records without breaking the signature.</p>
<table class="pricing" style="margin-top:1.2rem">
<thead><tr><th>Concern</th><th>Where it lives</th><th>Cost per change</th></tr></thead>
<tbody>
<tr><td>Who owns the name</td><td>Bitcoin Cash chain (NFT certificate)</td><td>1 tx to transfer</td></tr>
<tr><td>Where the site lives</td><td>Chain record: <code>s3</code>, <code>ip</code>, <code>u</code>, <code>h</code></td><td>1 tx per change</td></tr>
<tr><td>DNS records, meta, TXT churn, etc.</td><td>Sia at <code>&lt;s3-bucket&gt;/_records.json</code></td><td><b style="color:var(--acid)">free</b></td></tr>
</tbody>
</table>
<h3 style="margin-top:1.4rem">How the manifest is verified</h3>
<ol class="steps" style="counter-reset:s">
<li><b>Resolver reads</b> the name's chain record, follows <code>records.s3</code> to the Sia bucket,
fetches <code>_records.json</code>.</li>
<li><b>Signature check</b> — recovers the signing pubkey from the manifest's <code>sig</code> field,
derives the CashAddress, compares to the current NFT-holder address from the chain. Mismatch = reject.</li>
<li><b>Replay check</b> — the manifest carries a monotonic <code>seq</code> number. Resolvers cache the
highest seen and reject anything less-or-equal. An old signed blob can't be re-served.</li>
<li><b>Fall-back</b> — if the manifest is missing, invalid, or its signature doesn't match, the resolver
silently falls back to the on-chain records. Signed records never override a name; they extend it.</li>
</ol>
<h3 style="margin-top:1.4rem">The manifest shape</h3>
<pre style="background:#0e131b;border:1px solid var(--line);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:ui-monospace,monospace;font-size:12.5px;line-height:1.6;color:var(--ink);white-space:pre;margin-top:.6rem">{
"v": 1,
"name": "bitcoin.cash",
"seq": 42,
"updated_at": "2026-09-15T00:00:00Z",
"dns": {
"A": ["1.2.3.4"],
"AAAA": ["2001:db8::1"],
"MX": [{"pref": 10, "host": "mail.example.com"}],
"TXT": ["v=spf1 include:_spf.silentmode.st -all"],
"CNAME": null,
"NS": []
},
"sig": "&lt;BCH message signature over sha256(canonical bytes)&gt;"
}</pre>
<p class="lede" style="margin-top:.8rem">Canonicalisation: sorted keys, no whitespace, <code>null</code>
fields omitted. The signable bytes are the manifest with the <code>sig</code> field removed. Full
spec: <a href="./DESIGN-signed-records-manifest.md"><code>DESIGN-signed-records-manifest.md</code></a>
— signable byte order, seq-monotonicity rules, threat model, v2 roadmap.</p>
<h3 style="margin-top:1.4rem">Endpoints</h3>
<table class="pricing">
<thead><tr><th>Method</th><th>Path</th><th>Effect</th></tr></thead>
<tbody>
<tr><td><b>GET</b></td><td><code>/api/records/&lt;name&gt;</code></td><td>Fetches the raw signed manifest from the name's Sia bucket. Public, cacheable.</td></tr>
<tr><td><b>POST</b></td><td><code>/api/records/&lt;name&gt;</code></td><td>Accepts a signed manifest, verifies against the on-chain owner + seq monotonicity, uploads to Sia.</td></tr>
<tr><td><b>GET</b></td><td><code>/api/dns/&lt;name&gt;</code></td><td><b>Resolver read.</b> Fetches the manifest, verifies the signature against the on-chain owner and rejects rollback of the seq, then returns <em>only</em> the verified <code>dns</code> block. Callers can trust the response without doing their own signature or chain lookups. 30-second cache hint.</td></tr>
</tbody>
</table>
<p class="lede" style="margin-top:.8rem">The distinction between <code>/api/records/&lt;name&gt;</code>
and <code>/api/dns/&lt;name&gt;</code> is deliberate: the former is transparent (raw manifest as
uploaded, including the signature so clients can re-verify), the latter is opinionated (only the
verified DNS records, with the gateway having done the sig + seq work). Bridges and shims should
use <code>/api/dns/</code>; wallets and tools that want to see the full signed blob should use
<code>/api/records/</code>.</p>
<p class="lede" style="margin-top:.8rem">A name needs <code>records.s3</code> set on chain first —
without a storage pointer, the manifest has nowhere to live. Portal and CLI both refuse to
publish a manifest until <code>s3</code> is present.</p>
<div class="note" style="margin-top:1.2rem">
<b>Threat model at a glance.</b> Operator tampers → sig check fails, resolver falls back to chain
records. Replay of an old blob → seq check fails. Owner's key compromised → same as chain-side
compromise; move the name to a new key. Sia object deleted → name still resolves via chain
records, DNS records disappear until republished. Hostile gateway can refuse writes but cannot
forge them; users can PUT directly to Sia with their own credentials to bypass.
</div>
</section>
<section id="host">
<h2>Host your name</h2>
<p class="lede">The end-to-end recipe for putting a static site under a BCNR name. Chipnet
@ -358,6 +442,7 @@ process.exit(0);"</pre>
owner collects a share (<code>fee_bps</code>, default 5%, cap 50%). Chain-enforced when
the TLD's policy is <code>covenant</code>; honour-system otherwise. Name registration is
yearly and priced separately.</p>
<p class="lede" style="margin-top:1rem">The TLD owner also sets what names under it <b>sell for</b> and whether the TLD is <b>on or off</b>, straight from the portal: a <code>price</code> record (flat USD per name, replaces the length tiers for every buyer) and a <code>hidden</code> record (<code>1</code> makes it a <b>private TLD</b>: off the public list, nobody but the owner can register names under it — the owner still can from the portal, paying only the platform share — until it is switched back on). Both ride the same signed <code>TUPD</code>, so every client reads them from the chain; the owner keeps 90% of each sale.</p>
</section>
<section id="tracker">

View file

@ -195,11 +195,24 @@
// against the client-side TLDS list. Register buttons hand the label off
// to register.html?q= for the full mint flow.
(function () {
const TLDS = [
// Fallback list, used until /api/tlds answers (js/pricing.js loads it).
// The live list drops TLDs their owners switched off and carries the
// owner-set prices, so it wins whenever it is available.
let TLDS = [
"bch","p2p","bit","nav","test","x","asm","neo","gt","sc",
"sia","dex","cex","nt","dea","hub","os","vpn",
// .com is held off the public registry (2026-08-29) — skip.
];
// .com is held off the public registry (2026-08-29) regardless of what
// the API says — the operator holds it; nobody buys names under it yet.
const NEVER_SELL = new Set(["com"]);
async function liveTlds() {
const sp = window.siriusPricing;
if (!sp) return TLDS;
await sp.tldReady();
const live = sp.visibleTlds();
if (live && live.length) TLDS = live.filter((t) => !NEVER_SELL.has(t) && sp.tldSellable(t).ok);
return TLDS;
}
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
const clean = (v) => v.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 32);
@ -213,9 +226,10 @@
// pricing.js so landing / tld.html / the mint modal all agree.
let price = "";
if (state === "ok" && window.siriusPricing) {
const label = full.split(".")[0];
const p = window.siriusPricing.priceForName(label);
price = `<span class="price" title="${esc(p.tier)}">${esc(window.siriusPricing.formatUsd(p.usd))}</span>`;
const [label, tld] = full.split(".");
const p = window.siriusPricing.priceForName(label, tld);
const why = p.ownerSet ? `price set by the .${tld} owner` : p.tier;
price = `<span class="price" title="${esc(why)}">${esc(window.siriusPricing.formatUsd(p.usd))}</span>`;
}
const btn = state === "ok"
? `<button class="btn acid" data-register="${esc(full)}">Register →</button>`
@ -233,7 +247,10 @@
out.innerHTML = TLDS.map((t) => row(`${label}.${t}`, "checking")).join("");
let taken = new Set();
try {
const r = await fetch(`https://silentmode.st/api/labels/${encodeURIComponent(label)}`);
const [r] = await Promise.all([
fetch(`https://silentmode.st/api/labels/${encodeURIComponent(label)}`),
liveTlds(), // owner prices + on/off state, same round-trip
]);
if (r.ok) {
const j = await r.json();
for (const m of (j.matches || [])) taken.add(m.name);
@ -489,9 +506,9 @@ process.exit(0);"</pre>
<script type="importmap">
{ "imports": { "@bitauth/libauth": "https://silentmode.st/js/libauth.js" } }
</script>
<script type="module" src="./js/register-flow.js?v=20260909approve"></script>
<script type="module" src="./js/register-flow.js?v=20260916private"></script>
<script src="./js/pricing.js?v=20260909tldhigh"></script>
<script src="./js/pricing.js?v=20260916private"></script>
<script src="./js/pin-escrow.js?v=20260909pin"></script>
<script defer src="./js/theme.js?v=20260910font"></script>
<script defer src="./js/site-footer.js?v=20260907rel"></script>

View file

@ -3,6 +3,12 @@
// 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.
@ -16,12 +22,127 @@
const cleanLabel = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9-]/g, "");
// Tier picker — LENGTH-first, 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 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"; }
@ -36,9 +157,24 @@
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"; }
@ -68,5 +204,18 @@
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();
})();

View file

@ -353,14 +353,42 @@ async function startFlow(name, opts = {}) {
state.name = name;
state.tld = null;
state.signInOnly = false;
// Tier-based service fee (siriusPricing owns the tiers so landing search
// badges, tld cards and this mint agree). Caller can override with
// Service fee: the TLD owner's flat price when they set one, else the
// length tier (siriusPricing owns both so landing search badges, tld
// cards and this mint agree). Caller can override with
// opts.serviceFeeSats when a coupon or manual price is in play.
const label = String(name).split(".")[0];
const p = window.siriusPricing?.priceForName(label);
const [label, tldRaw] = String(name).split(".");
const tld = tldRaw || "bch";
const sp = window.siriusPricing;
if (sp?.tldReady) await sp.tldReady();
// A TLD its owner switched off is private: the public cannot buy under
// it, but the owner still can (from the portal's TLDs tab). Frozen means
// nobody, owner included. Check against the signed-in wallet's addresses
// so the owner's own purchase goes through.
const mine = (window.siriusWallet?.watchedAddresses)
|| (window.siriusWallet?.address ? [window.siriusWallet.address] : [])
|| [];
const sell = sp?.tldSellable ? sp.tldSellable(tld, mine) : { ok: true, owner: false };
if (!sell.ok) {
open();
const why = sell.reason === "frozen"
? `The owner of <b>.${esc(tld)}</b> has frozen it — no new names are being registered under it right now.`
: `<b>.${esc(tld)}</b> is a private TLD — its owner has switched it off for the public. Only the owner can register names under it right now. Check back later or pick another TLD.`;
render(`<h3>${esc(name)} is not available</h3><p class="sub">${why}</p>
<div class="row end"><button class="btn ghost" id="back">Close</button></div>`);
$("back").onclick = close;
return;
}
const p = sp?.priceForName(label, tld);
state.ownerMint = !!sell.owner;
// Owner registering under their own TLD: they would receive 90% of the
// fee from themselves, so only the platform's share is charged. The
// registrar lib already routes the whole fee to the platform when the
// TLD owner is the buyer (no self-payment output).
const ownerRate = (sats) => (sats == null ? null : BigInt(sats) / 10n);
state.nameFeeSats = opts.serviceFeeSats != null
? BigInt(opts.serviceFeeSats)
: (p ? p.sats : null);
: (p ? (sell.owner ? ownerRate(p.sats) : p.sats) : null);
open();
if (await adoptSessionWalletAsync()) stepFund();
else stepWallet();
@ -844,6 +872,7 @@ async function stepConfirm() {
render(`
<h3>Confirm ${esc(quote.displayName)}</h3>
<p class="sub">One transaction. It mints the certificate to <b>your</b> address and pays the fees.</p>
${state.ownerMint ? `<div class="note">You own this TLD, so this is an <b>owner registration</b>: only the platform's 10% share of the name price is charged — the 90% owner share would just be paid to yourself.</div>` : ""}
<table class="price">${rows}</table>
<div class="note" style="margin-top:16px">The certificate will be minted to
<span class="mono">${esc(state.wallet.address)}</span> your key, your name.

View file

@ -54,6 +54,33 @@
letter-spacing:inherit;text-transform:inherit;transition:color .12s}
.font-picker button:hover{color:var(--ink)}
.font-picker button.active{background:var(--acid);color:var(--bg);font-weight:600}
/* Profile-section tabs — horizontal menu inside the signed-in section.
Sticky under the topnav so context stays visible on scroll. Panes are
just <div data-pane="..."> siblings; JS toggles a `hidden` class. */
.profile-tabs{position:sticky;top:52px;z-index:15;display:flex;gap:6px;
padding:12px 0;margin:0 0 1.4rem;background:rgba(5,8,16,.86);
backdrop-filter:blur(10px);border-bottom:1px solid var(--line);
flex-wrap:wrap;font-family:var(--mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase}
.profile-tabs button{background:transparent;border:1px solid transparent;color:var(--mut);
padding:8px 14px;border-radius:999px;cursor:pointer;font-family:inherit;
font-size:inherit;letter-spacing:inherit;text-transform:inherit;
display:inline-flex;align-items:center;gap:8px;transition:all .12s}
.profile-tabs button:hover{color:var(--ink);background:rgba(255,255,255,.03);border-color:var(--line)}
.profile-tabs button.active{background:var(--acid);color:var(--bg);font-weight:600;border-color:var(--acid)}
.profile-tabs button .count{font-family:var(--mono);font-size:11px;padding:1px 7px;border-radius:999px;
background:rgba(255,255,255,.08);color:var(--ink);letter-spacing:.02em;text-transform:none;font-weight:500}
.profile-tabs button.active .count{background:rgba(5,8,16,.24);color:var(--bg)}
.pane{animation:paneIn .18s ease-out}
.pane[hidden]{display:none}
@keyframes paneIn{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
/* Overview stat grid */
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-top:1rem}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px 20px}
.stat .lbl{font-family:var(--mono);font-size:10.5px;letter-spacing:.24em;text-transform:uppercase;color:var(--dim);margin-bottom:8px;
display:flex;align-items:center;gap:6px}
.stat .lbl:before{content:"";width:5px;height:5px;background:var(--acid);border-radius:50%}
.stat .val{font-family:var(--serif);font-size:32px;font-weight:600;color:var(--ink);letter-spacing:-.01em;line-height:1}
.stat .sub{color:var(--mut);font-size:12.5px;margin-top:4px}
a{color:var(--acid)}
.wrap{max-width:820px;margin:0 auto;padding:0 1.2rem}
.topnav{position:sticky;top:0;z-index:20;display:flex;gap:2px;align-items:center;flex-wrap:wrap;
@ -104,7 +131,7 @@
.authbox h3{margin:0 0 .3rem;font-size:1rem}
.authbox p{margin:0;color:var(--mut);font-size:13px}
.authbox .card > .cta{margin-top:14px}
.name-row{display:grid;grid-template-columns:1fr auto auto auto;gap:10px;align-items:center;
.name-row{display:grid;grid-template-columns:1fr auto auto auto auto;gap:10px;align-items:center;
padding:14px 16px;background:var(--panel2);border:1px solid var(--line);border-radius:12px;margin-top:8px}
.name-row .n{font-family:ui-monospace,monospace;font-size:15px}
.name-row .rec{color:var(--mut);font-family:ui-monospace,monospace;font-size:12px;grid-column:1/-1;margin-top:6px;word-break:break-all}
@ -171,10 +198,65 @@
</section>
<section id="me" class="hidden">
<h2>Signed in</h2>
<!-- Horizontal tab menu. Counts populate from loadNames() (Names) and,
once /api/holdings returns TLDs, from a second query. -->
<nav class="profile-tabs" role="tablist" aria-label="Portal sections">
<button role="tab" data-pane="overview" class="active">Overview</button>
<button role="tab" data-pane="names">Names <span class="count" id="tab-names-count">0</span></button>
<button role="tab" data-pane="tlds">TLDs <span class="count" id="tab-tlds-count">0</span></button>
<button role="tab" data-pane="wallet">Wallet</button>
<button role="tab" data-pane="settings">Settings</button>
</nav>
<!-- OVERVIEW -->
<div class="pane" data-pane="overview">
<h2 style="margin:0">Overview</h2>
<p class="muted" style="margin:.3rem 0 0">A snapshot of what this wallet holds on chain.</p>
<div class="stat-grid">
<div class="stat">
<div class="lbl">Names held</div>
<div class="val" id="stat-names">0</div>
<div class="sub">CashTokens NFT certificates</div>
</div>
<div class="stat">
<div class="lbl">TLDs owned</div>
<div class="val" id="stat-tlds">0</div>
<div class="sub">Per-TLD registry NFTs</div>
</div>
<div class="stat">
<div class="lbl">Wallet balance</div>
<div class="val" id="stat-balance"></div>
<div class="sub" id="stat-balance-sub">chipnet sats</div>
</div>
<div class="stat">
<div class="lbl">Chain height</div>
<div class="val" id="stat-height"></div>
<div class="sub">Last seen block</div>
</div>
</div>
<div class="row" style="margin-top:1.4rem">
<a class="btn acid small" href="./#search-input"> Register a name</a>
<a class="btn small" href="./tld.html"> Register a TLD</a>
</div>
</div>
<!-- NAMES -->
<div class="pane" data-pane="names" hidden>
<h2 style="margin:0">Names you hold</h2>
<p class="muted" style="margin:.3rem 0 1rem">Every BCNR name your wallet's addresses currently hold a CashTokens NFT for. Edit records inline to publish an on-chain UPD.</p>
<div class="status" id="names-status">Loading names from the chain…</div>
<div id="names-list"></div>
</div>
<!-- TLDs -->
<div class="pane" data-pane="tlds" hidden>
<h2 style="margin:0">TLDs you own</h2>
<p class="muted" style="margin:.3rem 0 1rem">Every per-TLD registry NFT held by this wallet. Set the <b>price</b> every name under your TLD sells for, switch the TLD <b>on or off</b> in the public registry (off = a <b>private TLD</b>: nobody else can buy names under it, you still can from here), and tune policy — <b>fee</b>, <b>min length</b>, <b>reserved labels</b>, <b>renewal period</b>. Every change is one signed TUPD on chain; you receive 90% of each sale.</p>
<div class="status" id="tlds-status">Loading TLDs from the chain…</div>
<div id="tlds-list"></div>
<h2 style="margin-top:2rem">Register a new TLD</h2>
<div class="card" id="tld-register-card">
<h3 style="margin:0 0 .3rem;font-size:1.05rem">Register a new TLD</h3>
<p class="muted" style="margin:0 0 .8rem">TLDs are per-registry NFTs on the chipnet TLD beacon.
Public mints go through a service-fee output to fund the registry. Chipnet uses a placeholder
rate; mainnet will price by label length via a real oracle.</p>
@ -190,22 +272,40 @@
<div id="tld-log" class="status" style="display:none;font-family:ui-monospace,monospace;font-size:12.5px;margin-top:12px"></div>
<div id="tld-msg" style="margin-top:10px;font-size:13.5px"></div>
</div>
</div>
<h2 style="margin-top:2rem">Wallet</h2>
<!-- WALLET -->
<div class="pane" data-pane="wallet" hidden>
<h2 style="margin:0">Wallet</h2>
<p class="muted" style="margin:.3rem 0 1rem">Your addresses and current balance. Keys stay in this browser.</p>
<div class="card">
<label>Receive address</label>
<label>Receive address <span class="dim" style="margin-left:6px">chipnet</span></label>
<div><code id="me-addr"></code></div>
<label style="margin-top:14px">Token address (holds your name certificates)</label>
<label style="margin-top:14px">Token address <span class="dim" style="margin-left:6px">holds your name certificates</span></label>
<div><code id="me-taddr"></code></div>
<div class="row" style="justify-content:space-between">
<span class="dim" id="me-info"></span>
<a class="btn ghost small" href="https://chipnet.imaginary.cash/faucet" target="_blank" rel="noopener">Faucet →</a>
</div>
</div>
</div>
<!-- SETTINGS -->
<div class="pane" data-pane="settings" hidden>
<h2 style="margin:0">Settings</h2>
<p class="muted" style="margin:.3rem 0 1rem">Reader preferences live per-browser; wallet changes require a signature.</p>
<div class="card">
<label>Reading font</label>
<p class="dim" style="margin:0 0 8px;font-size:12.5px">Fraunces (Silent Mode family) or Ubuntu (Bitcoin Cash brand font). The pill lives in the top nav.</p>
<label style="margin-top:14px">Session PIN</label>
<p class="dim" style="margin:0 0 8px;font-size:12.5px">PIN unlocks the wallet without re-typing your password. Wipes after 3 wrong attempts.</p>
<label style="margin-top:14px">Sign out</label>
<p class="dim" style="margin:0 0 10px;font-size:12.5px">Clears the signed-in state in this browser. Your on-chain names are unaffected.</p>
<div class="row">
<button class="btn ghost small" id="signout-btn">Sign out</button>
<span class="dim" id="me-info"></span>
</div>
</div>
<h2 style="margin-top:2rem">Names you hold</h2>
<div class="status" id="names-status">Loading names from the chain…</div>
<div id="names-list"></div>
</div>
</section>
</div>
@ -245,6 +345,101 @@
</div>
</div>
<!-- DNS-record editor modal — composes a signed records manifest v1 and
POSTs to /api/records/<name>. No chain tx per change; the manifest
is verified against the on-chain NFT owner via signature. See
docs/#signed-records for the story, DESIGN-signed-records-manifest.md
for the wire format. -->
<div class="modal" id="dns-editor">
<div class="sheet">
<button class="x" id="dns-ed-close" title="close">×</button>
<h3>DNS records for <span id="dns-ed-name" style="font-family:var(--mono);color:var(--acid)"></span></h3>
<p class="sub">DNS-style records live in a signed manifest on Sia — not on chain.
<b>Free per edit</b>, no size cap. The signature proves you own the name; the
gateway rejects anything not signed by the current NFT holder. See
<a href="./docs/#signed-records" target="_blank">docs</a>.</p>
<label>A <span class="field-hint">IPv4 addresses, one per line</span></label>
<textarea id="dns-ed-A" placeholder="1.2.3.4"></textarea>
<label>AAAA <span class="field-hint">IPv6 addresses, one per line</span></label>
<textarea id="dns-ed-AAAA" placeholder="2001:db8::1"></textarea>
<label>MX <span class="field-hint">one per line: "priority hostname" (e.g. "10 mail.example.com")</span></label>
<textarea id="dns-ed-MX" placeholder="10 mail.example.com"></textarea>
<label>TXT <span class="field-hint">one string per line (SPF, DKIM, verification tokens…)</span></label>
<textarea id="dns-ed-TXT" placeholder="v=spf1 include:_spf.silentmode.st -all"></textarea>
<label>CNAME <span class="field-hint">one target host, or blank</span></label>
<input type="text" id="dns-ed-CNAME" placeholder="target.example.com">
<label>NS <span class="field-hint">nameservers, one per line</span></label>
<textarea id="dns-ed-NS" placeholder="ns1.example.com"></textarea>
<div class="steps-log" id="dns-ed-log"></div>
<div class="msg" id="dns-ed-msg"></div>
<div class="actions">
<button class="btn ghost" id="dns-ed-cancel">Cancel</button>
<button class="btn acid" id="dns-ed-save">Sign &amp; publish →</button>
</div>
</div>
</div>
<!-- TLD-policy editor modal — mirrors the record editor but signs a TUPD
against the TREG NFT for the selected TLD. Fields map to the policy
record schema described in DESIGN-tld-registry.md. -->
<div class="modal" id="tld-editor">
<div class="sheet">
<button class="x" id="tld-ed-close" title="close">×</button>
<h3>Policy for <span id="tld-ed-name" style="color:var(--acid);font-family:var(--mono)">.—</span></h3>
<p class="sub">Set policy for names registered under this TLD. Only your key can sign this TUPD;
the transaction fee is a few cents in chain dust. Leave a field blank to omit that policy
(defaults apply — open policy, tier pricing).</p>
<label>price <span class="field-hint">USD per name — flat, shown to every buyer; blank = length tiers. You receive 90% of each sale.</span></label>
<input type="text" id="tld-ed-price" placeholder="7" inputmode="decimal">
<label>listing <span class="field-hint">off hides the TLD from search and blocks new registrations until you switch it back on</span></label>
<select id="tld-ed-hidden" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;background:var(--panel,#141a24);color:var(--ink,#e7eaf1);border:1px solid var(--line,rgba(255,255,255,.12));font:inherit;margin:4px 0 12px">
<option value="">On — listed, names for sale</option>
<option value="1">Off — hidden, not for sale</option>
</select>
<label>min_len <span class="field-hint">reject names shorter than N chars</span></label>
<input type="text" id="tld-ed-minlen" placeholder="1">
<label>fee_bps <span class="field-hint">service fee owner takes, in basis points (500 = 5%)</span></label>
<input type="text" id="tld-ed-feebps" placeholder="500">
<label>policy <span class="field-hint">open · frozen · reserved-only</span></label>
<input type="text" id="tld-ed-policy" placeholder="open">
<label>reserved <span class="field-hint">space-separated labels only owner can mint</span></label>
<input type="text" id="tld-ed-reserved" placeholder="admin root bank">
<label>renewal_period <span class="field-hint">days between renewals, 0 = never expire</span></label>
<input type="text" id="tld-ed-renewal" placeholder="0">
<label>renewal_fee <span class="field-hint">renewal price in sats, 0 = free</span></label>
<input type="text" id="tld-ed-renewalfee" placeholder="0">
<label>site <span class="field-hint">where the TLD's own landing lives</span></label>
<input type="text" id="tld-ed-site" placeholder="https://silentmode.st/…">
<div class="budget" id="tld-ed-budget">payload: <b><span id="tld-ed-budget-used">0</span></b> / <span id="tld-ed-budget-max">?</span> bytes</div>
<div class="steps-log" id="tld-ed-log"></div>
<div class="msg" id="tld-ed-msg"></div>
<div class="actions">
<button class="btn ghost" id="tld-ed-cancel">Cancel</button>
<button class="btn acid" id="tld-ed-save">Save policy →</button>
</div>
</div>
</div>
<footer id="site-footer"></footer>
<script type="importmap">
@ -261,7 +456,7 @@
// so a plain "/js/bns-register.js" import can return a stale instance days
// after deploy. A query string forces the module loader to treat this as a
// new URL and fetch fresh bytes.
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260908split";
import * as BNS from "https://silentmode.st/js/bns-register.js?v=20260916dns";
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
@ -536,9 +731,42 @@ async function enterPortal() {
$("me-info").textContent = "watched addresses: " + wallet.watchedAddresses.length;
// Initialise the TLD-register card now that the wallet is available.
if (typeof updateTldPrice === "function") { updateTldPrice(); updateTldButtons(); }
wireProfileTabs();
await loadNames();
}
// ---------- profile section tabs ----------
// Horizontal tab bar drives which .pane is visible. Persists the active
// tab in the URL hash so a refresh/link keeps context (`portal.html#tab=tlds`).
function wireProfileTabs() {
const buttons = document.querySelectorAll(".profile-tabs [role=tab]");
const panes = document.querySelectorAll(".pane[data-pane]");
function activate(name) {
let matched = false;
buttons.forEach((b) => {
const on = b.dataset.pane === name;
b.classList.toggle("active", on);
if (on) matched = true;
});
panes.forEach((p) => { p.hidden = p.dataset.pane !== name; });
// Fall back to first tab if the hash points at something unknown.
if (!matched && buttons.length) {
buttons[0].classList.add("active");
const first = buttons[0].dataset.pane;
panes.forEach((p) => { p.hidden = p.dataset.pane !== first; });
}
}
buttons.forEach((b) => b.addEventListener("click", () => {
const name = b.dataset.pane;
activate(name);
// Update hash without adding a history entry.
history.replaceState(null, "", `#tab=${name}`);
}));
// Boot from hash if present.
const m = /(?:^|#|&)tab=([a-z]+)/.exec(location.hash || "");
activate(m ? m[1] : "overview");
}
// ---------- load names ----------
// Approach: derive the scripthash for each watched address locally (the
// wallet code already knows how), send them in one POST to
@ -566,7 +794,23 @@ async function loadNames() {
const detail = await r.text().catch(() => "");
throw new Error(`API ${r.status}: ${detail.slice(0, 200)}`);
}
const { count, names: mine } = await r.json();
const payload = await r.json();
const { count, names: mine, tlds = [], tld_count = 0, wallet: walletInfo, chain } = payload;
// Overview stats — always safe to update (elements exist when signed in).
const setText = (id, v) => { const el = document.getElementById(id); if (el) el.textContent = v; };
setText("stat-names", count);
setText("tab-names-count", count);
setText("stat-tlds", tld_count);
setText("tab-tlds-count", tld_count);
if (walletInfo?.sats != null) {
const sats = BigInt(walletInfo.sats);
const fmt = sats.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
setText("stat-balance", fmt);
setText("stat-balance-sub", `sat · ${walletInfo.utxo_count} UTXO${walletInfo.utxo_count === 1 ? "" : "s"}`);
}
if (chain?.height != null) setText("stat-height", chain.height.toLocaleString("en-US"));
// Render TLDs list in its own pane.
renderTlds(tlds);
if (!count) {
status.className = "status";
status.textContent = "No BCNR names found for this wallet. If you just registered, wait a minute for confirmation and reload.";
@ -585,6 +829,7 @@ async function loadNames() {
`<div class="n">${esc(e.name)}</div>` +
'<span class="badge b-x">on chain</span>' +
`<a class="btn small ghost" href="https://silentmode.st/bns/${encodeURIComponent(e.name)}/" target="_blank" rel="noopener">view →</a>` +
`<button class="btn small ghost" data-dns="${esc(e.name)}" title="Off-chain DNS records — free per edit">DNS</button>` +
`<button class="btn small acid" data-edit="${esc(e.name)}" data-records='${recJson}'>Edit</button>` +
`<div class="rec">${records}</div>`;
list.appendChild(div);
@ -593,12 +838,147 @@ async function loadNames() {
list.querySelectorAll("[data-edit]").forEach((btn) => {
btn.onclick = () => openEditor(btn.getAttribute("data-edit"), JSON.parse(btn.getAttribute("data-records") || "{}"));
});
list.querySelectorAll("[data-dns]").forEach((btn) => {
btn.onclick = () => openDnsEditor(btn.getAttribute("data-dns"));
});
} catch (e) {
status.className = "status err";
status.textContent = "Could not load names: " + (e.message || e);
}
}
// ---------- TLDs pane ----------
// Renders the "TLDs you own" list. Each row shows the TLD label, its
// on-chain policy fields (records), and an Edit-policy button — that
// button's wire-up lands with the TUPD editor in a follow-up. For now
// the button is disabled with a "coming soon" hint so the shape of
// the UI is honest about what works today.
function renderTlds(tlds) {
const status = document.getElementById("tlds-status");
const list = document.getElementById("tlds-list");
if (!list || !status) return;
list.innerHTML = "";
if (!tlds.length) {
status.className = "status";
status.innerHTML = "This wallet does not hold any TLD certificates yet. Mint one on <a href=\"./tld.html\">the TLD page</a> — every name registered under it earns you 90% of the service fee.";
return;
}
status.className = "status ok";
status.textContent = `You own ${tlds.length} TLD${tlds.length === 1 ? "" : "s"}.`;
for (const t of tlds) {
const div = document.createElement("div");
div.className = "name-row";
const records = t.records && typeof t.records === "object" ? t.records : {};
const off = isTldHidden(records);
const price = tldPriceOf(records);
const fmt = window.siriusPricing?.formatUsd || ((n) => "$" + n);
const priceBadge = price != null
? `<span class="badge b-x" title="flat price you set — every name under .${esc(t.tld)} sells for this">${esc(fmt(price))} / name</span>`
: '<span class="badge b-x" title="no owner price set — buyers pay the length tier">tier pricing</span>';
const stateBadge = off
? '<span class="badge" style="background:rgba(255,120,120,.16);color:#ff9a9a" title="hidden from the public registry; no new names until you switch it on">off</span>'
: '<span class="badge" style="background:rgba(120,255,160,.14);color:#8df0b0" title="listed in the public registry">on</span>';
const rec = Object.keys(records).length
? Object.entries(records).map(([k, v]) => `<span class="k">${esc(k)}=</span>${esc(String(v).slice(0, 80))}`).join(" &nbsp;·&nbsp; ")
: '<span class="k">(default policy — open, tier pricing)</span>';
const recJson = esc(JSON.stringify(records));
div.innerHTML =
`<div class="n">.<b style="color:var(--acid)">${esc(t.tld)}</b> &nbsp;${priceBadge} ${stateBadge}</div>` +
`<button class="btn small ghost" data-toggle-tld="${esc(t.tld)}" data-category="${esc(t.category)}" data-records='${recJson}' title="${off ? "Switch on: list the TLD and sell names again" : "Switch off: hide the TLD, stop new registrations"}">${off ? "Switch on" : "Switch off"}</button>` +
`<button class="btn small acid" data-edit-tld="${esc(t.tld)}" data-category="${esc(t.category)}" data-records='${recJson}'>Price &amp; policy</button>` +
`<div class="rec">${rec}<div class="tld-toggle-msg" style="margin-top:4px"></div>` +
// Owner registration — works whether the TLD is on or off. Off makes
// it a private TLD: the public cannot buy, the owner still can, at the
// platform's 10% share only (the register flow detects ownership).
`<form class="owner-reg" data-owner-reg="${esc(t.tld)}" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:10px">` +
`<span style="color:var(--dim)">Register a name under <b style="color:var(--acid)">.${esc(t.tld)}</b>${off ? " (private — only you can, while it is off)" : ""}:</span>` +
`<input type="text" placeholder="label" maxlength="32" autocomplete="off" spellcheck="false" style="width:160px;padding:6px 10px;border-radius:8px;background:var(--panel,#141a24);color:var(--ink,#e7eaf1);border:1px solid var(--line,rgba(255,255,255,.12));font:inherit">` +
`<span class="mono" style="color:var(--dim)">.${esc(t.tld)}</span>` +
`<button type="submit" class="btn small">Register →</button>` +
`</form></div>`;
list.appendChild(div);
}
// Wire owner registration forms → shared mint flow (js/register-flow.js).
list.querySelectorAll("[data-owner-reg]").forEach((form) => {
form.onsubmit = (e) => {
e.preventDefault();
const tld = form.getAttribute("data-owner-reg");
const input = form.querySelector("input");
const label = String(input.value || "").toLowerCase().replace(/[^a-z0-9-]/g, "");
input.value = label;
if (!label) { input.focus(); return; }
if (typeof window.siriusRegisterName !== "function") { input.placeholder = "loading…"; return; }
window.siriusRegisterName(`${label}.${tld}`);
};
});
// Wire Price & policy buttons.
list.querySelectorAll("[data-edit-tld]").forEach((btn) => {
btn.onclick = () => openTldEditor(
btn.getAttribute("data-edit-tld"),
btn.getAttribute("data-category"),
JSON.parse(btn.getAttribute("data-records") || "{}"),
);
});
// Wire on/off toggles — one TUPD that re-publishes the current policy
// with `hidden` flipped. Everything else the owner set stays as is.
list.querySelectorAll("[data-toggle-tld]").forEach((btn) => {
btn.onclick = () => toggleTldListing(
btn,
btn.getAttribute("data-toggle-tld"),
btn.getAttribute("data-category"),
JSON.parse(btn.getAttribute("data-records") || "{}"),
);
});
}
// Policy helpers shared by the TLD list, the editor and the on/off toggle.
// `hidden: 1` on chain means "off"; absence means "on". `price` is USD per
// name; absence means tier pricing. Kept in lockstep with the gateway's
// /api/tlds reading (public-gateway.mjs) and js/pricing.js.
function isTldHidden(records) {
const h = records?.hidden;
return h === 1 || h === true || h === "1";
}
function tldPriceOf(records) {
const p = records?.price;
return typeof p === "number" && Number.isFinite(p) && p >= 0 ? p : null;
}
async function toggleTldListing(btn, tld, category, records) {
if (!wallet) return;
const row = btn.closest(".name-row");
const out = row?.querySelector(".tld-toggle-msg");
const say = (t, cls) => { if (out) { out.textContent = t; out.style.color = cls === "err" ? "#ff9a9a" : cls === "ok" ? "#8df0b0" : "var(--dim)"; } };
if (wallet.source === "wc") {
say("External-wallet (WizardConnect) TUPD lands in the next drop — please sign in with the built-in wallet.", "err");
return;
}
const next = { ...records };
const turningOff = !isTldHidden(records);
if (turningOff) next.hidden = 1; else delete next.hidden;
const orig = btn.textContent;
btn.disabled = true;
btn.textContent = "Signing…";
let el = null;
try {
el = await BNS.connect();
say(turningOff ? `Switching .${tld} off…` : `Switching .${tld} on…`);
const res = await BNS.setTldRecordsWithBuiltInWallet(el, {
wallet, tld, records: next, category,
onProgress: (step) => say(String(step)),
});
say(`${turningOff ? "Off" : "On"} — broadcast ${res.txid ? res.txid.slice(0, 16) + "…" : ""}. The public registry picks it up within a minute.`, "ok");
btn.textContent = turningOff ? "Switch on" : "Switch off";
setTimeout(() => loadNames(), 4000);
} catch (e) {
say("Failed: " + (e.message || e), "err");
btn.textContent = orig;
} finally {
btn.disabled = false;
try { el?.close?.(); } catch {}
}
}
// ---------- record editor ----------
// Modal that composes a UPD payload from a small form and signs+broadcasts
// with the same code path register.html uses (BNS.setRecordsWithBuiltInWallet).
@ -699,6 +1079,253 @@ $("ed-save").addEventListener("click", async () => {
}
});
// ---------- DNS records editor (signed off-chain manifest) ----------
// The chain says who owns the name. The manifest we sign here says what
// the DNS records are. Resolvers verify both — the gateway ensures the
// signature matches the on-chain NFT owner before accepting the write to
// Sia. Zero chain tx per change, no size cap. See docs/#signed-records.
let dnsEditor = { name: null, currentSeq: -1 };
function linesToList(text) {
return String(text || "")
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
}
function listToLines(list) {
return (Array.isArray(list) ? list : []).join("\n");
}
function parseMx(text) {
// "10 mail.example.com" per line → [{pref: 10, host: "mail.example.com"}]
const out = [];
for (const line of linesToList(text)) {
const m = /^(\d+)\s+(\S.*)$/.exec(line);
if (!m) continue; // silently skip malformed rows — form is best-effort
out.push({ pref: Number(m[1]), host: m[2].trim() });
}
return out;
}
function mxToLines(list) {
return (Array.isArray(list) ? list : [])
.map((m) => `${m.pref} ${m.host}`)
.join("\n");
}
async function openDnsEditor(name) {
dnsEditor = { name, currentSeq: -1 };
$("dns-ed-name").textContent = name;
// Clear all fields, then GET current manifest (if any) to prefill and
// learn the current seq. On a first write, seq starts at 1.
for (const k of ["A", "AAAA", "MX", "TXT", "CNAME", "NS"]) $("dns-ed-" + k).value = "";
$("dns-ed-msg").textContent = ""; $("dns-ed-msg").className = "msg";
$("dns-ed-log").className = "steps-log"; $("dns-ed-log").innerHTML = "";
$("dns-ed-save").disabled = false;
$("dns-ed-save").textContent = "Sign & publish →";
$("dns-editor").classList.add("open");
try {
const r = await fetch(`https://silentmode.st/api/records/${encodeURIComponent(name)}`);
if (r.ok) {
const cur = await r.json();
dnsEditor.currentSeq = typeof cur.seq === "number" ? cur.seq : 0;
const dns = cur.dns || {};
$("dns-ed-A").value = listToLines(dns.A);
$("dns-ed-AAAA").value = listToLines(dns.AAAA);
$("dns-ed-MX").value = mxToLines(dns.MX);
$("dns-ed-TXT").value = listToLines(dns.TXT);
$("dns-ed-CNAME").value= dns.CNAME || "";
$("dns-ed-NS").value = listToLines(dns.NS);
} else if (r.status === 404) {
dnsEditor.currentSeq = 0; // fresh — first write will be seq 1
} else if (r.status === 409) {
// Name has no s3 pointer on chain — records-manifest has nowhere to live.
const j = await r.json().catch(() => ({}));
$("dns-ed-msg").className = "msg err";
$("dns-ed-msg").textContent = j.error || "This name has no s3 pointer on chain — set records.s3 first (Edit) so the manifest has a home.";
$("dns-ed-save").disabled = true;
} else {
throw new Error(`API ${r.status}`);
}
} catch (e) {
$("dns-ed-msg").className = "msg err";
$("dns-ed-msg").textContent = "Could not read current manifest: " + (e.message || e) + " (you can still publish a fresh one)";
dnsEditor.currentSeq = 0;
}
}
function closeDnsEditor() {
$("dns-editor").classList.remove("open");
dnsEditor = { name: null, currentSeq: -1 };
}
$("dns-ed-close").addEventListener("click", closeDnsEditor);
$("dns-ed-cancel").addEventListener("click", closeDnsEditor);
$("dns-editor").addEventListener("click", (e) => { if (e.target === $("dns-editor")) closeDnsEditor(); });
$("dns-ed-save").addEventListener("click", async () => {
if (!dnsEditor.name || !wallet) return;
if (wallet.source === "wc") {
$("dns-ed-msg").className = "msg err";
$("dns-ed-msg").textContent = "External-wallet signing (WizardConnect) for records manifests lands in the next drop — please sign in with the built-in wallet.";
return;
}
const msg = $("dns-ed-msg");
const log = $("dns-ed-log");
log.className = "steps-log on";
log.innerHTML = "";
const push = (t) => { const d = document.createElement("div"); d.textContent = t; log.appendChild(d); log.scrollTop = log.scrollHeight; };
msg.textContent = ""; msg.className = "msg";
$("dns-ed-save").disabled = true;
$("dns-ed-save").textContent = "Signing…";
try {
const cnameRaw = $("dns-ed-CNAME").value.trim();
const manifest = {
v: 1,
name: dnsEditor.name,
seq: (dnsEditor.currentSeq >= 0 ? dnsEditor.currentSeq : 0) + 1,
updated_at: new Date().toISOString(),
dns: {
A: linesToList($("dns-ed-A").value),
AAAA: linesToList($("dns-ed-AAAA").value),
MX: parseMx($("dns-ed-MX").value),
TXT: linesToList($("dns-ed-TXT").value),
CNAME: cnameRaw || null,
NS: linesToList($("dns-ed-NS").value),
},
};
push(`composed manifest · seq ${manifest.seq}`);
const signed = await BNS.signRecordsManifest(wallet, manifest);
push("signed with wallet key");
const r = await fetch(`https://silentmode.st/api/records/${encodeURIComponent(dnsEditor.name)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(signed),
});
const j = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(`API ${r.status}: ${j.error || "unknown error"}`);
push(`published · ${j.bytes || "?"} bytes at ${j.sia_key}`);
dnsEditor.currentSeq = manifest.seq;
msg.className = "msg ok";
msg.textContent = `Saved manifest seq ${manifest.seq}. Resolvers will pick it up on their next fetch.`;
$("dns-ed-save").textContent = "Saved ✓";
setTimeout(() => { $("dns-ed-save").textContent = "Sign & publish →"; $("dns-ed-save").disabled = false; }, 1500);
} catch (e) {
msg.className = "msg err";
msg.textContent = "Failed: " + (e.message || e);
push("error: " + (e.message || e));
$("dns-ed-save").disabled = false;
$("dns-ed-save").textContent = "Sign & publish →";
}
});
// ---------- TLD policy editor (TUPD) ----------
// Same shape as the name editor above but drives BNS.setTldRecordsWithBuiltInWallet,
// which signs a TUPD against the TREG NFT. Fields are numbers/strings that get
// packed into the TLD's records object — resolvers and future registrar UIs
// read that to apply per-TLD pricing, reservation, and renewal policy.
const TLD_POLICY_FIELDS = [
{ key: "price", id: "tld-ed-price", parse: parseUsdOrNull },
{ key: "hidden", id: "tld-ed-hidden", parse: (v) => (String(v) === "1" ? 1 : null) },
{ key: "min_len", id: "tld-ed-minlen", parse: parseIntOrNull },
{ key: "fee_bps", id: "tld-ed-feebps", parse: parseIntOrNull },
{ key: "policy", id: "tld-ed-policy", parse: trimOrNull },
{ key: "reserved", id: "tld-ed-reserved", parse: trimOrNull },
{ key: "renewal_period", id: "tld-ed-renewal", parse: parseIntOrNull },
{ key: "renewal_fee", id: "tld-ed-renewalfee", parse: parseIntOrNull },
{ key: "site", id: "tld-ed-site", parse: trimOrNull },
];
function parseIntOrNull(v) { v = String(v).trim(); if (!v) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null; }
// USD with at most two decimals ("7", "7.5", "$0.25"); anything else = unset.
function parseUsdOrNull(v) { v = String(v).trim().replace(/^\$/, ""); if (!v) return null; const n = Number(v); return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) / 100 : null; }
function trimOrNull(v) { v = String(v).trim(); return v || null; }
let tldEditor = { tld: null, category: null };
function openTldEditor(tld, category, records) {
tldEditor = { tld, category };
$("tld-ed-name").textContent = "." + tld;
for (const f of TLD_POLICY_FIELDS) {
const cur = records?.[f.key];
$(f.id).value = cur == null ? "" : String(cur);
}
$("tld-ed-msg").textContent = "";
$("tld-ed-msg").className = "msg";
$("tld-ed-log").className = "steps-log";
$("tld-ed-log").innerHTML = "";
$("tld-ed-save").disabled = false;
$("tld-ed-save").textContent = "Save policy →";
updateTldBudget();
$("tld-editor").classList.add("open");
}
function closeTldEditor() {
$("tld-editor").classList.remove("open");
tldEditor = { tld: null, category: null };
}
$("tld-ed-close").addEventListener("click", closeTldEditor);
$("tld-ed-cancel").addEventListener("click", closeTldEditor);
$("tld-editor").addEventListener("click", (e) => { if (e.target === $("tld-editor")) closeTldEditor(); });
function collectTldRecords() {
const r = {};
for (const f of TLD_POLICY_FIELDS) {
const v = f.parse($(f.id).value);
if (v !== null && v !== undefined && v !== "") r[f.key] = v;
}
return r;
}
function updateTldBudget() {
if (!tldEditor.tld) return;
try {
// TUPD envelope is the same 200-byte OP_RETURN cap. payloadBudget knows
// the op letter — pass "TUPD" so the envelope size (op + tld) is right.
const max = BNS.payloadBudget(tldEditor.tld, "TUPD");
const used = new TextEncoder().encode(JSON.stringify(collectTldRecords())).length;
$("tld-ed-budget-max").textContent = max;
$("tld-ed-budget-used").textContent = used;
const over = used > max;
$("tld-ed-budget").classList.toggle("over", over);
$("tld-ed-save").disabled = over;
} catch { /* budget helper doesn't yet know TUPD — leave it un-updated */ }
}
for (const f of TLD_POLICY_FIELDS) $(f.id).addEventListener("input", updateTldBudget);
$("tld-ed-save").addEventListener("click", async () => {
if (!tldEditor.tld || !tldEditor.category || !wallet) return;
if (wallet.source === "wc") {
$("tld-ed-msg").className = "msg err";
$("tld-ed-msg").textContent = "External-wallet (WizardConnect) TUPD lands in the next drop — please sign in with the built-in wallet.";
return;
}
const records = collectTldRecords();
const msg = $("tld-ed-msg");
const log = $("tld-ed-log");
log.className = "steps-log on";
log.innerHTML = "";
const push = (t) => { const d = document.createElement("div"); d.textContent = t; log.appendChild(d); log.scrollTop = log.scrollHeight; };
msg.textContent = ""; msg.className = "msg";
$("tld-ed-save").disabled = true;
$("tld-ed-save").textContent = "Signing…";
let el = null;
try {
el = await BNS.connect();
push("connected to chipnet");
const res = await BNS.setTldRecordsWithBuiltInWallet(el, {
wallet, tld: tldEditor.tld, records, category: tldEditor.category,
onProgress: (step) => push(String(step)),
});
push("broadcast: " + (res.txid || "(no txid)"));
msg.className = "msg ok";
msg.textContent = "Policy saved. It'll reflect in registrations under this TLD within a block.";
$("tld-ed-save").textContent = "Saved ✓";
setTimeout(() => loadNames(), 2500);
} catch (e) {
msg.className = "msg err";
msg.textContent = "Failed: " + (e.message || e);
push("error: " + (e.message || e));
$("tld-ed-save").disabled = false;
$("tld-ed-save").textContent = "Save policy →";
} finally {
try { el?.close?.(); } catch {}
}
});
// ---------- TLD register (chipnet MVP) ----------
// Simple oracle: static chipnet rate for testing. Replace with a live feed
// (CoinGecko, Kraken, or a Chainlink adapter) before mainnet. The `sats` output
@ -816,10 +1443,10 @@ $("tld-submit").addEventListener("click", async () => {
updateTldPrice();
</script>
<script src="./js/pin-escrow.js?v=20260909pin"></script>
<script src="./js/pricing.js?v=20260909tldhigh"></script>
<script src="./js/pricing.js?v=20260916private"></script>
<script defer src="./js/theme.js?v=20260910font"></script>
<script defer src="./js/site-footer.js?v=20260907rel"></script>
<script type="module" src="./js/register-flow.js?v=20260909approve"></script>
<script type="module" src="./js/register-flow.js?v=20260916private"></script>
<script defer src="./js/profile-menu.js?v=20260908tabs"></script>
</body>
</html>

View file

@ -301,8 +301,8 @@
})();
</script>
<script type="module" src="./js/register-flow.js?v=20260909approve"></script>
<script src="./js/pricing.js?v=20260909tldhigh"></script>
<script type="module" src="./js/register-flow.js?v=20260916private"></script>
<script src="./js/pricing.js?v=20260916private"></script>
<script src="./js/pin-escrow.js?v=20260909pin"></script>
<script defer src="./js/theme.js?v=20260910font"></script>
<script defer src="./js/site-footer.js?v=20260907rel"></script>