sirius.x portal: /api/holdings endpoint + favicon paths + roadmap link

gateway/public-gateway.mjs:
  * GET /api/holdings/<scripthash> and POST /api/holdings {scripthashes:[…]}
    — server-side wallet-holdings lookup for the portal. Bns-indexer is
    beacon-only so it can't answer listunspent for arbitrary addresses; the
    gateway does the electrum roundtrip and cross-joins with the cached BCNR
    index. Client posts scripthashes (no libauth needed on the server) and
    gets back the names owned.
  * elConnect now falls through to whole-buffer JSON.parse when a Fulcrum
    response lacks a trailing newline — chipnet.bch.ninja does this and
    otherwise every elCall to it times out. Same tolerance pattern as
    bns-indexer.test.mjs's tiny electrum client.
  * verifyElectrum picks a full electrum with a 3s server.version probe so a
    hung server fails over to the next in seconds instead of stalling 20.
  * Skips silentmode.st/electrum and coinspectrum.duckdns.org:50011 for
    /api/holdings — both are bns-indexer routes that don't do listunspent.

web/register-entry.js: export addressToScripthash so the portal can derive
the scripthash for each of its wallet's watched addresses before POSTing.
Browser bundle rebuilt.

site-sirius-x pages:
  * favicon + logo hrefs made relative (./assets/… on index/portal,
    ../assets/… on docs/theseus). Absolute /sirius-x/… broke on the sirius.x
    BCNR route where the site is served from root instead of under /sirius-x/.
  * Roadmap nav link added back to portal, docs, theseus — the landing has an
    anchor to a section on itself, so subpages now link to it explicitly.
  * portal.html: loadNames rewritten to POST scripthashes to /api/holdings
    instead of doing client-side electrum. Works on browsers whose networks
    block chipnet electrum ports; same-origin HTTPS on 443.
  * portal.html: bundle import ?v= bumped so Chrome's in-memory ES module
    map picks up the new bundle instead of a stale cached copy.
This commit is contained in:
Local Dev 2026-08-31 05:40:47 +02:00
parent 6539f84b24
commit ff76e4cf94
2 changed files with 129 additions and 1 deletions

View file

@ -65,6 +65,7 @@ function elConnect(url) {
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();
@ -76,6 +77,22 @@ function elConnect(url) {
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 */ }
}
});
});
}
@ -436,6 +453,117 @@ 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 (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/<hex>)" }));
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> -> { name, tld, registered, records? }
if (req.url.startsWith("/api/name/")) {

View file

@ -30,7 +30,7 @@ export {
InsufficientFundsError,
} from "../lib/register-tx.js";
export { signTransaction, txidOf, txidOfHex } from "../lib/sign-tx.js";
export { toTokenAddress, addressToLockingBytecode, getUtxos, getUtxosForAddresses, broadcast } from "../lib/chain.js";
export { toTokenAddress, addressToLockingBytecode, addressToScripthash, getUtxos, getUtxosForAddresses, broadcast } from "../lib/chain.js";
export { resolveName, buildIndex } from "../lib/resolver-web.js";
export {
describeGenesisRequirement,