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:
parent
5b65be2483
commit
de696a8b9f
1 changed files with 198 additions and 1 deletions
|
|
@ -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/")) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue