gateway: /api/tlds sourced from TLD_BEACON snapshot, add /api/registry

- /api/tlds now calls buildTldSnapshot on the per-TLD-NFT beacon
  (Decentralized.DNS/DESIGN-tld-registry.md §3) instead of reading the
  legacy tlds.bch record. Every entry carries `certificate: true|false`
  so a name shop can distinguish covenanted, sellable TLDs from legacy
  ones that predate the certificate model. Ships a `legend` field
  explaining both, and hides intentionally-legacy TLDs (currently just
  `silentmode`, from an accidental REG) unless `?hidden=1`.
- /api/registry — bulk dump of every registered name for offline
  availability computation by registrar sites.
- Inline electrum client (~30 lines) so the endpoint can query an
  arbitrary scripthash — resolver-web's Electrum class is not exported.
  Prefers the local multi-beacon indexer at ws://127.0.0.1:50010 with
  public-pool fallback.
This commit is contained in:
Local Dev 2026-08-29 16:43:27 +02:00
parent 5b65be2483
commit de696a8b9f

View file

@ -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> -> { name, tld, registered, records? }
if (req.url.startsWith("/api/name/")) {