argus/test/tld-index.test.mjs

253 lines
12 KiB
JavaScript
Raw Normal View History

BNS: per-TLD on-chain registry, multi-beacon indexer, enforcement gate 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).
2026-08-29 17:11:02 +02:00
// 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: [{ txid: CAT_X, vout: 0 }], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "x", { policy: "open", fee_bps: 500 }), nftMintOutput(CAT_X, "x")] };
const t2 = { txid: "t2", vin: [{ txid: CAT_TWO, vout: 0 }], vout: [beaconOutput(rawTld), opReturnOutput("TREG", "two", { policy: "open" }), nftMintOutput(CAT_TWO, "two")] };
BNS: per-TLD on-chain registry, multi-beacon indexer, enforcement gate 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).
2026-08-29 17:11:02 +02:00
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);