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:
parent
a9f2974194
commit
f15752fe4b
2 changed files with 143 additions and 3 deletions
|
|
@ -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/")) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue