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.
This commit is contained in:
Local Dev 2026-09-06 22:51:54 +02:00
parent a9f2974194
commit f15752fe4b
2 changed files with 143 additions and 3 deletions

View file

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

View file

@ -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);