diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 28661b8..2c9d52c 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -18,8 +18,21 @@ import WebSocket from "ws"; // resolver-web.js lives at ../lib/, not alongside this file — the previous // `./resolver-web.js` import was broken (file was moved into lib/ but this // import wasn't updated). Fix while touching the file. -import { resolveName, buildIndex, normalizeName } from "../lib/resolver-web.js"; +import { resolveName, buildIndex, normalizeName, CHIPNET_ELECTRUM } from "../lib/resolver-web.js"; import { pickRecord, isSubdomainOf } from "../lib/record-picker.js"; +import { buildTldSnapshot } from "../lib/tld-index.js"; + +// TLD-certificate beacon scripthash (Decentralized.DNS/DESIGN-tld-registry.md §3). +// Constant; keep in step with Argus/src/config.js:TLD_BEACON and the indexer's +// BNS_TLD_BEACON_SCRIPTHASH default. +const TLD_BEACON_SCRIPTHASH = "c85db10e106606273422b2733b2a6fb8b35a4d0e7dbfb20cf59bdb0833c008b8"; + +// Legacy TLDs to omit from the public `/api/tlds` listing by default. A legacy +// entry here is one that a name was accidentally minted under (typo, test +// artefact) that we do not want a name shop advertising as a browseable +// namespace. They still resolve, and pass `?hidden=1` to see them. +// `silentmode` — one-off (`releases.silentmode`) meant to be .st or .bch. +const HIDDEN_LEGACY_TLDS = new Set(["silentmode"]); // Full-index cache (rebuilt every 30s) for fast availability lookups. let _idx = { at: 0, index: null }; @@ -30,6 +43,74 @@ async function getIndex() { return _idx.index; } +// Minimal newline-delimited JSON-RPC electrum client over a single WS. Same +// framing as resolver-web.js's internal Electrum class and bns-indexer.test.mjs +// — kept inline here so the gateway can query an arbitrary scripthash (the +// resolver-web helpers only know the name beacon) without exporting internals. +async function elCall(el, method, params = []) { + return new Promise((resolve, reject) => { + const id = ++el.id; + el.pending.set(id, { resolve, reject }); + el.ws.send(JSON.stringify({ id, method, params }) + "\n"); + setTimeout(() => { + if (el.pending.has(id)) { el.pending.delete(id); reject(new Error(`electrum timeout: ${method}`)); } + }, 20000); + }); +} +function elConnect(url) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const el = { ws, id: 0, pending: new Map(), buf: "" }; + ws.on("open", () => resolve(el)); + ws.on("error", (e) => reject(e)); + ws.on("message", (data) => { + el.buf += data.toString(); + let nl; + while ((nl = el.buf.indexOf("\n")) >= 0) { + const line = el.buf.slice(0, nl).trim(); + el.buf = el.buf.slice(nl + 1); + if (!line) continue; + try { + const m = JSON.parse(line); + const p = el.pending.get(m.id); + if (p) { el.pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); } + } catch { /* ignore stray frame */ } + } + }); + }); +} + +// TLD snapshot cache (30s), same TTL as the name index. +let _tld = { at: 0, snapshot: null }; +async function getTldSnapshot() { + if (_tld.snapshot && Date.now() - _tld.at < 30000) return _tld.snapshot; + // Prefer the local BCHN-backed indexer (127.0.0.1:50010) — it indexes both + // the name beacon and the TLD beacon on one WS since bns-indexer.js is + // multi-beacon. Falls back to the public pool if the local endpoint is + // unreachable (a dev running the gateway without a local indexer). + const candidates = [ + process.env.BNS_ELECTRUM_LOCAL || "ws://127.0.0.1:50010", + ...CHIPNET_ELECTRUM.map((s) => s.url), + ]; + let el, lastErr; + for (const url of candidates) { + try { el = await elConnect(url); break; } catch (e) { lastErr = e; } + } + if (!el) throw lastErr ?? new Error("no electrum reachable for TLD snapshot"); + try { + await elCall(el, "server.version", ["bns-gateway", "1.4.3"]); + const history = await elCall(el, "blockchain.scripthash.get_history", [TLD_BEACON_SCRIPTHASH]); + const txs = new Map(); + for (const { tx_hash } of history) { + try { txs.set(tx_hash, await elCall(el, "blockchain.transaction.get", [tx_hash, true])); } + catch { /* unreadable tx — skip */ } + } + const snapshot = buildTldSnapshot({ history, txs }); + _tld = { at: Date.now(), snapshot }; + return snapshot; + } finally { try { el.ws.close(); } catch {} } +} + const MOUNT = process.env.BNS_MOUNT || "/bns"; const PORT = Number(process.env.BNS_PORT || 8700); const S3_ENDPOINT = process.env.BNS_S3 || "http://127.0.0.1:8500"; @@ -239,6 +320,122 @@ const server = http.createServer(async (req, res) => { return handleSearch(u, res); } + // --- registry API: TLDs from the on-chain TLD-certificate beacon --- + // GET /api/tlds -> { source, root, count, legend, tlds: [{ tld, certificate, ... }] } + // The authoritative list a name-selling site scans to know which suffixes it + // may sell. Each TLD is a first-class on-chain certificate (TREG NFT + records) + // per Decentralized.DNS/DESIGN-tld-registry.md §3; this endpoint returns the + // snapshot buildTldSnapshot() produces so the response `root` is byte-identical + // between any honest producer looking at the same chain state. + // + // Every entry carries `certificate` — true for TLDs registered on TLD_BEACON + // (the covenanted, sellable path), false for "legacy" TLDs: TLDs that had + // names REG'd under them before the TLD-certificate model landed (the + // list-in-one-NFT `tlds.bch` era). Legacy entries resolve today but have no + // owner NFT, no policy record, and are not sellable through the registrar + // flow. They stay visible so a shop can WARN a user browsing existing names + // ("this is legacy, will be invalidated on the mainnet migration") without + // offering them for sale. See the `legend` field in the response body. + // + // `registered_count` overlays the NAME index on top: how many names are + // registered under each TLD right now. A certified TLD with 0 names is a + // fresh registry ready to sell. + // + // `hidden` (query flag) — legacy TLDs on the HIDDEN_LEGACY_TLDS list are + // suppressed by default. Pass `?hidden=1` to include them (introspection). + if (req.url === "/api/tlds" || req.url.startsWith("/api/tlds?")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("content-type", "application/json"); + try { + const wantHidden = /(?:^|[?&])hidden=1(?:&|$)/.test(req.url); + const [snap, idx] = await Promise.all([getTldSnapshot(), getIndex()]); + const observed = new Map(); // tld -> count of names + for (const key of idx.keys()) { + const dot = key.indexOf("."); + const tld = dot < 0 ? "bch" : key.slice(dot + 1); + observed.set(tld, (observed.get(tld) || 0) + 1); + } + const certified = new Set(snap.tlds.map((t) => t.tld)); + const tlds = snap.tlds.map((t) => ({ + tld: t.tld, + certificate: true, + category: t.category, + reg_height: t.regHeight, + reg_txid: t.regTxid, + updated_txid: t.updatedTxid, + records: t.records, + owner: t.owner, + registered_count: observed.get(t.tld) || 0, + })); + // Legacy TLDs that have names under them but no certificate on TLD_BEACON. + // Surfaced so name-shops can warn "this TLD is not covenant-registered". + const legacyAll = [...observed.entries()] + .filter(([tld]) => !certified.has(tld)) + .map(([tld, count]) => ({ tld, certificate: false, registered_count: count })); + const legacy = wantHidden + ? legacyAll + : legacyAll.filter((e) => !HIDDEN_LEGACY_TLDS.has(e.tld)); + const all = [...tlds, ...legacy].sort((a, b) => a.tld.localeCompare(b.tld)); + res.writeHead(200); + res.end(JSON.stringify({ + source: "TLD_BEACON", + root: snap.root, + count: all.length, + hidden_legacy: wantHidden ? [] : [...HIDDEN_LEGACY_TLDS].filter((t) => observed.has(t)), + legend: { + certificate_true: + "TLD has an on-chain TREG certificate (per-TLD NFT + records on TLD_BEACON, " + + "Decentralized.DNS/DESIGN-tld-registry.md §3). Sellable through the covenanted " + + "registrar path; the owner field is the operator that can TUPD its policy.", + certificate_false: + "'Legacy' — one or more names were REG'd under this TLD before per-TLD certificates " + + "existed. Still resolves via the name index, but has no owner NFT, no policy record, " + + "and cannot be sold through the covenanted flow. On the mainnet migration these names " + + "will need re-registration under a certified TLD (see design doc §2 item 2).", + }, + tlds: all, + }, null, 2)); + } catch (e) { + res.writeHead(500); res.end(JSON.stringify({ error: e.message })); + } + return; + } + + // --- registry API: dump every registered name --- + // GET /api/registry -> { count, height_hint, names: [{ name, tld, records, category, txid, height, updated_txid }] } + // The bulk snapshot a name-selling site can cache to compute availability + // offline. Refreshed at most every 30s server-side (same cache as /api/*). + if (req.url === "/api/registry" || req.url.startsWith("/api/registry?")) { + res.setHeader("access-control-allow-origin", "*"); + res.setHeader("content-type", "application/json"); + try { + const idx = await getIndex(); + const names = []; + let heightHint = 0; + for (const [key, entry] of idx) { + const dot = key.indexOf("."); + const displayName = dot < 0 ? key + ".bch" : key; + const tld = dot < 0 ? "bch" : key.slice(dot + 1); + if (entry?.height && entry.height > heightHint) heightHint = entry.height; + names.push({ + name: displayName, + tld, + records: entry?.records ?? null, + category: entry?.category ?? null, + txid: entry?.txid ?? null, + height: entry?.height ?? null, + updated_txid: entry?.updatedTxid ?? null, + }); + } + names.sort((a, b) => a.name.localeCompare(b.name)); + res.writeHead(200); + res.end(JSON.stringify({ count: names.length, height_hint: heightHint, names }, null, 2)); + } catch (e) { + res.writeHead(500); res.end(JSON.stringify({ error: e.message })); + } + return; + } + // --- registrar API: availability of a name --- // GET /api/name/ -> { name, tld, registered, records? } if (req.url.startsWith("/api/name/")) {