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).
This commit is contained in:
Local Dev 2026-08-29 17:11:02 +02:00
parent de696a8b9f
commit 9068eff9c7
14 changed files with 1280 additions and 57 deletions

View file

@ -12,6 +12,6 @@
"_dnsBind": "Listen address for the DNS server, kept separate from 'bind' on purpose. A recursive resolver reachable from the internet is a DDoS amplification relay, so opening the gateway must never open DNS as a side effect. Leave this on loopback.",
"dnsBind": "127.0.0.1",
"_tlds": "TLDs the local CA may issue TLS certificates for. Resolution stays TLD-agnostic; only issuance is enumerated, because the root CA is name-constrained and X.509 constraints need a finite list. CHANGING THIS REQUIRES REGENERATING THE ROOT CA and re-importing it on every machine (uninstall.ps1 -> delete ca/ -> install.ps1); until then clients reject certificates for the added TLDs. Never add an ICANN-delegated TLD. Omit this key to use the built-in default in src/lib/tlds.js.",
"tlds": ["bch", "p2p", "bit", "deviant", "nav", "test", "bcnr"]
"_tlds": "TLDs the local CA may issue TLS certificates for. Resolution stays TLD-agnostic; only issuance is enumerated, because the root CA is name-constrained and X.509 constraints need a finite list. CHANGING THIS REQUIRES REGENERATING THE ROOT CA and re-importing it on every machine (uninstall.ps1 -> delete ca/ -> install.ps1); until then clients reject certificates for the added TLDs. Never add an ICANN-delegated TLD. This is now a fallback/debug-override: install.ps1 sources the authoritative list from the per-TLD registry via fetchTldList (Decentralized.DNS/DESIGN-tld-registry.md §8). Omit the key to use the built-in default in src/lib/tlds.js.",
"tlds": ["bch", "p2p", "bit", "nav", "test", "x"]
}

9
package-lock.json generated
View file

@ -15,7 +15,8 @@
"@wizardconnect/dapp": "^0.2.2",
"express": "^5.2.1",
"mainnet-js": "^4.0.0-next.13",
"node-forge": "^1.4.0"
"node-forge": "^1.4.0",
"nostr-tools": "^2.24.3"
},
"devDependencies": {
"esbuild": "^0.28.1"
@ -1618,9 +1619,9 @@
}
},
"node_modules/nostr-tools": {
"version": "2.24.1",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.1.tgz",
"integrity": "sha512-KdrKjC74n/rr6J3eCSfZj8dcbZFvolHYe4S22SefNZ5YWbhHiB0KL/mmJjEZ0u6B9mZK0YcQtl+WQ46KzwapeQ==",
"version": "2.24.3",
"resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.24.3.tgz",
"integrity": "sha512-8vPgdWuje+H0v2bnxo1/hZOi8VLLMqGjfO+Oxqkx+J22r76rIt/iICKZKBoTXWAP2qTYKCQv89M4A3PRc48JYw==",
"license": "Unlicense",
"dependencies": {
"@noble/ciphers": "2.1.1",

View file

@ -20,7 +20,8 @@
"@wizardconnect/dapp": "^0.2.2",
"express": "^5.2.1",
"mainnet-js": "^4.0.0-next.13",
"node-forge": "^1.4.0"
"node-forge": "^1.4.0",
"nostr-tools": "^2.24.3"
},
"devDependencies": {
"esbuild": "^0.28.1"

View file

@ -38,20 +38,32 @@ if (-not (Test-Path $node)) {
$daemon = Join-Path $root "src\daemon\bnsd.js"
$stateFile = Join-Path $PSScriptRoot "install-state.json"
# Discover the set of currently activated BCNR TLDs from the on-chain root TLD
# certificate (tlds.bch) -- START-HERE.md Section 1. This drives both the CA's
# nameConstraints extension and the surgical-mode NRPT rules below, so adding a
# new TLD on-chain is picked up by simply re-running this installer (no code
# release). Falls back to the bootstrap set ([bch]) if the chain is unreachable.
# Discover the set of currently activated BCNR TLDs. Primary source is the
# per-TLD registry on the TLD beacon (Decentralized.DNS/DESIGN-tld-registry.md
# §7 fetchTldList); fallback to the legacy tlds.bch list-NFT
# (fetchBcnrTlds); final fallback to the local ariadne.config.json / built-in
# bootstrap set. This drives both the CA's nameConstraints extension and the
# surgical-mode NRPT rules below, so adding a new TLD on-chain is picked up by
# simply re-running this installer (no code release).
Push-Location $root
$activatedJson = & $node --input-type=module -e "import('./src/lib/resolver-web.js').then(async m=>{const ws=(await import('ws')).default;try{const list=await m.fetchBcnrTlds({WebSocket:ws});console.log(JSON.stringify(list||[]))}catch(e){console.error('bnsd fetchBcnrTlds:',String(e));console.log('[]')}}).catch(e=>{console.error(String(e));console.log('[]')})" 2>$null
$activatedJson = & $node --input-type=module -e "import('./src/lib/resolver-web.js').then(async m=>{const ws=(await import('ws')).default;try{const list=await m.fetchTldList({WebSocket:ws});if(list&&list.length&&!(list.length===1&&list[0]==='bch')){console.log(JSON.stringify(list));return}}catch(e){console.error('fetchTldList:',String(e))}try{const legacy=await m.fetchBcnrTlds({WebSocket:ws});console.log(JSON.stringify(legacy||[]))}catch(e){console.error('fetchBcnrTlds:',String(e));console.log('[]')}}).catch(e=>{console.error(String(e));console.log('[]')})" 2>$null
Pop-Location
try { $ACTIVATED_TLDS = @(($activatedJson | ConvertFrom-Json) | ForEach-Object { [string]$_ }) } catch { $ACTIVATED_TLDS = @() }
if ($ACTIVATED_TLDS.Count -eq 0) {
Write-Host "[!] tlds.bch was unreachable -- using bootstrap set: [bch]" -ForegroundColor Yellow
$ACTIVATED_TLDS = @("bch")
# Final fallback: read the ariadne.config.json tlds array we ship with.
try {
$cfg = Get-Content (Join-Path $root "ariadne.config.json") -Raw | ConvertFrom-Json
if ($cfg.tlds -and $cfg.tlds.Count -gt 0) {
$ACTIVATED_TLDS = @($cfg.tlds | ForEach-Object { [string]$_ })
Write-Host "[!] on-chain TLD registry unreachable -- using ariadne.config.json fallback" -ForegroundColor Yellow
}
} catch {}
if ($ACTIVATED_TLDS.Count -eq 0) {
Write-Host "[!] no TLD source available -- using bootstrap set: [bch]" -ForegroundColor Yellow
$ACTIVATED_TLDS = @("bch")
}
}
Write-Host "[ok] activated BCNR TLDs (from tlds.bch): $($ACTIVATED_TLDS -join ', ')"
Write-Host "[ok] activated TLDs (chain > config > bootstrap): $($ACTIVATED_TLDS -join ', ')"
# Generate the name-constrained local root CA now (on THIS machine; the key never
# ships) so the trust-store import below always finds a valid, constrained root

View file

@ -1,4 +1,9 @@
export const BEACON = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
// TLD-certificate beacon (Decentralized.DNS/DESIGN-tld-registry.md §3).
// Distinct from the name BEACON so a client that only wants the TLD list can
// walk one address instead of every name-registration transaction. Chipnet.
// Scripthash (electrum encoding): c85db10e106606273422b2733b2a6fb8b35a4d0e7dbfb20cf59bdb0833c008b8
export const TLD_BEACON = "bchtest:qzc4c76vtw9nd49g6x8nmvc0jz62uvk4dc7hmpmerh";
export const PREFIX = "BNS1";
export const TLD = ".bch";
export const BEACON_DUST_SAT = 600;

View file

@ -21,6 +21,7 @@ import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { WebSocketServer } from "ws";
import { buildTldSnapshot } from "../lib/tld-index.js";
// ---- config (env, with sane chipnet defaults) ----
const CONFIG = {
@ -33,6 +34,16 @@ const CONFIG = {
// The beacon scripthash the resolver scans (constant; keep in sync with resolver-web.js).
beacon: process.env.BNS_BEACON_SCRIPTHASH ||
"e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b",
// TLD-certificate beacon scripthash for TREG/TUPD (BNS2 TLD registry).
// Defaults to the chipnet TLD beacon defined in src/config.js:
// bchtest:qzc4c76vtw9nd49g6x8nmvc0jz62uvk4dc7hmpmerh
// Set to "" to disable TLD indexing (the /tld-list.json endpoint then 503s).
// See Decentralized.DNS/DESIGN-tld-registry.md §7.
tldBeacon: process.env.BNS_TLD_BEACON_SCRIPTHASH ||
"c85db10e106606273422b2733b2a6fb8b35a4d0e7dbfb20cf59bdb0833c008b8",
// HTTP port for the /tld-list.json snapshot endpoint. 0 disables.
httpPort: Number(process.env.BNS_HTTP_PORT || 0),
httpBind: process.env.BNS_HTTP_BIND || "127.0.0.1",
// Skip blocks before BNS existed on chipnet — set this to shrink the first scan.
startHeight: Number(process.env.BNS_START_HEIGHT || 0),
pollMs: Number(process.env.BNS_POLL_MS || 10000),
@ -78,43 +89,128 @@ function makeRpc(cfg) {
}
// ---- the beacon index (source-independent; this is what the WS layer serves) ----
//
// Multi-beacon: one BeaconIndex can track more than one beacon scripthash and
// serve each independently via blockchain.scripthash.get_history. Used to run
// the name beacon and the TLD-certificate beacon (Decentralized.DNS/
// DESIGN-tld-registry.md §7) off one indexer + one BCHN.
//
// Single-beacon back-compat: `new BeaconIndex(str)`, `seed(history, txMap)` and
// argless `getHistory()` all keep working, so `test/bns-indexer.test.mjs` and
// any external caller that predates multi-beacon do not need to change.
export class BeaconIndex {
constructor(beacon) {
this.beacon = beacon;
this.history = new Map(); // txid -> height (0 = mempool)
this.txCache = new Map(); // txid -> verbose tx (as returned by getblock v2)
this.utxos = new Set(); // "txid:n" outputs paying the beacon (to detect spends)
constructor(beacons) {
// Accept string (single) OR iterable of strings (multi).
const list = typeof beacons === "string" ? [beacons] : [...beacons];
if (list.length === 0) throw new Error("BeaconIndex needs at least one beacon");
this.beacons = new Set(list);
// Per-beacon state: history (txid->height), txCache (txid->verbose tx),
// utxos (Set of "txid:n" outputs paying THIS beacon, used to detect spends).
this.state = new Map();
for (const b of list) this.state.set(b, { history: new Map(), txCache: new Map(), utxos: new Set() });
}
// Record a tx if it touches the beacon (pays it, or spends one of its outputs).
// Back-compat convenience: expose `.beacon` when there's exactly one.
get beacon() { return this.beacons.size === 1 ? [...this.beacons][0] : undefined; }
// Sum of history across all beacons — used only for log lines.
get historySize() { let n = 0; for (const s of this.state.values()) n += s.history.size; return n; }
// Add a beacon after construction (e.g. TLD beacon added after startup).
// The new beacon starts empty and needs backfilling — the caller decides how.
addBeacon(scripthash) {
if (this.beacons.has(scripthash)) return;
this.beacons.add(scripthash);
this.state.set(scripthash, { history: new Map(), txCache: new Map(), utxos: new Set() });
}
// Record a tx if it touches ANY tracked beacon (pays it or spends one of its
// outputs). Returns true iff at least one beacon was touched — same shape as
// the single-beacon return, so callers that just want "did this tx matter?"
// are unchanged.
processTx(tx, height) {
let touches = false;
const vout = tx.vout || [];
for (let n = 0; n < vout.length; n++) {
const hex = vout[n].scriptPubKey && vout[n].scriptPubKey.hex;
if (hex && scripthashOf(hex) === this.beacon) { touches = true; this.utxos.add(tx.txid + ":" + n); }
let anyTouch = false;
for (const [beacon, s] of this.state) {
let touches = false;
const vout = tx.vout || [];
for (let n = 0; n < vout.length; n++) {
const hex = vout[n].scriptPubKey && vout[n].scriptPubKey.hex;
if (hex && scripthashOf(hex) === beacon) { touches = true; s.utxos.add(tx.txid + ":" + n); }
}
for (const vin of tx.vin || []) {
if (vin.txid) { const k = vin.txid + ":" + vin.vout; if (s.utxos.has(k)) { touches = true; s.utxos.delete(k); } }
}
if (touches) { s.history.set(tx.txid, height); s.txCache.set(tx.txid, tx); anyTouch = true; }
}
for (const vin of tx.vin || []) {
if (vin.txid) { const k = vin.txid + ":" + vin.vout; if (this.utxos.has(k)) { touches = true; this.utxos.delete(k); } }
}
if (touches) { this.history.set(tx.txid, height); this.txCache.set(tx.txid, tx); }
return touches;
return anyTouch;
}
getHistory() {
return [...this.history.entries()]
// Argless form (single-beacon back-compat) returns the sole beacon's history.
// With multiple beacons the caller MUST pass a scripthash — that's the WS
// handler's dispatch path.
getHistory(scripthash) {
if (scripthash === undefined) {
if (this.beacons.size !== 1) throw new Error("getHistory() requires a scripthash when the index tracks multiple beacons");
scripthash = [...this.beacons][0];
}
const s = this.state.get(scripthash);
if (!s) return [];
return [...s.history.entries()]
.map(([tx_hash, height]) => ({ tx_hash, height }))
.sort((a, b) => (a.height - b.height) || (a.tx_hash < b.tx_hash ? -1 : 1));
}
// Seed directly (used by tests / a proxy backfill).
seed(history, txMap) {
for (const { tx_hash, height } of history) this.history.set(tx_hash, height);
if (txMap) for (const [id, tx] of Object.entries(txMap)) this.txCache.set(id, tx);
// Cross-beacon tx lookup — a transaction the indexer cached under one beacon
// is available via getTx regardless of which beacon it belongs to.
getTx(txid) {
for (const s of this.state.values()) if (s.txCache.has(txid)) return s.txCache.get(txid);
return null;
}
// Seed directly (used by tests / a proxy backfill). Two forms:
// seed(history, txMap) — single-beacon back-compat
// seed(scripthash, history, txMap) — multi-beacon
seed(a, b, c) {
let scripthash, history, txMap;
if (typeof a === "string") { scripthash = a; history = b; txMap = c; }
else {
if (this.beacons.size !== 1) throw new Error("seed() requires a scripthash when the index tracks multiple beacons");
scripthash = [...this.beacons][0]; history = a; txMap = b;
}
const s = this.state.get(scripthash);
if (!s) throw new Error(`unknown beacon ${scripthash}`);
for (const { tx_hash, height } of history) s.history.set(tx_hash, height);
if (txMap) for (const [id, tx] of Object.entries(txMap)) s.txCache.set(id, tx);
}
toJSON() {
return { history: [...this.history], txCache: [...this.txCache], utxos: [...this.utxos] };
// v2 shape: one bucket per beacon.
const out = { version: 2, beacons: {} };
for (const [b, s] of this.state) {
out.beacons[b] = { history: [...s.history], txCache: [...s.txCache], utxos: [...s.utxos] };
}
return out;
}
static fromJSON(beacon, j) {
const idx = new BeaconIndex(beacon);
if (j) { idx.history = new Map(j.history || []); idx.txCache = new Map(j.txCache || []); idx.utxos = new Set(j.utxos || []); }
// Rebuild from a serialised state. Accepts BOTH v2 and legacy v1 shapes:
// v2: { version: 2, beacons: { [scripthash]: { history, txCache, utxos } } }
// v1: { history, txCache, utxos } — was single-beacon
//
// Legacy v1 state loads into whichever beacon the caller passed as the first
// arg (or, for multi-beacon callers, the first beacon in the set). That
// preserves state across upgrades from single- to multi-beacon deployments
// without a rescan. Beacons present in v2 state but no longer configured are
// dropped silently (stale-beacon cleanup).
static fromJSON(beacons, j) {
const idx = new BeaconIndex(beacons);
if (!j) return idx;
if (j.version === 2 && j.beacons) {
for (const [b, sj] of Object.entries(j.beacons)) {
if (!idx.beacons.has(b)) continue; // stale beacon in state file — drop
const s = idx.state.get(b);
s.history = new Map(sj.history || []);
s.txCache = new Map(sj.txCache || []);
s.utxos = new Set(sj.utxos || []);
}
} else if (j.history || j.txCache || j.utxos) {
// Legacy v1 → migrate into the first configured beacon.
const target = [...idx.beacons][0];
const s = idx.state.get(target);
s.history = new Map(j.history || []);
s.txCache = new Map(j.txCache || []);
s.utxos = new Set(j.utxos || []);
}
return idx;
}
}
@ -129,9 +225,20 @@ export class RpcSource {
if (existsSync(this.cfg.stateFile)) {
try {
const j = JSON.parse(readFileSync(this.cfg.stateFile, "utf8"));
this.index = BeaconIndex.fromJSON(this.cfg.beacon, j.index);
// Hydrate against the CONFIGURED beacon set — that's how a beacon added
// via env (BNS_TLD_BEACON_SCRIPTHASH) picks up state on a restart, and
// how a beacon removed from config is dropped from state silently.
this.index = BeaconIndex.fromJSON([...this.index.beacons], j.index);
this.lastHeight = j.lastHeight ?? -1; this.lastHash = j.lastHash ?? null;
log(`loaded state: height ${this.lastHeight}, ${this.index.history.size} beacon txs`);
log(`loaded state: height ${this.lastHeight}, ${this.index.historySize} beacon txs across ${this.index.beacons.size} beacon(s)`);
// Warn about beacons that were added since last scan and now need
// backfill — the operator should reset the state file if they want
// history for the new beacon.
for (const b of this.index.beacons) {
if (this.index.state.get(b).history.size === 0 && this.lastHeight > this.cfg.startHeight) {
log(`WARNING: beacon ${b.slice(0, 16)}… has no state — added after last scan. Delete ${this.cfg.stateFile} and restart to backfill.`);
}
}
} catch (e) { log("state load failed, starting fresh:", e.message); }
}
return this.index;
@ -159,7 +266,13 @@ export class RpcSource {
if (h !== this.lastHash) {
const rollback = Math.max(this.cfg.startHeight, this.lastHeight - 10);
log(`reorg detected at ${this.lastHeight} — rolling back to ${rollback}`);
for (const [txid, ht] of [...this.index.history]) if (ht >= rollback) { this.index.history.delete(txid); this.index.txCache.delete(txid); }
// Roll back per-beacon: a reorg drops every recorded tx at ≥ rollback
// from every beacon's history AND cache. UTXOs derived below rollback
// are unaffected — they were recorded from confirmed blocks that
// survive the reorg.
for (const s of this.index.state.values()) {
for (const [txid, ht] of [...s.history]) if (ht >= rollback) { s.history.delete(txid); s.txCache.delete(txid); }
}
this.lastHeight = rollback - 1;
}
} catch {}
@ -169,17 +282,23 @@ export class RpcSource {
log(`scanning blocks ${from}..${tip}`);
for (let h = from; h <= tip; h++) {
await this.scanBlock(h);
if (h % 1000 === 0) { this.save(); log(`… ${h}/${tip} (${this.index.history.size} beacon txs)`); }
if (h % 1000 === 0) { this.save(); log(`… ${h}/${tip} (${this.index.historySize} beacon txs)`); }
}
this.save();
log(`up to date at ${tip} — ${this.index.history.size} beacon txs`);
log(`up to date at ${tip} — ${this.index.historySize} beacon txs across ${this.index.beacons.size} beacon(s)`);
return tip - from + 1;
}
async scanMempool() {
try {
const ids = await this.rpc("getrawmempool", []);
// "already seen" means this txid is in ANY beacon's history — no need to
// refetch and re-dispatch.
const seen = (txid) => {
for (const s of this.index.state.values()) if (s.history.has(txid)) return true;
return false;
};
for (const txid of ids) {
if (this.index.history.has(txid)) continue;
if (seen(txid)) continue;
const tx = await this.rpc("getrawtransaction", [txid, true]).catch(() => null);
if (tx) this.index.processTx(tx, 0);
}
@ -197,7 +316,7 @@ export class RpcSource {
// ---- electrum-over-WebSocket server (newline-delimited JSON-RPC) ----
export function startElectrumWS(index, { bind, port }, rpc) {
const getTx = async (txid, verbose) => {
if (verbose) return index.txCache.get(txid) || (rpc ? await rpc("getrawtransaction", [txid, true]) : null);
if (verbose) return index.getTx(txid) || (rpc ? await rpc("getrawtransaction", [txid, true]) : null);
return rpc ? await rpc("getrawtransaction", [txid, false]) : null;
};
async function handle(req) {
@ -206,8 +325,11 @@ export function startElectrumWS(index, { bind, port }, rpc) {
if (method === "server.version") return { id, result: ["BNS-Indexer/0.1", "1.4.3"] };
if (method === "server.ping") return { id, result: null };
if (method === "server.banner") return { id, result: "BNS-only indexer" };
// Dispatch by requested scripthash across the multi-beacon index.
// getHistory returns [] for unknown scripthashes, so a client asking for
// a beacon we don't track gets the same reply as before: no history.
if (method === "blockchain.scripthash.get_history")
return { id, result: params[0] === index.beacon ? index.getHistory() : [] };
return { id, result: index.getHistory(params[0]) };
if (method === "blockchain.transaction.get")
return { id, result: await getTx(params[0], params[1]) };
return { id, error: { code: -32601, message: "unsupported method: " + method } };
@ -229,14 +351,58 @@ export function startElectrumWS(index, { bind, port }, rpc) {
return wss;
}
// ---- HTTP: /tld-list.json snapshot endpoint ----
// Not part of consensus. Serves the TLD registry snapshot described in
// Decentralized.DNS/DESIGN-tld-registry.md §7. Any conforming client can
// verify `root` by recomputing it over the returned `tlds[]`, so a malicious
// mirror is caught by any recipient that bothers to check.
//
// If no TLD beacon is configured (BNS_TLD_BEACON_SCRIPTHASH unset), the
// endpoint returns 503 rather than an empty list — silence would be
// indistinguishable from "no TLDs exist", which is the wrong signal.
export function startHttpServer(src, { bind, port, tldBeacon }) {
const server = http.createServer((req, res) => {
const send = (code, body, type = "application/json") => {
res.writeHead(code, { "content-type": type, "access-control-allow-origin": "*" });
res.end(body);
};
if (req.method === "GET" && (req.url === "/tld-list.json" || req.url === "/tld-list")) {
if (!tldBeacon) return send(503, JSON.stringify({ error: "no TLD beacon configured (set BNS_TLD_BEACON_SCRIPTHASH)" }));
const state = src.index.state.get(tldBeacon);
if (!state) return send(503, JSON.stringify({ error: "TLD beacon not tracked by this index" }));
const history = [...state.history.entries()].map(([tx_hash, height]) => ({ tx_hash, height }));
const snap = buildTldSnapshot({
history,
txs: state.txCache,
asOfHeight: src.lastHeight,
asOfTxid: src.lastHash,
});
return send(200, JSON.stringify(snap));
}
if (req.method === "GET" && req.url === "/health") {
return send(200, JSON.stringify({ ok: true, lastHeight: src.lastHeight, beacons: src.index.beacons.size }));
}
send(404, "not found\n", "text/plain");
});
server.listen(port, bind);
log(`http listening on ${bind}:${port} (GET /tld-list.json, /health)`);
return server;
}
// ---- main ----
async function main() {
const rpc = makeRpc(CONFIG);
const chain = await rpc("getblockchaininfo", []).catch((e) => { throw new Error(`cannot reach BCHN at ${CONFIG.rpcUrl}: ${e.message}`); });
log(`connected to BCHN: chain=${chain.chain} blocks=${chain.blocks}`);
const src = new RpcSource(new BeaconIndex(CONFIG.beacon), CONFIG, rpc);
const beacons = [CONFIG.beacon];
if (CONFIG.tldBeacon) beacons.push(CONFIG.tldBeacon);
log(`tracking ${beacons.length} beacon(s): ${beacons.map((b) => b.slice(0, 16) + "…").join(", ")}`);
const src = new RpcSource(new BeaconIndex(beacons), CONFIG, rpc);
src.load();
startElectrumWS(src.index, { bind: CONFIG.wsBind, port: CONFIG.wsPort }, rpc);
if (CONFIG.httpPort > 0) {
startHttpServer(src, { bind: CONFIG.httpBind, port: CONFIG.httpPort, tldBeacon: CONFIG.tldBeacon });
}
await src.follow();
}

View file

@ -1,6 +1,6 @@
// BNS1 core: payload codec, chain indexer/resolver, and tx builders.
import { OpReturnData, TokenSendRequest, NFTCapability } from "mainnet-js";
import { BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.js";
import { BEACON, TLD_BEACON, PREFIX, BEACON_DUST_SAT, NAME_RE } from "../config.js";
// ---------- payload codec ----------
@ -47,11 +47,20 @@ export function parseOpReturn(scriptHex) {
if (!data.startsWith(PREFIX)) return null;
try {
const p = JSON.parse(data.slice(PREFIX.length));
if ((p.o === "REG" || p.o === "UPD") && typeof p.n === "string") return p;
// 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.
@ -89,8 +98,70 @@ function orderByDependency(history, txs) {
return ordered;
}
// Builds { name -> { name, category, records, txid, height, updatedTxid } }
export async function buildIndex(provider) {
// 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);
@ -117,6 +188,9 @@ export async function buildIndex(provider) {
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);
@ -199,3 +273,58 @@ export async function updateName(wallet, input, records) {
]);
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 };
}

View file

@ -34,6 +34,8 @@ export function fetchBcnrTlds(opts?: {}): Promise<string[]>;
export function isBcnrNativeTld(tldOrList: any, tld: any): boolean;
export const BEACON: "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
export const BEACON_SCRIPTHASH: "e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b";
export const TLD_BEACON: "bchtest:qzc4c76vtw9nd49g6x8nmvc0jz62uvk4dc7hmpmerh";
export const TLD_BEACON_SCRIPTHASH: "c85db10e106606273422b2733b2a6fb8b35a4d0e7dbfb20cf59bdb0833c008b8";
export const PREFIX: "BNS1";
export const TLD: ".bch";
export const DEFAULT_TLD: "bch";

View file

@ -14,6 +14,12 @@ export const BEACON = "bchtest:qp8njaa9a3ahg7259s5qajnce526n0rttsq8qwx4jl";
// Constant — the beacon never changes.
export const BEACON_SCRIPTHASH =
"e1cdbba719436f49acb4bf245b44df875c6d6b405eda46a9d3e736d138102f3b";
// TLD-certificate beacon (Decentralized.DNS/DESIGN-tld-registry.md §3).
// Distinct from the name BEACON so clients can walk the TLD registry without
// touching every name registration. Chipnet.
export const TLD_BEACON = "bchtest:qzc4c76vtw9nd49g6x8nmvc0jz62uvk4dc7hmpmerh";
export const TLD_BEACON_SCRIPTHASH =
"c85db10e106606273422b2733b2a6fb8b35a4d0e7dbfb20cf59bdb0833c008b8";
export const PREFIX = "BNS1";
export const TLD = ".bch";
export const DEFAULT_TLD = "bch";
@ -173,11 +179,20 @@ function parseOpReturnHex(hex) {
if (!data.startsWith(PREFIX)) return null;
try {
const p = JSON.parse(data.slice(PREFIX.length));
if ((p.o === "REG" || p.o === "UPD") && typeof p.n === "string") return p;
// 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 bns.js parseOpReturn — memory rule.
if (KNOWN_OPS.has(p.o) && typeof p.n === "string") return p;
} catch {}
return null;
}
// The set of payload opcodes this codec recognises. New opcodes go here AND in
// bns.js — divergence is a consensus split.
export const KNOWN_OPS = new Set(["REG", "UPD", "TREG", "TUPD"]);
// ---- ordering ----
// Baseline order: block height, then txid. Unconfirmed (height <= 0) last.
@ -224,12 +239,71 @@ function orderByDependency(history, txs) {
return ordered;
}
// TLD of a canonical index key ("<label>.<tld>") — always the rightmost label,
// since canonicalKey always emits "label.tld" form.
function tldOfKey(key) {
const i = key.lastIndexOf(".");
return i < 0 ? key : key.slice(i + 1);
}
// Enforcement gate: a name REG is admitted iff its TLD has a live TLD cert on
// the TLD beacon at a height ≤ the REG's height. Mempool-friendly: both-in-
// mempool is admissible. KEEP IN LOCKSTEP with bns.js tldAdmits.
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;
}
// Walk the TLD beacon and return Map<tld, {category, regHeight, regTxid}>.
// KEEP IN LOCKSTEP with bns.js buildTldMap — divergence is a consensus split.
async function fetchTldMap(el) {
const history = typeof el.unionHistory === "function"
? (await el.unionHistory(TLD_BEACON_SCRIPTHASH)).history
: await el.call("blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]);
history.sort(byHeightThenTxid);
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await el.call("blockchain.transaction.get", [tx_hash, true])); }
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);
const vouts = tx.vout || [];
let payload = null;
for (const v of vouts) payload = parseOpReturnHex(v.scriptPubKey?.hex) ?? payload;
if (!payload) continue;
if (typeof payload.n !== "string" || !TLD_RE.test(payload.n)) continue;
const tld = payload.n;
if (payload.o === "TREG") {
if (tlds.has(tld)) continue; // first valid TREG wins
const commitHex = strToHex(tld);
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
if (!mint) continue;
tlds.set(tld, { category: mint.tokenData.category, regHeight: height, regTxid: tx_hash });
}
// TUPDs are irrelevant to the enforcement map — we only need existence + regHeight.
}
return tlds;
}
// ---- the resolver ----
// Pass `client` to reuse an already-open Electrum connection (the registrar
// does this); in that case the caller owns it and we leave it open.
export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, client, directIP = false } = {}) {
//
// `enforceTld: true` applies the TLD-registry gate (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. Off by
// default while the mainnet migration story lands.
export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, client, directIP = false, enforceTld = false } = {}) {
const el = client ?? (await connectAny(electrum, WS, { directIP }));
try {
// Fetch the TLD map first (single electrum connection, one extra roundtrip).
// We need it before deciding which name REGs to keep. Only when enforcing.
const tldMap = enforceTld ? await fetchTldMap(el) : null;
// With a pool, take the UNION of every server's beacon history. A single
// server can silently omit a registration, and because "first valid REG
// wins" that makes a taken name look free — the buyer pays for a duplicate
@ -273,6 +347,9 @@ export async function buildIndex({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, c
if (payload.o === "REG") {
if (names.has(key)) continue; // first valid REG wins
// TLD gate — see §4 of the design. Only applies to REG; UPDs modify
// names already admitted, so no gate needed there.
if (enforceTld && !tldAdmits(tldOfKey(key), height, tldMap)) continue;
const commitHex = strToHex(rawName); // commitment matches the raw on-chain name
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
if (!mint) continue;
@ -356,9 +433,32 @@ export function getBcnrTlds(index) {
}
// Convenience: build the index, return the activated TLD set. Empty root
// certificate or unreachable chain -> BOOTSTRAP_TLDS.
//
// LEGACY — this reads the single-NFT `tlds.bch` list. The per-TLD registry
// (Decentralized.DNS/DESIGN-tld-registry.md) supersedes it; new callers should
// use fetchTldList() below, which reads the TLD beacon directly. Left in place
// for backwards compatibility with older install.ps1 / build clients.
export async function fetchBcnrTlds(opts = {}) {
return getBcnrTlds(await buildIndex(opts));
}
// Walk the TLD beacon and return the current TLD set as a plain string[].
// This is the source of truth for the CA's nameConstraints extension and for
// the daemon's isPermittedHost check. Falls back to BOOTSTRAP_TLDS if the
// chain is unreachable (same shape as fetchBcnrTlds) — an installer that
// cannot reach any electrum still gets a working (if minimal) root CA.
export async function fetchTldList({ electrum = CHIPNET_ELECTRUM, WebSocket: WS, client, directIP = false } = {}) {
const el = client ?? (await connectAny(electrum, WS, { directIP }));
try {
const tldMap = await fetchTldMap(el);
const list = [...tldMap.keys()].sort();
return list.length ? list : BOOTSTRAP_TLDS.slice();
} catch {
return BOOTSTRAP_TLDS.slice();
} finally {
if (!client) el.close();
}
}
// Is this TLD activated on-chain? Case-insensitive.
export function isBcnrNativeTld(tldOrList, tld) {
const list = Array.isArray(tldOrList) ? tldOrList : [tldOrList];

145
src/lib/tld-index.js Normal file
View file

@ -0,0 +1,145 @@
// Build a `tld-list.json` snapshot from a TLD beacon's history and tx cache.
//
// Same shape as the tracker described in
// Decentralized.DNS/DESIGN-tld-registry.md §7. Pure function: takes raw beacon
// history + verbose transactions (as produced by BCHN's getblock v2), returns
// { root, asOfHeight, asOfTxid, tlds: [...] }. No I/O, no imports of the
// electrum/RPC layers — so tests can drive it with fixtures and the indexer
// can mount it on its HTTP surface without pulling anything else along.
//
// Ordering rules mirror bns.js / resolver-web.js buildIndex:
// • (height, txid) baseline
// • dependency-order pass so a TUPD is never evaluated before its TREG
// (memory: "resolver-web.js and bns.js must stay in lockstep — a
// disagreement is a consensus split between clients"; that lockstep
// extends to this snapshot too, since the point of `root` is that any
// honest producer computes the same bytes over the same chain state).
import { createHash } from "node:crypto";
import { parseOpReturn } from "./bns.js";
const TLD_RE = /^[a-z0-9-]{1,16}$/;
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 < b.tx_hash ? -1 : 1);
}
// O(n^2) topo pass — n is the TLD-beacon history size, which stays small.
function orderByDependency(history, txMap) {
const inSet = new Set(history.map((h) => h.tx_hash));
const deps = new Map(
history.map((h) => {
const spent = new Set();
const tx = txMap.get ? txMap.get(h.tx_hash) : txMap[h.tx_hash];
for (const vin of tx?.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;
}
// Best-effort owner extraction from a token-bearing output. BCHN's verbose tx
// exposes either `scriptPubKey.address` (newer) or `scriptPubKey.addresses[]`
// (older/deprecated); we accept both and fall through to null so the snapshot
// stays well-formed either way. The chain itself is the authority — this is a
// display-side convenience the caller may want to render.
function ownerFromVout(vout) {
if (!vout || !vout.scriptPubKey) return null;
const sp = vout.scriptPubKey;
if (typeof sp.address === "string") return sp.address;
if (Array.isArray(sp.addresses) && sp.addresses.length) return sp.addresses[0];
return null;
}
// Canonical serialisation: sort tlds[] by label, then JSON.stringify. That is
// what `root` is computed over, so every honest producer converges on the
// same bytes for the same chain state.
function canonicalise(tlds) {
return JSON.stringify([...tlds].sort((a, b) => a.tld.localeCompare(b.tld)));
}
const asHexBuf = (s) => Buffer.from(s, "utf8").toString("hex");
/**
* @param {object} inputs
* @param {Array<{tx_hash: string, height: number}>} inputs.history
* @param {Map<string, object> | Record<string, object>} inputs.txs
* Verbose transactions keyed by txid (BCHN getblock v2 shape).
* @param {number|null} [inputs.asOfHeight]
* @param {string|null} [inputs.asOfTxid]
* @returns {{root: string, asOfHeight: number|null, asOfTxid: string|null, tlds: object[]}}
*/
export function buildTldSnapshot({
history = [],
txs,
asOfHeight = null,
asOfTxid = null,
} = {}) {
const txMap = txs instanceof Map ? txs : new Map(Object.entries(txs || {}));
const sorted = [...history].sort(byHeightThenTxid);
const usable = sorted.filter((h) => txMap.has(h.tx_hash));
const ordered = orderByDependency(usable, txMap);
const tlds = new Map(); // tld -> { tld, category, regHeight, regTxid, updatedTxid, records, owner }
for (const { tx_hash, height } of ordered) {
const tx = txMap.get(tx_hash);
if (!tx) continue;
const vouts = tx.vout || [];
let payload = null;
for (const v of vouts) payload = parseOpReturn(v.scriptPubKey?.hex) ?? payload;
if (!payload) continue;
if (typeof payload.n !== "string" || !TLD_RE.test(payload.n)) continue;
const tld = payload.n;
if (payload.o === "TREG") {
if (tlds.has(tld)) continue; // first valid TREG wins — same rule as REG
const commitHex = asHexBuf(tld);
const mint = vouts.find((v) => v.tokenData?.nft && v.tokenData.nft.commitment === commitHex);
if (!mint) continue; // TREG payload without a matching mint output is not authoritative
tlds.set(tld, {
tld,
category: mint.tokenData.category,
regHeight: height,
regTxid: tx_hash,
updatedTxid: null,
records: payload.r ?? {},
owner: ownerFromVout(mint),
});
} else if (payload.o === "TUPD") {
const entry = tlds.get(tld);
if (!entry) continue;
// A TUPD is authoritative only if this tx spends and re-outputs the TLD's
// NFT: an output carrying the same category proves the owner authorised
// the change. Same rule the name UPD uses for its category proof.
const carrier = vouts.find((v) => v.tokenData && v.tokenData.category === entry.category);
if (!carrier) continue;
entry.records = payload.r ?? {};
entry.updatedTxid = tx_hash;
const owner = ownerFromVout(carrier);
if (owner) entry.owner = owner;
}
// Other opcodes (REG/UPD, unknown) are silently ignored — a TLD beacon
// configured on the same address as the name beacon would land here and
// the snapshot skips them cleanly. Separate beacons remain the sanctioned
// configuration; this is a safety net.
}
const arr = [...tlds.values()].sort((a, b) => a.tld.localeCompare(b.tld));
const root = createHash("sha256").update(canonicalise(arr), "utf8").digest("hex");
return { root, asOfHeight, asOfTxid, tlds: arr };
}

160
src/publish-tld-mirror.mjs Normal file
View file

@ -0,0 +1,160 @@
// Publish the current TLD-registry snapshot to public mirrors:
// • Sia S3 — both a floating key (bns/tld-list.json) and a content-addressed
// immutable key (bns/tld-list-<root>.json). Consumers pin the root and
// fetch the immutable key when they need proof; the floating key is the
// "latest" pointer.
// • Nostr — a NIP-33 replaceable event (kind 30078, d-tag bns-tld-list)
// carrying {root, asOfHeight, asOfTxid, sia_url}, signed by the operator's
// Nostr key derived from the same wallet seed (Hermes key derivation).
//
// Snapshot source: the on-chain TLD beacon, walked via the wallet's provider.
// Works even when the indexer isn't running locally — same code path
// buildTldSnapshot uses in the indexer's HTTP endpoint. Design: §7 of
// Decentralized.DNS/DESIGN-tld-registry.md.
//
// Environment:
// BNS_MIRROR_SIA_BUCKET default "bns"
// BNS_MIRROR_SIA_PREFIX default "" — set to "tld/" to nest by category
// BNS_MIRROR_NOSTR_RELAYS default "wss://nos.lol,wss://relay.damus.io"
// BNS_MIRROR_DRY_RUN=1 build + hash + log, do NOT publish anywhere
//
// Idempotent: content-addressed Sia key changes only when the snapshot's root
// changes; the Nostr replaceable event is scoped to (pubkey, kind, d-tag) so
// re-publishing just replaces the prior copy at each relay.
import { readFileSync } from "node:fs";
import { S3Client, PutObjectCommand, HeadBucketCommand, CreateBucketCommand } from "@aws-sdk/client-s3";
import WebSocket from "ws";
import { finalizeEvent } from "nostr-tools/pure";
import { loadWallet } from "./lib/wallet.js";
import { buildTldSnapshot } from "./lib/tld-index.js";
import { TLD_BEACON, WALLET_FILE } from "./config.js";
import { nostrKeyFromMnemonic } from "./lib/hermes-derive.js";
import { fileURLToPath } from "node:url";
const DRY = process.env.BNS_MIRROR_DRY_RUN === "1";
const BUCKET = process.env.BNS_MIRROR_SIA_BUCKET || "bns";
const PREFIX = process.env.BNS_MIRROR_SIA_PREFIX || "";
const RELAYS = (process.env.BNS_MIRROR_NOSTR_RELAYS || "wss://nos.lol,wss://relay.damus.io")
.split(",").map((s) => s.trim()).filter(Boolean);
const log = (...a) => console.log(new Date().toISOString(), ...a);
// ---------- 1) build snapshot ----------
log("building snapshot from TLD beacon", TLD_BEACON, "…");
const wallet = await loadWallet("main");
const history = await wallet.provider.getHistory(TLD_BEACON);
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await wallet.provider.getRawTransactionObject(tx_hash)); }
catch { /* skip */ }
}
const snap = buildTldSnapshot({
history,
txs,
asOfHeight: null, // no BCHN tip available here — the indexer fills these in
asOfTxid: null,
});
const body = JSON.stringify(snap, null, 2);
log(`snapshot: ${snap.tlds.length} TLDs, root=${snap.root}`);
if (DRY) {
log("BNS_MIRROR_DRY_RUN=1 — not publishing. Snapshot preview:");
for (const t of snap.tlds) log(` - ${t.tld.padEnd(8)} cat=${t.category.slice(0, 16)}…`);
process.exit(0);
}
// ---------- 2) publish to Sia (both floating + content-addressed keys) ----------
let siaUrl = null;
try {
const cfg = JSON.parse(readFileSync(new URL("../sia-s3.json", import.meta.url), "utf8"));
const s3 = new S3Client({
endpoint: cfg.endpoint ?? "http://127.0.0.1:8000",
region: "us-east-1",
forcePathStyle: true,
credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey },
});
// Ensure the bucket exists — same idempotent pattern as sia-upload.js.
try { await s3.send(new HeadBucketCommand({ Bucket: BUCKET })); }
catch { await s3.send(new CreateBucketCommand({ Bucket: BUCKET })); log(`created bucket "${BUCKET}"`); }
const floating = `${PREFIX}tld-list.json`;
const immutable = `${PREFIX}tld-list-${snap.root}.json`;
for (const key of [floating, immutable]) {
// ACL=public-read so anonymous consumers can fetch — Sia s3d defaults to
// private buckets, and a private mirror is useless. If the s3 backend
// refuses ACLs, fall back to putting without ACL (the operator can set a
// bucket policy out-of-band).
try {
await s3.send(new PutObjectCommand({
Bucket: BUCKET, Key: key, Body: body,
ContentType: "application/json",
CacheControl: key === floating ? "public, max-age=60" : "public, max-age=31536000, immutable",
ACL: "public-read",
}));
} catch (aclErr) {
// Retry without ACL — some S3-compatible backends reject the header.
await s3.send(new PutObjectCommand({
Bucket: BUCKET, Key: key, Body: body,
ContentType: "application/json",
CacheControl: key === floating ? "public, max-age=60" : "public, max-age=31536000, immutable",
}));
log(` (ACL=public-read rejected for ${key}: ${aclErr.message} — uploaded without ACL; operator must set bucket policy for anon reads)`);
}
log(` uploaded s3://${BUCKET}/${key}`);
}
siaUrl = `${cfg.endpoint.replace(/\/$/, "")}/${BUCKET}/${immutable}`;
log(`sia URL (immutable): ${siaUrl}`);
} catch (e) {
log(`SIA UPLOAD FAILED: ${e.message}`);
log("continuing to Nostr anyway — a Nostr event without sia_url still carries the root");
}
// ---------- 3) publish NIP-33 event to Nostr ----------
// Hand-rolled (no nostr-tools dep) to keep Argus's dep tree small. Same shape
// nostr-tools would produce: id = sha256 of serialisation-per-NIP-01, sig =
// schnorr(sk, id). Verified against nostr-tools by re-computing both.
const store = JSON.parse(readFileSync(fileURLToPath(WALLET_FILE), "utf8"));
const { skHex, pkHex } = await nostrKeyFromMnemonic(store.main.seed);
log(`nostr pubkey : ${pkHex}`);
const content = JSON.stringify({
root: snap.root,
asOfHeight: snap.asOfHeight,
asOfTxid: snap.asOfTxid,
count: snap.tlds.length,
sia_url: siaUrl,
});
const createdAt = Math.floor(Date.parse(new Date().toISOString()) / 1000);
const kind = 30078;
const tags = [["d", "bns-tld-list"]];
// nostr-tools' finalizeEvent handles both NIP-01 id derivation and BIP-340
// schnorr signing. BCH's own schnorr (libauth's signMessageHashSchnorr) uses
// RFC 6979 nonce derivation, which Nostr relays reject as "bad signature" —
// they check BIP-340 which specifies a different tagged-hash nonce scheme.
const sk = Uint8Array.from(Buffer.from(skHex, "hex"));
const event = finalizeEvent({ kind, created_at: createdAt, tags, content }, sk);
log(`nostr event id : ${event.id}`);
// Publish to each relay in parallel; report per-relay result. Each relay
// answers with ["OK", <event-id>, true|false, <message>] per NIP-20.
async function publishOne(url) {
return new Promise((resolve) => {
const ws = new WebSocket(url);
const done = (status, detail) => { try { ws.close(); } catch {} resolve({ url, status, detail }); };
ws.on("open", () => { ws.send(JSON.stringify(["EVENT", event])); });
ws.on("message", (d) => {
let msg; try { msg = JSON.parse(d.toString()); } catch { return; }
if (msg[0] === "OK" && msg[1] === event.id) done(msg[2] ? "ok" : "rejected", msg[3] || "");
});
ws.on("error", (e) => done("error", e.message));
setTimeout(() => done("timeout", ""), 8000);
});
}
const results = await Promise.all(RELAYS.map(publishOne));
for (const r of results) log(` relay ${r.url.padEnd(30)} → ${r.status}${r.detail ? " (" + r.detail + ")" : ""}`);
const okCount = results.filter((r) => r.status === "ok").length;
log(`nostr: ${okCount}/${results.length} relays accepted`);
process.exit(0);

104
src/seed-tld-beacon.mjs Normal file
View file

@ -0,0 +1,104 @@
// One-off (idempotent): seed the chipnet TLD-certificate beacon with the
// operator-confirmed initial TLD set. Reads what's already there via the wallet's
// provider (so re-runs skip anything present), then mints a TREG for each
// missing TLD. Verifies at the end by rebuilding the snapshot from beacon
// history — the same code path the /tld-list.json endpoint uses.
//
// node src/seed-tld-beacon.mjs
//
// Design context: Decentralized.DNS/DESIGN-tld-registry.md §9 item 1. Chipnet
// only — mainnet is a separate switch and needs the TLD covenant first.
import { loadWallet } from "./lib/wallet.js";
import { registerTld, parseOpReturn } from "./lib/bns.js";
import { buildTldSnapshot } from "./lib/tld-index.js";
import { TLD_BEACON } from "./config.js";
// Operator-confirmed seed set (2026-08-20): shipped defaults minus
// deviant/bcnr/silentmode, plus x for the coinspectrum.x work already in flight.
// Extended 2026-08-29: asm, neo, gt, sc, sia, com; then dex, cex, nt; then dea.
// The operator holds the countersignature key that would otherwise gate
// ICANN-adjacent TLDs (.com, .sc, .gt, .neo) on mainnet; on chipnet no covenant
// enforces the gate, so these are seeded directly under operator authority.
// `.com` is HELD by the operator (excluded from the public registrar UI) so
// nobody else can claim names under it — the TLD cert stays on-chain, but no
// second-level registrations are offered. Future TUPD may set policy:"frozen"
// once the TLD covenant lands and enforces it.
const SEED_TLDS = [
"bch", "p2p", "bit", "nav", "test", "x",
"asm", "neo", "gt", "sc", "sia", "com",
"dex", "cex", "nt", "dea",
];
// Empty records for the seed — the TLD covenant work will populate
// policy/fee_bps/reserved later. Empty means "defaults apply everywhere",
// which per the design doc §3 is {policy:"open", fee_bps:500, min_len:1}.
const SEED_RECORDS = {};
const wallet = await loadWallet("main");
console.log(`from wallet : ${wallet.cashaddr}`);
console.log(`TLD beacon : ${TLD_BEACON}`);
console.log(`target TLD set : ${SEED_TLDS.join(" ")}`);
// Fetch what's already on the beacon so re-runs skip it.
console.log("\nfetching current TLD-beacon state …");
const history = await wallet.provider.getHistory(TLD_BEACON);
const txs = new Map();
for (const { tx_hash } of history) {
try { txs.set(tx_hash, await wallet.provider.getRawTransactionObject(tx_hash)); }
catch { /* unreadable — skip */ }
}
const before = buildTldSnapshot({ history, txs });
console.log(`present : ${before.tlds.length ? before.tlds.map((t) => t.tld).join(" ") : "(none)"}`);
const existing = new Set(before.tlds.map((t) => t.tld));
const toMint = SEED_TLDS.filter((t) => !existing.has(t));
if (!toMint.length) {
console.log("\nnothing to do — all target TLDs already registered.");
process.exit(0);
}
console.log(`will register : ${toMint.join(" ")} (${toMint.length} tx)`);
const bal = await wallet.getBalance("sat");
console.log(`wallet balance : ${bal} sat`);
const results = [];
for (const tld of toMint) {
process.stdout.write(` TREG ${tld} … `);
try {
const res = await registerTld(wallet, tld, SEED_RECORDS);
results.push({ tld, ok: true, ...res });
console.log(`ok txid=${res.txid} category=${res.category?.slice(0, 16)}…`);
} catch (e) {
results.push({ tld, ok: false, error: String(e && e.message || e) });
console.log(`FAIL ${e && e.message || e}`);
}
// A short pause so a chain query between two REGs sees the previous tx.
await new Promise((r) => setTimeout(r, 500));
}
const failed = results.filter((r) => !r.ok);
console.log(`\n${results.length - failed.length}/${results.length} TREG(s) broadcast${failed.length ? ` (${failed.length} failed)` : ""}`);
// Verify by rebuilding the snapshot from the beacon.
console.log("\nverifying via TLD-beacon snapshot rebuild …");
await new Promise((r) => setTimeout(r, 2000)); // mempool propagation
const historyAfter = await wallet.provider.getHistory(TLD_BEACON);
const txsAfter = new Map();
for (const { tx_hash } of historyAfter) {
try { txsAfter.set(tx_hash, await wallet.provider.getRawTransactionObject(tx_hash)); }
catch { /* skip */ }
}
const after = buildTldSnapshot({ history: historyAfter, txs: txsAfter });
console.log(`snapshot root : ${after.root}`);
console.log(`snapshot tlds :`);
for (const t of after.tlds) {
console.log(` - ${t.tld.padEnd(6)} cat=${t.category.slice(0, 16)}… regHeight=${t.regHeight ?? "(mempool)"} txid=${t.regTxid.slice(0, 16)}…`);
}
const missing = SEED_TLDS.filter((t) => !after.tlds.some((s) => s.tld === t));
if (missing.length) {
console.log(`\nWARNING: still missing after broadcast: ${missing.join(" ")}`);
process.exit(1);
}
console.log(`\ndone — all ${SEED_TLDS.length} target TLDs present on the TLD beacon.`);
process.exit(0);

View file

@ -0,0 +1,146 @@
// Tests for the TLD-registry enforcement gate in bns.js buildIndex.
//
// Two parts:
// 1) Unit — synthetic mock provider, fast and deterministic. Confirms the
// admits/rejects rule and mempool-friendly behaviour.
// 2) Chipnet integration — live provider. Since we just seeded the TLD
// beacon at the tip, EVERY existing chipnet name predates its TLD's
// TREG. So enforceTld:true should reduce the index dramatically compared
// to enforceTld:false. That is the design's "no grandfathering" rule
// producing exactly the effect the doc predicts (§2.2).
import { buildIndex, registerName, registerTld, encodePayload } from "../src/lib/bns.js";
import { BEACON, TLD_BEACON } from "../src/config.js";
import { loadWallet } from "../src/lib/wallet.js";
let failures = 0;
const ok = (c, m) => { console.log((c ? " ✅ " : " ❌ ") + m); if (!c) failures++; };
// ---------- (1) unit tests with a synthetic mock provider ----------
console.log("1) unit: enforcement gate on synthetic fixtures");
// A tx factory that produces the shape provider.getRawTransactionObject returns.
// The important fields for the indexer: vin[], vout[].scriptPubKey.hex,
// vout[].tokenData?.{category, nft.commitment}.
function opreturnVout(op, name, records) {
const buf = encodePayload(op, name, records);
return { scriptPubKey: { hex: "6a" + buf.length.toString(16).padStart(2, "0") + buf.toString("hex") } };
}
function mintVout(category, commitLabel) {
return {
scriptPubKey: { hex: "76a914" + "aa".repeat(20) + "88ac" },
tokenData: { category, amount: "0", nft: { capability: "none", commitment: Buffer.from(commitLabel, "utf8").toString("hex") } },
};
}
function beaconVout() { return { scriptPubKey: { hex: "76a91400aa" + "bb".repeat(18) + "88ac" } }; }
// Build a mock provider whose getHistory(address) returns different histories
// per address, and getRawTransactionObject returns the queued txs.
function mockProvider({ nameHistory, tldHistory, txs }) {
return {
async getHistory(addr) {
if (addr === BEACON) return nameHistory;
if (addr === TLD_BEACON) return tldHistory;
return [];
},
async getRawTransactionObject(txid) {
const tx = txs.get(txid);
if (!tx) throw new Error("unknown tx " + txid);
return tx;
},
};
}
// Fixture: TLD `x` registered at height 100.
// Names:
// `late` — regged at 150 under `.x` (should be ADMITTED under enforcement)
// `early` — regged at 50 under `.x` (predates TLD → REJECTED under enforcement)
// `orphan` — regged at 200 under `.zz` (no TLD cert → REJECTED under enforcement)
// `mempoolName` — regged at 0 (mempool) under `.x` (TLD confirmed → ADMITTED, mempool-friendly)
{
const CAT_X = "aa".repeat(32);
const CAT_LATE = "cc".repeat(32);
const CAT_EARLY = "dd".repeat(32);
const CAT_ORPHAN = "ee".repeat(32);
const CAT_MEMPOOL = "ff".repeat(32);
const txs = new Map();
txs.set("tX", { txid: "tX", vin: [], vout: [beaconVout(), opreturnVout("TREG", "x", { policy: "open" }), mintVout(CAT_X, "x")] });
txs.set("tEarly", { txid: "tEarly", vin: [], vout: [beaconVout(), opreturnVout("REG", "early.x", {}), mintVout(CAT_EARLY, "early.x")] });
txs.set("tLate", { txid: "tLate", vin: [], vout: [beaconVout(), opreturnVout("REG", "late.x", {}), mintVout(CAT_LATE, "late.x")] });
txs.set("tOrphan", { txid: "tOrphan", vin: [], vout: [beaconVout(), opreturnVout("REG", "orphan.zz",{}), mintVout(CAT_ORPHAN, "orphan.zz")] });
txs.set("tMempool", { txid: "tMempool", vin: [], vout: [beaconVout(), opreturnVout("REG", "mempool.x",{}), mintVout(CAT_MEMPOOL, "mempool.x")] });
const provider = mockProvider({
nameHistory: [
{ tx_hash: "tEarly", height: 50 },
{ tx_hash: "tLate", height: 150 },
{ tx_hash: "tOrphan", height: 200 },
{ tx_hash: "tMempool", height: 0 },
],
tldHistory: [{ tx_hash: "tX", height: 100 }],
txs,
});
const noGate = await buildIndex(provider);
ok(noGate.size === 4, `enforceTld:false admits all 4 name REGs (got ${noGate.size})`);
ok(noGate.has("early.x") && noGate.has("late.x") && noGate.has("orphan.zz") && noGate.has("mempool.x"), "no-gate: every name present");
const withGate = await buildIndex(provider, { enforceTld: true });
ok(!withGate.has("early.x"), "gate: early.x REJECTED (predates TLD)");
ok(withGate.has("late.x"), "gate: late.x ADMITTED (post-TLD)");
ok(!withGate.has("orphan.zz"), "gate: orphan.zz REJECTED (no TLD cert)");
ok(withGate.has("mempool.x"), "gate: mempool.x ADMITTED (mempool-friendly rule)");
ok(withGate.size === 2, `gate: exactly 2 admitted (got ${withGate.size})`);
}
// Fixture: TLD `y` registered in MEMPOOL (height 0). A mempool name under `.y`
// is admitted (both in mempool); a confirmed name under `.y` before the TLD
// confirms is also admitted (mempool-friendly: we cannot decide time-order
// across mempool + confirmed edge). This is the deliberate simplification.
{
const CAT_Y = "11".repeat(32);
const CAT_N = "22".repeat(32);
const txs = new Map();
txs.set("tYmempool", { txid: "tYmempool", vin: [], vout: [beaconVout(), opreturnVout("TREG", "y", {}), mintVout(CAT_Y, "y")] });
txs.set("tNameUnderY", { txid: "tNameUnderY", vin: [], vout: [beaconVout(), opreturnVout("REG", "name.y", {}), mintVout(CAT_N, "name.y")] });
const provider = mockProvider({
nameHistory: [{ tx_hash: "tNameUnderY", height: 0 }],
tldHistory: [{ tx_hash: "tYmempool", height: 0 }],
txs,
});
const withGate = await buildIndex(provider, { enforceTld: true });
ok(withGate.has("name.y"), "gate: both-in-mempool → admitted");
}
// ---------- (2) chipnet integration ----------
// Real provider. All existing chipnet names predate the just-seeded TLD TREGs
// (in mempool at time of writing), so enforceTld:true should return a MUCH
// smaller index than enforceTld:false.
console.log("\n2) chipnet integration (live provider)");
try {
const wallet = await loadWallet("main");
const noGate = await buildIndex(wallet.provider);
const withGate = await buildIndex(wallet.provider, { enforceTld: true });
console.log(` index sizes: no-gate=${noGate.size}, with-gate=${withGate.size}`);
ok(noGate.size > 0, "no-gate index has entries");
ok(withGate.size <= noGate.size, "gate never admits MORE than no-gate");
const droppedTlds = new Set();
for (const [name] of noGate) if (!withGate.has(name)) {
const i = name.lastIndexOf("."); droppedTlds.add(i < 0 ? "bch" : name.slice(i + 1));
}
console.log(` TLDs whose names got dropped: ${[...droppedTlds].sort().join(", ") || "(none)"}`);
// If the TLD TREGs have not confirmed yet (mempool), the mempool-friendly
// rule admits chipnet names that share the mempool state; once confirmed,
// no chipnet name predating them is admitted. Either way: gate should have
// dropped SOMETHING, because there are chipnet names under TLDs we did not
// seed (`.de`, `.dgb`, `.silentmode`).
ok(droppedTlds.size > 0, "gate drops at least one TLD's worth of names (chipnet had names under unseeded TLDs)");
} catch (e) {
console.log(" ⚠️ chipnet section skipped (network / wallet unavailable):", e.message);
}
console.log(failures === 0 ? "\n🎉 ALL PASSED — enforcement gate works" : `\n💥 ${failures} check(s) failed`);
process.exit(failures === 0 ? 0 : 1);

252
test/tld-index.test.mjs Normal file
View file

@ -0,0 +1,252 @@
// 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);