From 9068eff9c7e1f0f98ceaea8023d6eb3d8f752d67 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sat, 29 Aug 2026 17:11:02 +0200 Subject: [PATCH] BNS: per-TLD on-chain registry, multi-beacon indexer, enforcement gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- ariadne.config.json | 4 +- package-lock.json | 9 +- package.json | 3 +- setup/install.ps1 | 30 ++-- src/config.js | 5 + src/indexer/bns-indexer.js | 236 ++++++++++++++++++++++++++----- src/lib/bns.js | 137 +++++++++++++++++- src/lib/resolver-web.d.ts | 2 + src/lib/resolver-web.js | 104 +++++++++++++- src/lib/tld-index.js | 145 +++++++++++++++++++ src/publish-tld-mirror.mjs | 160 +++++++++++++++++++++ src/seed-tld-beacon.mjs | 104 ++++++++++++++ test/tld-enforcement.test.mjs | 146 ++++++++++++++++++++ test/tld-index.test.mjs | 252 ++++++++++++++++++++++++++++++++++ 14 files changed, 1280 insertions(+), 57 deletions(-) create mode 100644 src/lib/tld-index.js create mode 100644 src/publish-tld-mirror.mjs create mode 100644 src/seed-tld-beacon.mjs create mode 100644 test/tld-enforcement.test.mjs create mode 100644 test/tld-index.test.mjs diff --git a/ariadne.config.json b/ariadne.config.json index bf8b167..25eb1db 100644 --- a/ariadne.config.json +++ b/ariadne.config.json @@ -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"] } diff --git a/package-lock.json b/package-lock.json index fed70df..9483c3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 83c52d3..734e469 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/setup/install.ps1 b/setup/install.ps1 index d6db344..4d66102 100644 --- a/setup/install.ps1 +++ b/setup/install.ps1 @@ -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 diff --git a/src/config.js b/src/config.js index 90e5cd2..64da87a 100644 --- a/src/config.js +++ b/src/config.js @@ -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; diff --git a/src/indexer/bns-indexer.js b/src/indexer/bns-indexer.js index 4d4529e..ab615bf 100644 --- a/src/indexer/bns-indexer.js +++ b/src/indexer/bns-indexer.js @@ -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(); } diff --git a/src/lib/bns.js b/src/lib/bns.js index 6614445..67674f1 100644 --- a/src/lib/bns.js +++ b/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. +// 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 }; +} diff --git a/src/lib/resolver-web.d.ts b/src/lib/resolver-web.d.ts index 4742d71..a5a1fea 100644 --- a/src/lib/resolver-web.d.ts +++ b/src/lib/resolver-web.d.ts @@ -34,6 +34,8 @@ export function fetchBcnrTlds(opts?: {}): Promise; 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"; diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index b85ba00..adbfc89 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -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 ("