diff --git a/src/gateway/public-gateway.mjs b/src/gateway/public-gateway.mjs index 2c9d52c..bdfa702 100644 --- a/src/gateway/public-gateway.mjs +++ b/src/gateway/public-gateway.mjs @@ -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, 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/")) { diff --git a/src/web/register-entry.js b/src/web/register-entry.js index ce6d522..fea8782 100644 --- a/src/web/register-entry.js +++ b/src/web/register-entry.js @@ -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,