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).
252 lines
12 KiB
JavaScript
252 lines
12 KiB
JavaScript
// Self-contained tests for the multi-beacon indexer + /tld-list.json snapshot.
|
|
// No network, no BCHN — everything runs against synthetic fixtures. Covers the
|
|
// four things the memory rule cares about after this change lands:
|
|
// 1. Multi-beacon BeaconIndex dispatches processTx / getHistory per beacon.
|
|
// 2. Legacy v1 state file migrates into the first configured beacon on load,
|
|
// preserving history across an upgrade from single- to multi-beacon.
|
|
// 3. Electrum WS handler routes blockchain.scripthash.get_history by
|
|
// requested scripthash, and returns [] for unknown scripthashes.
|
|
// 4. /tld-list.json snapshot has the shape the design doc specifies, its
|
|
// root is deterministic across insertion order, and TREG payloads
|
|
// without a matching NFT mint are rejected.
|
|
//
|
|
// The existing bns-indexer.test.mjs still covers the live-chipnet integration
|
|
// for the name beacon; this file is what proves the multi-beacon plumbing
|
|
// doesn't break it and the new TLD path works.
|
|
|
|
import http from "node:http";
|
|
import WebSocket from "ws";
|
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
|
|
import {
|
|
BeaconIndex,
|
|
RpcSource,
|
|
startElectrumWS,
|
|
startHttpServer,
|
|
scripthashOf,
|
|
} from "../src/indexer/bns-indexer.js";
|
|
import { buildTldSnapshot } from "../src/lib/tld-index.js";
|
|
import { encodePayload } from "../src/lib/bns.js";
|
|
|
|
let failures = 0;
|
|
const ok = (cond, msg) => { console.log((cond ? " ✅ " : " ❌ ") + msg); if (!cond) failures++; };
|
|
const eq = (a, b, msg) => ok(JSON.stringify(a) === JSON.stringify(b), `${msg} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`);
|
|
|
|
// ---------- tiny helpers to synth verbose txs ----------
|
|
// Pretend the scriptPubKey for a beacon output is a plain OP_DUP HASH160 push
|
|
// whose scripthash we choose freely by picking the raw hex. We compute
|
|
// scripthashOf from that hex, so the "beacon scripthash" is whatever we say.
|
|
function beaconOutput(rawHex) {
|
|
return { scriptPubKey: { hex: rawHex } };
|
|
}
|
|
function opReturnOutput(op, name, records) {
|
|
const buf = encodePayload(op, name, records);
|
|
const hex = "6a" + buf.length.toString(16).padStart(2, "0") + buf.toString("hex");
|
|
return { scriptPubKey: { hex } };
|
|
}
|
|
function nftMintOutput(category, commitmentLabel, ownerAddr = "bchtest:qexample") {
|
|
return {
|
|
scriptPubKey: { hex: "76a914" + "aabbcc".padEnd(40, "0") + "88ac", address: ownerAddr },
|
|
tokenData: {
|
|
category,
|
|
amount: "0",
|
|
nft: { capability: "none", commitment: Buffer.from(commitmentLabel, "utf8").toString("hex") },
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------- (1) multi-beacon BeaconIndex ----------
|
|
console.log("1) BeaconIndex multi-beacon dispatch");
|
|
{
|
|
const rawA = "aa11" + "00".repeat(20); // scriptPubKey for beacon A
|
|
const rawB = "bb22" + "00".repeat(20); // scriptPubKey for beacon B
|
|
const shA = scripthashOf(rawA);
|
|
const shB = scripthashOf(rawB);
|
|
const idx = new BeaconIndex([shA, shB]);
|
|
|
|
// A tx paying only beacon A
|
|
const txA = { txid: "txA", vin: [], vout: [beaconOutput(rawA)] };
|
|
ok(idx.processTx(txA, 100) === true, "processTx returns true for beacon-A tx");
|
|
eq(idx.getHistory(shA).map((h) => h.tx_hash), ["txA"], "beacon A history has txA");
|
|
eq(idx.getHistory(shB), [], "beacon B history is empty");
|
|
|
|
// A tx paying only beacon B
|
|
const txB = { txid: "txB", vin: [], vout: [beaconOutput(rawB)] };
|
|
idx.processTx(txB, 101);
|
|
eq(idx.getHistory(shA).map((h) => h.tx_hash), ["txA"], "beacon A unaffected by beacon-B tx");
|
|
eq(idx.getHistory(shB).map((h) => h.tx_hash), ["txB"], "beacon B history has txB");
|
|
|
|
// A tx paying BOTH beacons (edge case: same output hex is possible if beacons
|
|
// are configured overlapping; not our normal shape, but the code must not crash)
|
|
const rawC = "cc33" + "00".repeat(20);
|
|
const shC = scripthashOf(rawC);
|
|
const idx2 = new BeaconIndex([shC]);
|
|
idx2.processTx({ txid: "txC", vin: [], vout: [beaconOutput(rawC)] }, 200);
|
|
eq(idx2.getHistory().map((h) => h.tx_hash), ["txC"], "argless getHistory returns single-beacon history");
|
|
|
|
ok(idx.getTx("txA")?.txid === "txA", "getTx finds cross-beacon");
|
|
ok(idx.getTx("nonexistent") === null, "getTx returns null for unknown");
|
|
ok(idx.historySize === 2, "historySize sums across beacons");
|
|
}
|
|
|
|
// ---------- (2) legacy v1 state migration ----------
|
|
console.log("2) BeaconIndex.fromJSON migrates legacy v1 state");
|
|
{
|
|
const sh = "de".repeat(32);
|
|
const legacy = {
|
|
history: [["tx-old", 42]],
|
|
txCache: [["tx-old", { txid: "tx-old", vin: [], vout: [] }]],
|
|
utxos: [],
|
|
};
|
|
const idx = BeaconIndex.fromJSON(sh, legacy);
|
|
ok(idx.getHistory().length === 1, "legacy history migrated");
|
|
ok(idx.getHistory()[0].tx_hash === "tx-old", "legacy tx present in first beacon");
|
|
ok(idx.getTx("tx-old")?.txid === "tx-old", "legacy tx cache migrated");
|
|
|
|
// Legacy state passed with multi-beacon config lands in FIRST beacon; other
|
|
// beacons stay empty and need backfill (RpcSource logs a warning for this).
|
|
const shB = "ef".repeat(32);
|
|
const idxMulti = BeaconIndex.fromJSON([sh, shB], legacy);
|
|
ok(idxMulti.getHistory(sh).length === 1, "legacy migrated to first beacon under multi");
|
|
ok(idxMulti.getHistory(shB).length === 0, "second beacon empty pending backfill");
|
|
|
|
// v2 round-trip preserves both beacons
|
|
const j2 = idxMulti.toJSON();
|
|
ok(j2.version === 2, "toJSON writes v2 shape");
|
|
const roundtrip = BeaconIndex.fromJSON([sh, shB], j2);
|
|
eq(roundtrip.getHistory(sh).map((h) => h.tx_hash), ["tx-old"], "v2 round-trip preserves first beacon history");
|
|
}
|
|
|
|
// ---------- (3) WS handler dispatch ----------
|
|
console.log("3) WS handler dispatches blockchain.scripthash.get_history per scripthash");
|
|
await (async () => {
|
|
const rawA = "aa11" + "00".repeat(20);
|
|
const rawB = "bb22" + "00".repeat(20);
|
|
const shA = scripthashOf(rawA);
|
|
const shB = scripthashOf(rawB);
|
|
const idx = new BeaconIndex([shA, shB]);
|
|
idx.processTx({ txid: "aaa", vin: [], vout: [beaconOutput(rawA)] }, 100);
|
|
idx.processTx({ txid: "bbb", vin: [], vout: [beaconOutput(rawB)] }, 101);
|
|
|
|
const PORT = 51099;
|
|
const wss = startElectrumWS(idx, { bind: "127.0.0.1", port: PORT }, null);
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
const call = (method, params) => new Promise((res, rej) => {
|
|
const ws = new WebSocket(`ws://127.0.0.1:${PORT}`);
|
|
let id = 0, buf = "";
|
|
ws.on("open", () => ws.send(JSON.stringify({ id: ++id, method, params }) + "\n"));
|
|
ws.on("message", (d) => {
|
|
buf += d.toString();
|
|
const nl = buf.indexOf("\n");
|
|
const line = (nl >= 0 ? buf.slice(0, nl) : buf).trim();
|
|
if (line) { try { const m = JSON.parse(line); res(m); ws.close(); } catch (e) { rej(e); } }
|
|
});
|
|
ws.on("error", rej);
|
|
setTimeout(() => rej(new Error("timeout")), 5000);
|
|
});
|
|
|
|
const rA = await call("blockchain.scripthash.get_history", [shA]);
|
|
eq(rA.result.map((h) => h.tx_hash), ["aaa"], "beacon A history served");
|
|
const rB = await call("blockchain.scripthash.get_history", [shB]);
|
|
eq(rB.result.map((h) => h.tx_hash), ["bbb"], "beacon B history served");
|
|
const rUnknown = await call("blockchain.scripthash.get_history", ["ff".repeat(32)]);
|
|
eq(rUnknown.result, [], "unknown scripthash returns [] (no error)");
|
|
const rTx = await call("blockchain.transaction.get", ["aaa", true]);
|
|
ok(rTx.result && rTx.result.txid === "aaa", "blockchain.transaction.get finds cross-beacon");
|
|
|
|
wss.close();
|
|
})();
|
|
|
|
// ---------- (4) /tld-list.json snapshot shape + root recomputability ----------
|
|
console.log("4) /tld-list.json snapshot shape and root");
|
|
await (async () => {
|
|
const rawTld = "ee44" + "00".repeat(20);
|
|
const shTld = scripthashOf(rawTld);
|
|
const idx = new BeaconIndex([shTld]);
|
|
|
|
const CAT_X = "aa".repeat(32);
|
|
const CAT_TWO = "bb".repeat(32);
|
|
// Each tx pays the beacon (proof it belongs there), carries the TREG/TUPD
|
|
// OP_RETURN, and mints/transfers the TLD NFT (proof of ownership).
|
|
const t1 = { txid: "t1", vin: [], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "x", { policy: "open", fee_bps: 500 }), nftMintOutput(CAT_X, "x")] };
|
|
const t2 = { txid: "t2", vin: [], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "two", { policy: "open" }), nftMintOutput(CAT_TWO, "two")] };
|
|
const t3 = { txid: "t3", vin: [{ txid: "t1", vout: 2 }], vout: [beaconOutput(rawTld), opReturnOutput("TUPD", "x", { policy: "covenant", fee_bps: 1000 }), nftMintOutput(CAT_X, "x", "bchtest:qnew-owner")] };
|
|
idx.processTx(t1, 100);
|
|
idx.processTx(t2, 100);
|
|
idx.processTx(t3, 101);
|
|
|
|
// Fake src with lastHeight / lastHash so the HTTP handler has something to
|
|
// report. Only the fields the handler reads are needed.
|
|
const src = { index: idx, lastHeight: 200, lastHash: "tip-hash" };
|
|
|
|
const PORT = 51100;
|
|
const server = startHttpServer(src, { bind: "127.0.0.1", port: PORT, tldBeacon: shTld });
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
|
|
const fetchJson = (path) => new Promise((res, rej) => {
|
|
const req = http.request({ hostname: "127.0.0.1", port: PORT, path, method: "GET" }, (r) => {
|
|
const chunks = [];
|
|
r.on("data", (c) => chunks.push(c));
|
|
r.on("end", () => {
|
|
try { res({ status: r.statusCode, body: JSON.parse(Buffer.concat(chunks).toString("utf8")) }); }
|
|
catch (e) { rej(e); }
|
|
});
|
|
});
|
|
req.on("error", rej);
|
|
req.end();
|
|
});
|
|
|
|
const { status, body } = await fetchJson("/tld-list.json");
|
|
ok(status === 200, "GET /tld-list.json returns 200");
|
|
ok(typeof body.root === "string" && /^[0-9a-f]{64}$/.test(body.root), "root is 64-char hex");
|
|
ok(body.asOfHeight === 200 && body.asOfTxid === "tip-hash", "asOfHeight/asOfTxid populated from src");
|
|
ok(body.tlds.length === 2, "two TLDs in snapshot");
|
|
const x = body.tlds.find((t) => t.tld === "x");
|
|
ok(x && x.records.policy === "covenant" && x.records.fee_bps === 1000, "TUPD applied to x");
|
|
ok(x && x.updatedTxid === "t3", "updatedTxid tracks TUPD");
|
|
ok(x && x.owner === "bchtest:qnew-owner", "owner refreshed by TUPD");
|
|
|
|
// Root recomputes locally over returned tlds[] — the "verify" path the design promises.
|
|
const canonical = JSON.stringify([...body.tlds].sort((a, b) => a.tld.localeCompare(b.tld)));
|
|
const recomputed = createHash("sha256").update(canonical, "utf8").digest("hex");
|
|
ok(recomputed === body.root, "client recomputes root from returned tlds[]");
|
|
|
|
// Health endpoint
|
|
const h = await fetchJson("/health");
|
|
ok(h.status === 200 && h.body.ok === true, "GET /health returns ok");
|
|
|
|
server.close();
|
|
|
|
// Direct buildTldSnapshot: TREG without a matching NFT mint commitment is rejected.
|
|
const bad = new Map();
|
|
bad.set("tbad", { txid: "tbad", vin: [], vout: [opReturnOutput("TREG", "no-mint", {}), nftMintOutput("cc".repeat(32), "wrong-commit")] });
|
|
const snapBad = buildTldSnapshot({ history: [{ tx_hash: "tbad", height: 100 }], txs: bad });
|
|
ok(snapBad.tlds.length === 0, "TREG without matching mint commitment is dropped");
|
|
|
|
// 503 when TLD beacon not configured
|
|
const src2 = { index: new BeaconIndex(["ff".repeat(32)]), lastHeight: 0, lastHash: null };
|
|
const PORT2 = 51101;
|
|
const server2 = startHttpServer(src2, { bind: "127.0.0.1", port: PORT2, tldBeacon: "" });
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
const { status: s503 } = await fetchJson.call(null, "/tld-list.json").catch(() => ({ status: 0 }));
|
|
// fetchJson uses PORT (the first server) — build a scoped fetch for PORT2:
|
|
const fetch2 = (path) => new Promise((res, rej) => {
|
|
const req = http.request({ hostname: "127.0.0.1", port: PORT2, path, method: "GET" }, (r) => {
|
|
const chunks = [];
|
|
r.on("data", (c) => chunks.push(c));
|
|
r.on("end", () => res({ status: r.statusCode, body: Buffer.concat(chunks).toString("utf8") }));
|
|
});
|
|
req.on("error", rej);
|
|
req.end();
|
|
});
|
|
const { status: sr503 } = await fetch2("/tld-list.json");
|
|
ok(sr503 === 503, "endpoint returns 503 when no TLD beacon configured");
|
|
server2.close();
|
|
})();
|
|
|
|
console.log(failures === 0 ? "\n🎉 ALL PASSED — multi-beacon + /tld-list.json wired correctly" : `\n💥 ${failures} check(s) failed`);
|
|
process.exit(failures === 0 ? 0 : 1);
|