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:
parent
de696a8b9f
commit
9068eff9c7
14 changed files with 1280 additions and 57 deletions
|
|
@ -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
9
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
137
src/lib/bns.js
137
src/lib/bns.js
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
2
src/lib/resolver-web.d.ts
vendored
2
src/lib/resolver-web.d.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
145
src/lib/tld-index.js
Normal 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
160
src/publish-tld-mirror.mjs
Normal 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
104
src/seed-tld-beacon.mjs
Normal 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);
|
||||
146
test/tld-enforcement.test.mjs
Normal file
146
test/tld-enforcement.test.mjs
Normal 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
252
test/tld-index.test.mjs
Normal 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);
|
||||
Loading…
Add table
Reference in a new issue