Per-TLD certificates on a distinct TLD beacon supersede the legacy list-in-one-NFT
model. TLD label is the NFT commitment; TREG/TUPD payloads on the TLD beacon
mirror REG/UPD on the name beacon. Chipnet TLD beacon:
bchtest:qzc4c76vtw9nd49g6x8nmvc0jz62uvk4dc7hmpmerh
scripthash c85db10e106606273422b2733b2a6fb8b35a4d0e7dbfb20cf59bdb0833c008b8
* config.js, resolver-web.{js,d.ts} — TLD_BEACON + TLD_BEACON_SCRIPTHASH
* bns.js — KNOWN_OPS { REG, UPD, TREG, TUPD }, registerTld/updateTld/normalizeTld
* bns.js + resolver-web.js — buildIndex({enforceTld:false}) drops name REGs
whose TLD lacks a live TLD cert at their block height (mempool-friendly).
Lockstep across both files.
* lib/tld-index.js — buildTldSnapshot({history,txs}) → deterministic root over
canonicalised tlds[]. Pure, importable, and stable across insertion order.
* indexer/bns-indexer.js — BeaconIndex is multi-beacon; ws handler dispatches
by scripthash; new HTTP endpoint GET /tld-list.json + /health behind
BNS_HTTP_PORT; state file v1 → v2 migrates in fromJSON.
* publish-tld-mirror.mjs — S3 (floating + immutable content-addressed) plus
NIP-33 kind 30078 d:bns-tld-list to Nostr relays, signed via nostr-tools
(libauth's Schnorr differs from BIP-340 and relays reject it).
* seed-tld-beacon.mjs — idempotent chipnet seed. Current set on the beacon:
bch p2p bit nav test x asm neo gt sc sia com dex cex nt (15). `.com` is
held by the operator: cert on-chain, excluded from public registrar UI.
Design + docs:
* Decentralized.DNS/DESIGN-tld-registry.md — schema, enforcement,
operator-countersig gate, USD-tiered pricing floors, fee splits,
tracker/mirror mechanics, migration/grandfathering.
* INSTRUCTIONS.md §3 rewritten for the 4-step flow (TREG → UI → deploy →
republish snapshot).
* site/tlds/ — public docs page listing current TLDs + verification paths.
* site/sirius/register.html — dropdown removed; parallel search across
every TLD in the TLDS array, sorted available → taken → not-on-registry.
Config:
* ariadne.config.json tlds → [bch, p2p, bit, nav, test, x] (CA constraints;
on-chain registry is now the authoritative source, config is fallback).
* setup/install.ps1 — reads TLD list via fetchTldList (per-TLD registry)
with fallback to fetchBcnrTlds (legacy tls.bch list-NFT) then config.
Tests:
* test/tld-index.test.mjs — 30 self-contained checks (multi-beacon dispatch,
state migration, ws routing, snapshot shape + root recomputability).
* test/tld-enforcement.test.mjs — synthetic + live chipnet integration
(with-gate correctly drops all pre-seed names).
330 lines
13 KiB
JavaScript
330 lines
13 KiB
JavaScript
// BNS1 core: payload codec, chain indexer/resolver, and tx builders.
|
|
import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js";
|
|
import { BEACON, TLD_BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.js";
|
|
|
|
// ---------- payload codec ----------
|
|
|
|
const LABEL_RE = /^[a-z0-9-]{1,32}$/;
|
|
const TLD_RE = /^[a-z0-9-]{1,16}$/;
|
|
const RAWNAME_RE = /^[a-z0-9-]{1,32}(\.[a-z0-9-]{1,16})?$/;
|
|
// On-chain name for a registration: bare "<label>" for .bch (backward compat),
|
|
// or "<label>.<tld>" for any other TLD. The rightmost label is the registry.
|
|
export function normalizeName(input) {
|
|
const parts = String(input).toLowerCase().trim().replace(/\.$/, "").split(".").filter(Boolean);
|
|
let label, tld;
|
|
if (parts.length === 1) { label = parts[0]; tld = "bch"; }
|
|
else { tld = parts[parts.length - 1]; label = parts[parts.length - 2]; }
|
|
if (!LABEL_RE.test(label) || !TLD_RE.test(tld)) {
|
|
throw new Error(`invalid name "${input}" (allowed: <label>.<tld>, [a-z0-9-])`);
|
|
}
|
|
return tld === "bch" ? label : `${label}.${tld}`;
|
|
}
|
|
|
|
export function encodePayload(op, name, records) {
|
|
const json = JSON.stringify({ o: op, n: name, r: records });
|
|
const buf = Buffer.from(PREFIX + json, "utf8");
|
|
if (buf.length > 200) throw new Error(`records too large (${buf.length} > 200 bytes)`);
|
|
return buf;
|
|
}
|
|
|
|
// Parse a raw output script hex; return payload object or null.
|
|
export function parseOpReturn(scriptHex) {
|
|
if (!scriptHex?.startsWith("6a")) return null; // OP_RETURN
|
|
let i = 2;
|
|
const pushes = [];
|
|
while (i < scriptHex.length) {
|
|
let op = parseInt(scriptHex.slice(i, i + 2), 16);
|
|
i += 2;
|
|
let len;
|
|
if (op <= 0x4b) len = op;
|
|
else if (op === 0x4c) { len = parseInt(scriptHex.slice(i, i + 2), 16); i += 2; }
|
|
else if (op === 0x4d) { len = parseInt(scriptHex.slice(i + 2, i + 4) + scriptHex.slice(i, i + 2), 16); i += 4; }
|
|
else return null;
|
|
pushes.push(Buffer.from(scriptHex.slice(i, i + len * 2), "hex"));
|
|
i += len * 2;
|
|
}
|
|
const data = pushes[0]?.toString("utf8") ?? "";
|
|
if (!data.startsWith(PREFIX)) return null;
|
|
try {
|
|
const p = JSON.parse(data.slice(PREFIX.length));
|
|
// Recognised opcodes:
|
|
// REG / UPD — name registrations & updates on the name beacon
|
|
// TREG / TUPD — TLD registrations & updates on the TLD beacon
|
|
// (Decentralized.DNS/DESIGN-tld-registry.md §3)
|
|
// KEEP IN LOCKSTEP with resolver-web.js parseOpReturnHex — memory rule.
|
|
if (KNOWN_OPS.has(p.o) && typeof p.n === "string") return p;
|
|
} catch { /* not ours */ }
|
|
return null;
|
|
}
|
|
|
|
// The set of payload opcodes this codec recognises. New opcodes go here AND in
|
|
// resolver-web.js — divergence is a consensus split.
|
|
export const KNOWN_OPS = new Set(["REG", "UPD", "TREG", "TUPD"]);
|
|
|
|
// ---------- indexer / resolver ----------
|
|
|
|
// (height, txid) order; unconfirmed (height<=0) last.
|
|
function byHeightThenTxid(a, b) {
|
|
const ha = a.height > 0 ? a.height : Infinity;
|
|
const hb = b.height > 0 ? b.height : Infinity;
|
|
return ha - hb || a.tx_hash.localeCompare(b.tx_hash);
|
|
}
|
|
|
|
// Never index a transaction before one it spends from — otherwise an UPD that
|
|
// happens to sort below its own REG is evaluated against an index that does not
|
|
// yet contain the name, and is dropped. Keeps the txid tiebreak for independent
|
|
// transactions. Mirrors resolver-web.js `orderByDependency` — change both.
|
|
function orderByDependency(history, txs) {
|
|
const inSet = new Set(history.map((h) => h.tx_hash));
|
|
const deps = new Map(
|
|
history.map((h) => {
|
|
const spent = new Set();
|
|
for (const vin of txs.get(h.tx_hash)?.vin ?? []) {
|
|
if (vin.txid && vin.txid !== h.tx_hash && inSet.has(vin.txid)) spent.add(vin.txid);
|
|
}
|
|
return [h.tx_hash, spent];
|
|
}),
|
|
);
|
|
const emitted = new Set();
|
|
const ordered = [];
|
|
const pending = [...history];
|
|
while (pending.length) {
|
|
let i = pending.findIndex((h) => [...deps.get(h.tx_hash)].every((d) => emitted.has(d)));
|
|
if (i < 0) i = 0; // unreachable on a real chain; guarantees termination
|
|
const [next] = pending.splice(i, 1);
|
|
emitted.add(next.tx_hash);
|
|
ordered.push(next);
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
// Extract the TLD of a raw on-chain name: "hello" -> "bch" (legacy .bch),
|
|
// "foo.x" -> "x". Same rule as normalizeName's TLD inference.
|
|
function tldOf(rawName) {
|
|
const i = rawName.lastIndexOf(".");
|
|
return i < 0 ? "bch" : rawName.slice(i + 1);
|
|
}
|
|
|
|
// Walk the TLD-beacon history to a Map<tld, {category, regHeight, regTxid}>.
|
|
// Same first-TREG-wins / commitment-must-match rules as buildIndex uses for
|
|
// names — kept inline (rather than importing tld-index.js) to avoid the
|
|
// circular import bns.js -> tld-index.js -> bns.js. Any change here must
|
|
// match the equivalent block in resolver-web.js (memory rule).
|
|
async function buildTldMap(provider) {
|
|
const history = await provider.getHistory(TLD_BEACON);
|
|
history.sort(byHeightThenTxid);
|
|
const txs = new Map();
|
|
for (const { tx_hash } of history) {
|
|
try { txs.set(tx_hash, await provider.getRawTransactionObject(tx_hash)); }
|
|
catch { /* skip */ }
|
|
}
|
|
const tlds = new Map();
|
|
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
|
|
const tx = txs.get(tx_hash);
|
|
let payload = null;
|
|
for (const vout of tx.vout) payload = parseOpReturn(vout.scriptPubKey?.hex) ?? payload;
|
|
if (!payload) continue;
|
|
if (typeof payload.n !== "string" || !/^[a-z0-9-]{1,16}$/.test(payload.n)) continue;
|
|
const tld = payload.n;
|
|
if (payload.o === "TREG") {
|
|
if (tlds.has(tld)) continue; // first valid TREG wins
|
|
const commitment = Buffer.from(tld, "utf8").toString("hex");
|
|
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
|
|
if (!mint) continue;
|
|
tlds.set(tld, { category: mint.tokenData.category, regHeight: height, regTxid: tx_hash });
|
|
}
|
|
// TUPDs are ignored for the enforcement map — we only need existence + registration height.
|
|
}
|
|
return tlds;
|
|
}
|
|
|
|
// The gate the design specifies (Decentralized.DNS/DESIGN-tld-registry.md §4):
|
|
// a name REG is admitted iff its TLD has a live TLD certificate on the TLD
|
|
// beacon at a height ≤ the REG's height. Mempool-friendly: if either side is
|
|
// unconfirmed, admit — the beacon is the source of truth for "does this TLD
|
|
// exist now", and time-ordering across mempool entries is fuzzy anyway.
|
|
function tldAdmits(tld, regHeight, tldMap) {
|
|
const t = tldMap.get(tld);
|
|
if (!t) return false;
|
|
if (t.regHeight > 0 && regHeight > 0) return t.regHeight <= regHeight;
|
|
return true;
|
|
}
|
|
|
|
// Builds { name -> { name, category, records, txid, height, updatedTxid } }.
|
|
//
|
|
// `enforceTld: true` turns the TLD registry into a consensus gate — see §4 of
|
|
// the design doc. Off by default while the mainnet migration and grandfathering
|
|
// story lands; a chipnet deployment can flip it on immediately to prove the
|
|
// path end-to-end.
|
|
export async function buildIndex(provider, { enforceTld = false } = {}) {
|
|
// In enforcement mode, fetch the TLD map first — we need it before we
|
|
// decide which name REGs to keep. Both fetches share `provider`, so a single
|
|
// electrum connection carries both.
|
|
const tldMap = enforceTld ? await buildTldMap(provider) : null;
|
|
|
|
const history = await provider.getHistory(BEACON);
|
|
history.sort(byHeightThenTxid);
|
|
|
|
const txs = new Map();
|
|
for (const { tx_hash } of history) {
|
|
try { txs.set(tx_hash, await provider.getRawTransactionObject(tx_hash)); }
|
|
catch { /* unreadable tx — skip it entirely */ }
|
|
}
|
|
|
|
const names = new Map();
|
|
const byCategory = new Map();
|
|
|
|
for (const { tx_hash, height } of orderByDependency(history.filter((h) => txs.has(h.tx_hash)), txs)) {
|
|
const tx = txs.get(tx_hash);
|
|
|
|
let payload = null;
|
|
for (const vout of tx.vout) {
|
|
payload = parseOpReturn(vout.scriptPubKey?.hex) ?? payload;
|
|
}
|
|
if (!payload) continue;
|
|
|
|
const name = payload.n;
|
|
if (!RAWNAME_RE.test(name)) continue;
|
|
|
|
if (payload.o === "REG") {
|
|
if (names.has(name)) continue; // first valid REG wins
|
|
// TLD gate — see design §4 / tldAdmits above. Only applies to REG; UPDs
|
|
// are transitions on names already admitted, so no gate needed there.
|
|
if (enforceTld && !tldAdmits(tldOf(name), height, tldMap)) continue;
|
|
// the same tx must mint a certificate whose commitment is the name
|
|
const commitment = Buffer.from(name, "utf8").toString("hex");
|
|
const mint = tx.vout.find((v) => v.tokenData?.nft?.commitment === commitment);
|
|
if (!mint) continue;
|
|
const entry = {
|
|
name, category: mint.tokenData.category, records: payload.r ?? {},
|
|
txid: tx_hash, height, updatedTxid: null,
|
|
};
|
|
names.set(name, entry);
|
|
byCategory.set(entry.category, entry);
|
|
} else if (payload.o === "UPD") {
|
|
const entry = names.get(name);
|
|
if (!entry) continue;
|
|
// ownership proof: some output carries the name's category
|
|
// (consensus: impossible unless an input held that category)
|
|
const proves = tx.vout.some((v) => v.tokenData?.category === entry.category);
|
|
if (!proves) continue;
|
|
entry.records = payload.r ?? {};
|
|
entry.updatedTxid = tx_hash;
|
|
}
|
|
}
|
|
return names;
|
|
}
|
|
|
|
export async function resolveName(provider, input) {
|
|
const name = normalizeName(input);
|
|
const index = await buildIndex(provider);
|
|
return index.get(name) ?? null;
|
|
}
|
|
|
|
// ---------- tx builders ----------
|
|
|
|
// CashTokens genesis must spend an outpoint with index 0; self-send to make one if needed.
|
|
async function ensureGenesisInput(wallet) {
|
|
const utxos = await wallet.getUtxos();
|
|
if (utxos.some((u) => u.vout === 0 && !u.token)) return;
|
|
await wallet.send([{ cashaddr: wallet.cashaddr, value: 10000n, unit: "sat" }]);
|
|
}
|
|
|
|
export async function registerName(wallet, input, records) {
|
|
const name = normalizeName(input);
|
|
const index = await buildIndex(wallet.provider);
|
|
if (index.has(name)) throw new Error(`"${name}${'.bch'}" is already registered`);
|
|
await ensureGenesisInput(wallet);
|
|
const payload = encodePayload("REG", name, records);
|
|
const result = await wallet.tokenGenesis(
|
|
{
|
|
cashaddr: wallet.tokenaddr,
|
|
nft: {
|
|
capability: NFTCapability.none,
|
|
commitment: Buffer.from(name, "utf8").toString("hex"),
|
|
},
|
|
value: 1000n,
|
|
},
|
|
[
|
|
OpReturnData.fromUint8Array(payload),
|
|
{ cashaddr: BEACON, value: BEACON_DUST_SAT, unit: "sat" },
|
|
],
|
|
);
|
|
return { name, txid: result.txId, category: result.tokenIds?.[0] };
|
|
}
|
|
|
|
export async function updateName(wallet, input, records) {
|
|
const name = normalizeName(input);
|
|
const index = await buildIndex(wallet.provider);
|
|
const entry = index.get(name);
|
|
if (!entry) throw new Error(`"${name}.bch" is not registered`);
|
|
const payload = encodePayload("UPD", name, records);
|
|
const result = await wallet.send([
|
|
new TokenSendRequest({
|
|
cashaddr: wallet.tokenaddr,
|
|
category: entry.category,
|
|
nft: {
|
|
capability: NFTCapability.none,
|
|
commitment: Buffer.from(name, "utf8").toString("hex"),
|
|
},
|
|
}),
|
|
OpReturnData.fromUint8Array(payload),
|
|
{ cashaddr: BEACON, value: BEACON_DUST_SAT, unit: "sat" },
|
|
]);
|
|
return { name, txid: result.txId, category: entry.category };
|
|
}
|
|
|
|
// ---------- TLD certificate tx builders ----------
|
|
// Mirror registerName / updateName but for the per-TLD registry
|
|
// (Decentralized.DNS/DESIGN-tld-registry.md §3):
|
|
// • payload opcode is TREG/TUPD
|
|
// • the NFT commitment is the TLD label (e.g. "x") — not a name
|
|
// • dust flows to TLD_BEACON, not the name BEACON
|
|
// Ownership + spend rules are otherwise identical to the name path, so the
|
|
// signing / covenant / renewal work carries over unchanged.
|
|
|
|
export function normalizeTld(input) {
|
|
const tld = String(input).toLowerCase().trim().replace(/^\.+/, "").replace(/\.+$/, "");
|
|
if (!TLD_RE.test(tld)) throw new Error(`invalid TLD "${input}" (allowed: [a-z0-9-]{1,16})`);
|
|
return tld;
|
|
}
|
|
|
|
export async function registerTld(wallet, input, records = {}) {
|
|
const tld = normalizeTld(input);
|
|
await ensureGenesisInput(wallet);
|
|
const payload = encodePayload("TREG", tld, records);
|
|
const result = await wallet.tokenGenesis(
|
|
{
|
|
cashaddr: wallet.tokenaddr,
|
|
nft: {
|
|
capability: NFTCapability.none,
|
|
commitment: Buffer.from(tld, "utf8").toString("hex"),
|
|
},
|
|
value: 1000n,
|
|
},
|
|
[
|
|
OpReturnData.fromUint8Array(payload),
|
|
{ cashaddr: TLD_BEACON, value: BEACON_DUST_SAT, unit: "sat" },
|
|
],
|
|
);
|
|
return { tld, txid: result.txId, category: result.tokenIds?.[0] };
|
|
}
|
|
|
|
export async function updateTld(wallet, input, records, category) {
|
|
const tld = normalizeTld(input);
|
|
if (!category) throw new Error("updateTld requires the TLD certificate's category id");
|
|
const payload = encodePayload("TUPD", tld, records);
|
|
const result = await wallet.send([
|
|
new TokenSendRequest({
|
|
cashaddr: wallet.tokenaddr,
|
|
category,
|
|
nft: {
|
|
capability: NFTCapability.none,
|
|
commitment: Buffer.from(tld, "utf8").toString("hex"),
|
|
},
|
|
}),
|
|
OpReturnData.fromUint8Array(payload),
|
|
{ cashaddr: TLD_BEACON, value: BEACON_DUST_SAT, unit: "sat" },
|
|
]);
|
|
return { tld, txid: result.txId, category };
|
|
}
|