From f15752fe4b8a10bb11598c83c8f71fa3655f5b23 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Sun, 6 Sep 2026 22:51:54 +0200 Subject: [PATCH] fix(gateway): restore /api/holdings endpoint for sirius.x portal The sirius.x portal signs the user in with a seed phrase and then calls POST /api/holdings to look up the BCNR names their wallet holds. That endpoint was removed by commit 66fb0fe ("forward proxied headers + trust Silent Mode CA") along with the elConnect/elCall helpers it depended on. From a user's point of view the sign-in appeared broken: the wallet unlocked but the names panel showed 'Could not load names: API 404: bns gateway'. Restores the endpoint using the connectAny + CHIPNET_ELECTRUM helpers that resolver-web.js already had (now exported), and keeps the beacon-only-URL filter so listunspent doesn't land on bns-indexer, which only serves get_history + transaction.get. Rebuild-in-place: no client-side change needed; portal.html already POSTs to /api/holdings and expects the {count, names[]} shape this endpoint returns. --- src/gateway/public-gateway.mjs | 142 ++++++++++++++++++++++++++++++++- src/lib/resolver-web.js | 4 +- 2 files changed, 143 insertions(+), 3 deletions(-) diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 3b1861b..c3da2ec 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -18,7 +18,7 @@ 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 "./resolver-web.js"; +import { resolveName, buildIndex, normalizeName, connectAny, CHIPNET_ELECTRUM } from "./resolver-web.js"; import { pickRecord, isSubdomainOf } from "./record-picker.js"; // Full-index cache (rebuilt every 30s) for fast availability lookups. @@ -140,6 +140,34 @@ async function ipRequest(ip, reqPath, hostHeader, tlsFingerprint) { return await httpGet(ip, reqPath, hostHeader); } +// Reverse-proxy to a full upstream URL — the `p` record handler. Unlike +// ipRequest (raw IP + pinned cert from the chain), this uses the upstream's +// own DNS and public CA cert. The Host header sent upstream is the upstream's +// hostname, not the BNS name — so vhost-based upstreams answer with their own +// vhost, which is the whole point (mirror behaviour). Path is upstream's own +// prefix plus the caller's reqPath, so `{p:"https://api.example.com/v1"}` + +// request "/x" hits `https://api.example.com/v1/x`. +function proxyToUrl(upstreamUrl, reqPath) { + const url = new URL(upstreamUrl); + const client = url.protocol === "https:" ? https : http; + const port = url.port || (url.protocol === "https:" ? 443 : 80); + const prefix = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""); + const upstreamPath = prefix + reqPath; + return new Promise((resolve, reject) => { + const req = client.request({ + host: url.hostname, port, method: "GET", path: upstreamPath, + headers: { host: url.host, "user-agent": "bns-gateway/1" }, + }, (up) => { + const chunks = []; + up.on("data", (c) => chunks.push(c)); + up.on("end", () => resolve({ status: up.statusCode, headers: up.headers, buffer: Buffer.concat(chunks) })); + }); + req.setTimeout(15000, () => req.destroy(new Error("proxy timeout"))); + req.on("error", reject); + req.end(); + }); +} + // Serve a resolved name. baseHref = null for subdomain form (own origin). // // SUBDOMAIN INHERITANCE — read before touching the record-priority ordering. @@ -209,6 +237,25 @@ async function serve(res, name, reqPath, baseHref) { 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 "p": { + const up = await proxyToUrl(r.p, reqPath); + const buf = up.buffer; + let ct = up.headers["content-type"] || guess(reqPath); + 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(); default: @@ -333,6 +380,99 @@ 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. bns-indexer + // only serves the beacon; arbitrary-address listunspent needs a full + // electrum, which many browsers cannot reach on ports 50004/50011/62002 + // (firewalls, mobile carriers). The gateway can reach them, so it queries + // and returns the cross-joined result: the client POSTs a scripthash list + // and gets back the BCNR names each hash currently holds an NFT for. + // + // 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"); + 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 { 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; + } + // Skip beacon-only indexers (silentmode.st/electrum on 443, our own + // bns-indexer on :50011) — they don't implement listunspent. + const BNS_INDEXER_URLS = /silentmode\.st\/electrum|coinspectrum\.duckdns\.org:50011/; + const candidates = CHIPNET_ELECTRUM + .map((s) => (typeof s === "string" ? { url: s } : s)) + .filter((s) => !BNS_INDEXER_URLS.test(s.url)); + let el, lastErr; + for (const s of candidates) { + try { el = await connectAny([s], WebSocket); break; } + catch (e) { lastErr = e; } + } + if (!el) { + res.writeHead(503); + res.end(JSON.stringify({ error: "no full electrum reachable", detail: String(lastErr?.message || lastErr) })); + return; + } + try { + const utxoLists = await Promise.all(scripthashes.map((sh) => + el.call("blockchain.scripthash.listunspent", [sh, "include_tokens"]).catch(() => []) + )); + 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); + } + const idx = await getIndex(); + const names = []; + for (const [key, entry] of idx) { + if (!categories.has(entry.category)) continue; + const dot = key.indexOf("."); + const displayName = dot < 0 ? key + ".bch" : key; + const tld = dot < 0 ? "bch" : key.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.close(); } catch {} + } + return; + } + // --- registrar API: availability of a name --- // GET /api/name/ -> { name, tld, registered, records? } if (req.url.startsWith("/api/name/")) { diff --git a/src/lib/resolver-web.js b/src/lib/resolver-web.js index 3965ab8..041134a 100644 --- a/src/lib/resolver-web.js +++ b/src/lib/resolver-web.js @@ -90,7 +90,7 @@ export function normalizeName(input) { } // ---- electrum over a WebSocket (JSON-RPC; Fulcrum-WS framing tolerant) ---- -class Electrum { +export class Electrum { constructor(url, WebSocketImpl, options) { this.url = url; this.WS = WebSocketImpl || globalThis.WebSocket; @@ -152,7 +152,7 @@ class Electrum { // A server is either a bare "wss://host:port" string or { url, ip }. const asServer = (s) => (typeof s === "string" ? { url: s } : s); -async function connectAny(servers, WebSocketImpl, { directIP = false } = {}) { +export async function connectAny(servers, WebSocketImpl, { directIP = false } = {}) { let lastErr; for (const raw of servers) { const s = asServer(raw);