diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index bdfa702..c01dcc8 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -18,21 +18,8 @@ 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, 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"]); +import { resolveName, buildIndex, normalizeName } from "./resolver-web.js"; +import { pickRecord, isSubdomainOf } from "./record-picker.js"; // Full-index cache (rebuilt every 30s) for fast availability lookups. let _idx = { at: 0, index: null }; @@ -43,91 +30,6 @@ 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(); - // Newline-delimited messages first — Fulcrum's normal framing. - 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 */ } - } - // Some Fulcrum builds (and chipnet.bch.ninja specifically) omit the - // trailing newline. If what's left in the buffer parses as a complete - // JSON message, settle it. Same pattern as bns-indexer.test.mjs's - // "some servers omit the trailing newline" branch. - const rest = el.buf.trim(); - if (rest) { - try { - const m = JSON.parse(rest); - const p = el.pending.get(m.id); - if (p) { - el.pending.delete(m.id); - el.buf = ""; - m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); - } - } catch { /* still partial — wait for more bytes */ } - } - }); - }); -} - -// 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"; @@ -287,8 +189,23 @@ async function serve(res, name, reqPath, baseHref) { const up = await ipRequest(r.ip, reqPath, name, r.tls); const buf = up.buffer; let ct = up.headers["content-type"] || guess(reqPath); - if (ct.includes("text/html")) { res.writeHead(up.status, { "content-type": ct }); return res.end(injectBase(buf.toString("utf8"), baseHref)); } - res.writeHead(up.status, { "content-type": ct }); return res.end(buf); + // Forward the upstream's other response headers so redirects, cookies, + // caching directives, etc. actually work end-to-end. Drop hop-by-hop + // headers (RFC 7230 §6.1) plus Content-Length (we may rewrite HTML). + const HOP_BY_HOP = new Set([ + "connection", "keep-alive", "transfer-encoding", + "proxy-authenticate", "proxy-authorization", + "te", "trailer", "upgrade", "content-length", + ]); + const outHeaders = { "content-type": ct }; + for (const [k, v] of Object.entries(up.headers)) { + const lk = k.toLowerCase(); + if (HOP_BY_HOP.has(lk)) continue; + if (lk === "content-type") continue; + outHeaders[k] = v; + } + if (ct.includes("text/html")) { res.writeHead(up.status, outHeaders); return res.end(injectBase(buf.toString("utf8"), baseHref)); } + res.writeHead(up.status, outHeaders); return res.end(buf); } case "u": res.writeHead(302, { location: r.u }); return res.end(); @@ -337,80 +254,41 @@ 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). + // --- registry API: advertised TLDs (mirrors on-chain `tlds.bch` "tlds" record) --- + // GET /api/tlds -> { source, height, updated, tlds: [{ tld, registered, in_advertised }], advertised: [...] } + // The list a name-selling site scans to know which suffixes it may sell. + // `advertised` is exactly what the chain says (space-split); `tlds` is the + // observed union of every TLD that appears on any registered name plus every + // TLD in the advertised list — so a shop sees "sm" (advertised) even if no + // .sm has been registered yet, and "silentmode" (registered) even if it + // isn't advertised. 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 idx = await 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, + const anchor = idx.get("tlds.bch"); + const advertisedRaw = (anchor?.records?.tlds ?? "").trim(); + const advertised = advertisedRaw ? advertisedRaw.split(/\s+/) : []; + const known = new Set([...advertised, ...observed.keys()]); + const tlds = [...known].sort().map((tld) => ({ + tld, + registered_count: observed.get(tld) || 0, + in_advertised: advertised.includes(tld), })); - // 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, + source: "tlds.bch", + height: anchor?.height ?? null, + updated_txid: anchor?.updatedTxid ?? anchor?.txid ?? null, + advertised, + tlds, }, null, 2)); } catch (e) { res.writeHead(500); res.end(JSON.stringify({ error: e.message })); @@ -453,117 +331,6 @@ const server = http.createServer(async (req, res) => { return; } - // GET /api/holdings/ -> { scripthash, count, names: [...] } - // POST /api/holdings body: {"scripthashes":[…]} -> { count, names: [...] } - // - // Server-side wallet-holdings lookup for the sirius.x portal (and any other - // BCNR-aware client). Bns-indexer only serves the beacon; arbitrary-address - // listunspent needs a full electrum, which many browsers cannot reach on - // ports 50004/50011/62002 (corporate firewalls, mobile carriers). The - // gateway CAN reach them, so it queries and returns the cross-joined - // result — client just POSTs a scripthash list and gets back the names. - // - // Takes scripthash (not cashaddr) to keep the gateway libauth-free; the - // client's wallet code already knows the scripthash for each of its - // watched addresses. - if (req.url === "/api/holdings" || req.url.startsWith("/api/holdings/") || req.url.startsWith("/api/holdings?")) { - res.setHeader("access-control-allow-origin", "*"); - res.setHeader("content-type", "application/json"); - // Collect scripthashes: URL path for GET, JSON body for POST. - let scripthashes = []; - if (req.method === "POST") { - try { - const chunks = []; - for await (const c of req) chunks.push(c); - const body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); - scripthashes = Array.isArray(body.scripthashes) ? body.scripthashes : []; - } catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: "invalid JSON body" })); return; } - } else { - const one = req.url.slice("/api/holdings/".length).split("?")[0]; - if (one) scripthashes = [one]; - } - scripthashes = scripthashes.filter((s) => typeof s === "string" && /^[0-9a-f]{64}$/i.test(s)); - if (!scripthashes.length) { - res.writeHead(400); - res.end(JSON.stringify({ error: "no valid scripthashes (need 64-char hex, POST as {scripthashes:[…]} or GET /api/holdings/)" })); - return; - } - if (scripthashes.length > 64) { - res.writeHead(400); res.end(JSON.stringify({ error: "at most 64 scripthashes per request" })); return; - } - // Connect to any full electrum. bns-indexer only supports get_history + - // transaction.get — no listunspent for arbitrary scripthashes. Skip - // every URL that resolves to our own bns-indexer (silentmode.st/electrum - // on 443, coinspectrum.duckdns.org:50011 on the direct port). - const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/; - const candidates = CHIPNET_ELECTRUM.map((s) => s.url).filter((u) => !BNS_INDEXER_URLS.test(u)); - // Connect + verify with a short timeout, so a hung server (an open TCP - // that never speaks JSON-RPC back) fails over to the next candidate in - // seconds instead of stalling the request for 20. - const verifyElectrum = async (url) => { - const c = await elConnect(url); - const versionPromise = new Promise((resolve, reject) => { - const id = ++c.id; - c.pending.set(id, { resolve, reject }); - c.ws.send(JSON.stringify({ id, method: "server.version", params: ["bns-gateway", "1.4.3"] }) + "\n"); - setTimeout(() => { - if (c.pending.has(id)) { c.pending.delete(id); reject(new Error("server.version timeout")); } - }, 3000); - }); - try { await versionPromise; return c; } - catch (e) { try { c.ws.close(); } catch {} throw e; } - }; - let el, lastErr, elUrl; - for (const url of candidates) { - try { el = await verifyElectrum(url); elUrl = url; console.log("api/holdings: verified", url); break; } - catch (e) { lastErr = e; console.log("api/holdings: skip", url, "-", e.message); } - } - if (!el) { res.writeHead(503); res.end(JSON.stringify({ error: "no full electrum reachable", detail: String(lastErr?.message || lastErr) })); return; } - try { - // Fan out listunspent in parallel. Chipnet is small; 64 concurrent - // listunspent calls on one WS is well within a public server's budget. - const utxoLists = await Promise.all(scripthashes.map((sh) => - elCall(el, "blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => []) - )); - // Collect NFT categories held by any of the scripthashes. - const categories = new Set(); - for (const list of utxoLists) { - for (const u of list) if (u.token_data?.nft) categories.add(u.token_data.category); - } - // Cross-reference with the cached BCNR index. - const idx = await getIndex(); - const names = []; - for (const entry of idx.values()) { - if (categories.has(entry.category)) { - const dot = entry.name.indexOf("."); - const displayName = dot < 0 ? entry.name + ".bch" : entry.name; - const tld = dot < 0 ? "bch" : entry.name.slice(dot + 1); - names.push({ - name: displayName, - tld, - category: entry.category, - records: entry.records ?? null, - txid: entry.txid ?? null, - height: entry.height ?? null, - updated_txid: entry.updatedTxid ?? null, - }); - } - } - names.sort((a, b) => a.name.localeCompare(b.name)); - const body = req.method === "POST" - ? { count: names.length, scripthashes_queried: scripthashes.length, names } - : { scripthash: scripthashes[0], count: names.length, names }; - res.writeHead(200); - res.end(JSON.stringify(body, null, 2)); - } catch (e) { - res.writeHead(500); - res.end(JSON.stringify({ error: String(e.message || e) })); - } finally { - try { el.ws.close(); } catch {} - } - return; - } - // --- registrar API: availability of a name --- // GET /api/name/ -> { name, tld, registered, records? } if (req.url.startsWith("/api/name/")) {